ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

搜索引擎生态解析:从Meilisearch到Tantivy、Quickwit与LMDB的选型指南

搜索引擎生态解析:从Meilisearch到Tantivy、Quickwit与LMDB的选型指南 这五个名字放在一起看起来有点像某次技术讨论的散装笔记Meilisearch 是这几年人气很高的轻量级搜索引擎LMDB 是数据库圈里以“快”著称的内存映射存储Tantivy 是 Rust 生态里的全文检索库Quickwit 是为日志这类海量数据量身做的分布式搜索引擎。至于 Cellulite……这个名字通常不会出现在搜索引擎技术清单里它更像是一个误入代码仓库的“外来词”。但恰恰是这个“不和谐音”值得认真拆一拆。搜索系统的日常并不是把锦上添花的单词塞进索引而是要处理真实世界的脏数据、错别字、多义词以及和预期八竿子打不着的查询词。我接下来的内容就是把这些名字逐个展开每个项目到底解决搜索链路里的哪一环、实际跑起来是什么效果、不同规模的项目该怎么选型以及当 Cellulite 这种“跑偏词”真的进入检索时索引和排序引擎内部会发生什么。1. 把这五个名字按工程分层摆上桌搜索引擎不是单个软件而是一条由多个环节组成的链路。你可以把它想象成一家餐厅用户在前台点菜后厨要切菜配菜传菜员负责端上桌背后还得有食材仓库。Meilisearch、 Tantivy、 Quickwit、 LMDB 这四个名字恰好分别对应这条链路的不同角色而 Cellulite 则像一张“点错了菜但确实出现在菜单里”的单子——它考验的是系统如何对待意料之外的输入。1.1 一条搜索链路里通常站着四类组件最前端的组件是搜索 API 和业务接入层。它负责接收用户的查询词、做鉴权、把参数转发给检索核心再把结构化的 JSON 结果返回给前端页面或 App。Meilisearch 本身就是这一层的完整实现它自带 REST API、搜索 UI 以及简单的权限机制属于“前台连着后厨一起干”的选手。中间的检索核心负责两件事建索引和算相关性。Tantivy 就是典型的检索核心库它提供倒排索引、分词器、BM25 相关性打分等底层能力但不会帮你处理 HTTP 请求也不管数据从哪来。你可以把 Tantivy 想成一把锋利的厨刀Meilisearch 是半成品菜包而 Quickwit 则是一个中央厨房。再往下是存储层。LMDB 这种嵌入式键值存储不直接参与打分但它决定了索引和文档数据读写快不快、重启后能不能复原、高并发读撑不撑得住。很多搜索系统把自己的文档仓库、更新日志和临时状态放在 LMDB 里因为它的随机读延迟极低而且数据可靠性有保障。最上层或者说最后面是分发调度层。当数据量大到一台机器的内存装不下、索引写不过来时就需要 Quickwit 这类分布式系统它把索引分片、副本、查询合并、对象存储对接这些脏活全部包下。1.2 用一张表把五个名字先归位名字角色定位典型场景上手难度Meilisearch开箱即用的搜索后端站内搜索、应用内搜索、中小规模业务很低TantivyRust 版全文检索核心库自研搜索引擎、嵌入式检索、二次开发较高Quickwit云原生分布式日志搜索引擎日志分析、可观测性、PB 级时序数据中等LMDB嵌入式内存映射键值库索引存储、文档仓库、热数据缓存中等Cellulite典型的多义词/非技术查询词样本测试搜索相关性时的干扰词视情况而定这张表不是让你照单全收而是提供一个判断起点。很多项目其实只需要其中两三层一个小工具站用 Meilisearch 就够了想在 Rust 程序里嵌入搜索能力Tantivy 加 LMDB 是硬核组合要处理服务器上每天攒下来的千万行日志Quickwit 才是对胃口的菜。1.3 别急着选型先搞清楚你搜的是文档还是日志我见过不少团队上来就挑分布式搜索引擎结果发现自己一天只有几万条数据纯属杀鸡用牛刀。反过来也有团队拿 Meilisearch 硬扛日志检索数据一多就内存暴涨。这里有个最简单的分界线你要搜的是“业务对象”还是“机器产生的流水”。业务对象指商品、文章、用户、订单这类数据量级通常在百万到千万之间而且每个文档有明确的业务含义。这类需求优先考虑 Meilisearch 或 Tantivy因为它们对相关性、排序、过滤的支持更细搜索体验也更好。机器流水指日志、Trace、监控指标这类数据特点是量大、无严格结构、很少单独修改查询通常带时间范围。这类需求适合 Quickwit 一类按日志场景优化的引擎否则在存储成本上就会吃大亏。搞清楚这个前提后面几个章节的理解会顺很多。2. Meilisearch让搜索先跑起来再谈优化Meilisearch 的核心卖点说到底就四个字开箱即用。它使用 Rust 编写默认带一套合理配置不需要像 Elasticsearch 那样折腾 JVM 参数和分词插件你甚至不需要学习查询 DSL——一个简单的 REST API 就能完成绝大多数搜索需求。2.1 三分钟把一个可用搜索跑起来本地体验最快的方式是 Docker。在终端执行下面这行命令默认会监听 7700 端口docker run -p 7700:7700 getmeili/meilisearch如果你不想用 Docker官方也提供二进制安装。启动之后第一步是创建索引并写入数据。Meilisearch 的索引概念很简单一个索引就是一组文档集合每条文档是一个 JSON 对象字段随意。用 curl 写入数据像这样curl -X POST http://localhost:7700/indexes/articles/documents?primaryKeyid \ -H Content-Type: application/json \ -d [ {id: 1, title: Rust 搜索库对比, body: Tantivy 与 Meilisearch 的选型分析}, {id: 2, title: 日志搜索引擎实践, body: Quickwit 处理海量日志的思路} ]写入之后直接在浏览器打开http://localhost:7700你就能看到一个自带的搜索前端输入关键词即可拿到结果。这中间甚至没有建索引这一步操作Meilisearch 会在写入文档时自动分词、构建倒排索引并更新检索副本。Meilisearch 默认就开了“拼写容错”。比如文档里写的是 Tantivy你搜 Tantivy 少打一个字母依然能搜到。这个设置在传统搜索引擎里往往是需要额外调的在 Meilisearch 里却是标准行为。2.2 让结果更聪明typo tolerance、filter 与 ranking rules搜索能跑起来只是第一步真正让人满意的搜索要靠相关性排序。Meilisearch 默认使用一套内部排序规则包括词频、字段权重、文档位置、拼写容错度等这套规则叫 ranking rules。你可以调整顺序或关闭其中某些规则例如让精确匹配永远排在最前面curl -X PUT http://localhost:7700/indexes/articles/settings/ranking-rules \ -H Content-Type: application/json \ -d [words, typo, proximity, attribute, sort, exactness]这里的 words 表示词的匹配程度typo 表示拼写错误越少越靠前attribute 表示标题字段权重高于正文字段exactness 表示完全匹配优先。过滤和分面是另一个高频需求。比如只搜索某个分类下的文章同时统计各分类数量。Meilisearch 通过 filterableAttributes 声明可过滤字段后查询时加 filter 参数即可curl -X POST http://localhost:7700/indexes/articles/search \ -H Content-Type: application/json \ -d {q: 搜索, filter: category 技术, facets: [category]}分面返回的结果会告诉你每个 category 下的命中数量这在电商、内容站的筛选栏里几乎是标配功能。2.3 适不适合你Meilisearch 的边界Meilisearch 的优势是省心但省心是有代价的。当数据量突破千万甚至上亿级别或者对内存占用极度敏感它就会暴露出短板。它默认把索引和部分数据放在内存里以换取低延迟这意味着数据量越大内存成本越高。我自己的判断标准很简单如果团队只有两三个人、没有专职搜索工程师直接用 Meilisearch 准没错如果你们已经有 Elasticsearch 且稳定运行、只是对体验不满意那不一定值得迁移。搜索后端迁移的隐性成本往往被严重低估——你要重写同步逻辑、重调相关性参数、重新做测试数据评估。3. LMDB搜索系统底下那块“快”存储提到 LMDB很多人的第一反应是“数据库”。它全称 Lightning Memory-Mapped Database确实是个数据库但它是嵌入式、面向键值读写的内存映射数据库不适合当业务主库反而特别适合做搜索系统的底层仓库。3.1 内存映射数据库解决了什么问题传统数据库的读写要经过系统调用、文件缓存、日志落盘等路径每笔操作都有不小的固定开销。LMDB 的做法是把整个数据库文件直接映射到进程地址空间读取时不需要拷贝到用户态缓冲区CPU 直接访问映射内存就行。配合它底层的 B 树结构按键查找一条记录通常只需要几次内存比较延迟能压到微秒级。这种“零拷贝读取”对搜索系统极其重要。搜索引擎在查询时可能要随机读取上千个文档 ID 对应的元信息如果用普通数据库每一次读都是一次磁盘寻址加缓存拷贝性能会被拖垮。LMDB 则让这些随机读取变得接近纯内存操作。另外LMDB 使用 MVCC 并发控制。写者持有独占锁但读者在写者提交之前读到的是旧快照读操作不会被写阻塞。搜索引擎的典型负载就是“少量写入、大量读取”这套并发模型几乎是量身定做的。3.2 Meilisearch 为什么曾把 LMDB 当存储基座老版本的 Meilisearch 用 LMDB 存放文档数据、更新日志和内部状态。一个索引写入请求进来先落到 LMDB 的更新日志里后续再异步刷入索引结构。这样既保证了写入不丢又不阻塞主线程。不止 Meilisearch很多向量数据库、嵌入式分析引擎也喜欢拿 LMDB 当公共底座。原因有三点随机读极快适合“按文档 ID 取数据”这种访问模式。数据文件就是数据库本身备份、复制、迁移都简单改个文件名就能带走。对内存映射环境友好多个进程可以共享同一个只读数据库省下重复缓存。我用 LMDB 时最常用的一招是mdb_stat观察数据库状态。它能告诉你当前 B 树层数、页面使用率、记录数等关键指标是排查“为什么这个库文件那么大”的第一工具。3.3 和其他嵌入式键值库比选谁有讲究项目读写模型存储结构典型场景LMDB多读单写、零拷贝读B树读多写少、随机读密集RocksDBLSM 树、批量写优势大SSTable写多读多、数据量大SQLite关系型、事务完善B树需要 SQL、元数据管理BadgerDBGo 生态、LSM 树SSTableGo 服务内嵌使用我个人的经验是如果你的搜索业务典型模式是“写入少、查询多、查询要快”选 LMDB 收益最大如果写入频率很高、且可以容忍写放大选 RocksDB 更稳。不该上来就硬套“普遍最优”的结论存储选型向来是和访问模式绑定的。4. Tantivy自己动手实现倒排索引与 BM25 检索Tantivy 常被拿来和 Lucene 对比。你可以把它理解成“Rust 世界里的 Lucene”一个提供全文索引与检索能力的核心库。它不给你现成的服务端也不管理多少分片——但它给了你构建自定义搜索引擎所需的全部底层组件。4.1 倒排索引与 BM25两个必须懂的概念倒排索引的字面意思是“反向查找”正常我们拿着文档找词在哪里倒排索引则是拿着词去查文档。它的结构可以简单理解成一个映射表tantivy - [doc1, doc5, doc12] lmdb - [doc3, doc7] quickwit- [doc12, doc18]查询 “tantivy lmdb” 时引擎分别查这两个词的倒排链表再对链表做交集或并集操作最后用相关性算法打分排序。BM25 是目前最主流的相关性打分算法。它把词频、文档长度、逆向文档频率放进同一个公式里既保证“词出现越多得分越高”又防止长文档因为字多而占便宜。你不需要手写公式Tantivy 已经实现了标准的 BM25 打分器开箱就能用。4.2 用 Tantivy 建索引并搜索的最小示例先建一个项目并添加依赖或者直接在已有 Rust 工程里引入 tantivy。下面是一个最简版本use tantivy::collector::TopDocs; use tantivy::query::QueryParser; use tantivy::schema::{Schema, TEXT}; use tantivy::{doc, Index}; fn main() - tantivy::Result() { // 1. 定义 schema哪些字段参与索引类型是什么 let mut schema_builder Schema::builder(); let title schema_builder.add_text_field(title, TEXT); let body schema_builder.add_text_field(body, TEXT); let schema schema_builder.build(); // 2. 创建索引目录并写入文档 let index Index::create_in_dir(my_index, schema)?; let mut writer index.writer(50_000_000)?; writer.add_document(doc!(title 搜索引擎选型实践, body Meilisearch 和 Quickwit 的对比))?; writer.add_document(doc!(title 指纹集合, body LMDB 在日志索引中的应用))?; writer.commit()?; // 3. 构建查询 let reader index.reader()?; let searcher reader.searcher(); let parser QueryParser::for_index(index, vec![title, body]); let query parser.parse_query(搜索引擎)?; // 4. 搜索并输出前 10 条 let top_docs searcher.search(query, TopDocs::with_limit(10))?; for (_score, doc_address) in top_docs { let doc searcher.doc(doc_address)?; println!({:?}, doc); } Ok(()) }这段代码里的核心逻辑并不复杂定义 schema、写索引、提交、读索引、解析查询、搜索。但它背后已经把分词、倒排链表合并、BM25 打分全跑了一遍。要是换 Lucene你得先写 Java 工程再引入一堆依赖用 Tantivy一个主函数就够了。4.3 生产使用者的视角Quickwit 为什么选择 TantivyQuickwit 的底层索引就是建立在 Tantivy 之上的。这说明 Tantivy 不仅能做学习 Demo也扛得住生产级压力。Quickwit 选择 Tantivy 有几个现实原因首先Rust 没有像 Lucene 一样成熟的全文检索库Tantivy 是目前完成度最高的选择其次Tantivy 的数据结构对内存控制友好能跟 Quickwit 的对象存储架构配合最后Tantivy 的 query parser 和 collector 抽象足够灵活Quickwit 可以在不重写核心检索逻辑的情况下加入自己的分布式查询分布式语义。如果你要自研一个不算太大的搜索系统用 Tantivy 当核心是合理的。但要注意Tantivy 只保证单机索引和检索能力跨节点分布式、容错协调、数据同步都得自己造。工作量往往比预想的大。5. Quickwit为日志海啸重新设计的分布式搜索很多人第一次听说 Quickwit是在“相比 Elasticsearch 成本更低”的对比文章里。但它和 Elasticsearch 不是同类工具——Quickwit 专门面向日志、Trace、事件这类海量时序数据核心设计目标是在对象存储上跑搜索。5.1 日志搜索的痛点与架构差异日志数据和业务文档有本质区别。业务的每条记录有独立价值需要随机修改日志则是一次性追加极少回改查询往往带时间范围。传统方案把日志当成“普通文档”存进 Elasticsearch等于拿一整套昂贵的通用搜索系统去处理流水账存储成本会高得离谱。Quickwit 的核心思路是让对象存储成为主存储。索引分片以段segment为单位写入对象存储本地只留热数据和元数据。查询时按需拉取目标段而不是把所有数据常驻内存。这样一来冷数据再冷也只是存在对象存储里成本低一个数量级。为了在这种架构下保证查询效率Quickwit 在索引文件上大量使用了列式存储和位图索引让“按时间范围扫字段”这类常见日志查询不必全量加载文档。5.2 Quickwit 实际跑一遍索引、写入、查询先用官方脚本安装二进制curl -L https://install.quickwit.io | sh创建一个索引配置文件声明字段类型、分词器并用命令创建version: 0.8 index_id: my_log_index doc_mapping: field_mappings: - name: timestamp type: datetime fast: true - name: level type: text - name: message type: text timestamp_field: timestamp search_settings: default_search_fields: [message, level]quickwit index create --index-config my_log_index.yaml写入数据用 JSON 格式quickwit index ingest --index my_log_index --input logs.json然后搜索quickwit index search --index my_log_index --query level:error AND message:timeout如果你的数据源是 KafkaQuickwit 也有专门的 Source 配置能直接持续消费 Kafka 里的日志流不需要额外写消费者。这套流程已经从“学习工具”进入“生产可用”范畴。5.3 与 Elasticsearch 的对比以及成本账对比维度QuickwitElasticsearch存储成本低以对象存储为主高本地磁盘副本多索引更新面向追加适合日志支持实时更新查询语言简单查询语法丰富 Query DSL生态组件偏年轻集中日志场景成熟ELK 全家桶运维复杂度较低无 JVM 调优较高我实际测算过一个小集群的日志场景每天 10GB 日志Elasticsearch 方案大约需要三到四台 64GB 内存的机器Quickwit 方案可以缩到一台普通 16GB 机器加对象存储整体成本下降四到五成。当然代价是进阶功能少比如 Kibana 里的复杂可视化要自己做适配。6. 当 Cellulite 这样的“跑偏词”进入索引搜索系统会怎么处理现在把 Cellulite 单独拎出来。这个单词在日常语境里通常指皮肤状态的描述词和技术栈毫无关系。但真实的搜索请求里这种“跑偏词”每天都会出现。用户可能拼错、可能用了行业黑话、也可能只是想知道某个词为什么会出现在搜索结果里。处理这种东西才是搜索引擎从“能用”到“好用”的分水岭。6.1 当无关词出现在索引里会发生什么假设你的博客索引里有一篇文章提到“Cellulite”这个单词用户搜索它时BM25 会给它打一个不低的分——因为这个词在全局文档里越稀有IDF 权重越高。于是结果看起来像“匹配”实际上是误伤。这就是倒排索引的一个天然局限它只回答“词是否出现”不理解“出现是否合理”。Cellulite 这个词没有语义不能靠索引结构自动判断它属于医学美容领域还是软件工程领域。所有相关性工作都必须由上层策略来兜底。6.2 实战里怎么处理多义词与噪音词处理“跑偏词”的手段大致有四层。第一层是索引时做规范对文本分词、归一化大小写、去掉停用词让 Cellulite 这样的话题词不会因为大小写和标点被误建索引。第二层是查询时做纠偏引入同义词表、拼写纠正尝试把用户输入映射到索引里真实存在的词。第三层是相关性加权给标题、标签这类高可信字段更高的权重让“正文里随口提了一句”的词不至于喧宾夺主。第四层是数据层面的过滤如果某些词大量出现且点击率极低可以直接把它们加入负词典降低权重甚至不参与检索。实际项目里我更多会观察搜索日志。如果某个词连续一周进入 Top 查询词但零点击基本上就是噪音词。此时在查询入口做一次规则拦截比调整整个算法更省事。6.3 用 Meilisearch 演示一次拼写容错检索Meilisearch 对这类词有天然优势因为它的 typo tolerance 不要求精确匹配。先写入一条包含 Cellulite 的文档{id: 1, title: 误入技术列表的词语, body: Cellulite 这个单词在大部分搜索系统里会被当作普通文本索引}现在搜索好多形都会被匹配。比如搜celllulite多打一个 lMeilisearch 会尝试组合拆分字母并命中。这看起来是个小功能但恰恰是拼写错误处理能力的落地表现。如果想要“严格按短语精准匹配”Meilisearch 也支持用引号包裹查询词关闭拼写容错。这种开关放在用户体验上很实用用户主动加引号就说明他要精确结果系统应该尊重这个意图。7. 一套现代搜索链路的选型顺序与避坑心得最后把这五者串成一条完整的参考链路。不是每个项目都要全部用上但理解它们如何配合能帮你少走弯路。7.1 典型中型搜索架构参考一个中等规模的业务站数据量在千万级、吞吐每秒几十次查询我推荐的简化链路是数据入口业务数据库或消息队列文档变更写入 Kafka。索引构建消费 Kafka 数据经过清理、分词落到 Tantivy 生成的索引段。存储基座索引段连同文档元数据写入 LMDB保证随机读快、重启恢复快。搜索服务对外暴露 REST API查询走 Tantivy 检索用 LMDB 取文档详情。大日志场景再叠加日志类数据的采集和检索单独走 Quickwit和业务搜索互相不干扰。这样拆的好处是每一层都可以独立扩容和替换不会出现“换搜索体验必须重写全部业务逻辑”的窘境。7.2 选型与实施中的一些经验第一别一上来就追求“全部自己造”。如果有现成库和现成系统优先用。Tantivy 虽然灵活但你需要自己处理索引生命周期、段合并、并发锁、多租户隔离这些工作量通常比搜索引擎本身还大。第二相关性调优要有数据支撑。不要凭感觉修改权重。每次调参后准备一组真实查询词和期望结果跑回归对比。搜索系统的质量从来不是“改一个参数变好”而是“让绝大多数查询保持在期望状态”。第三存储选型和查询模式强绑定。读多写少、随机读密集优先 LMDB写入频繁、数据量持续增长优先 RocksDB 或 Quickwit。选型之前先统计真实的读写比例、数据量增长曲线别拍脑袋。第四冷热分离是成本关键。日志这类数据几乎不会回写放对象存储是天然合理选择业务数据则需要随机修改热数据放本地、冷数据归档即可。两者的边界必须从设计阶段就划清楚。我自己在实操里最常见的后悔时刻就是早期没把“哪些查询应该被排除”想清楚。很多时候搜索引擎慢不是因为倒排链表合并不够快而是因为把大量不相关文档也拉进了候选集。多做一层查询前置过滤比换数据库、调参数都见效快。这五个名字的旅程到这里其实更像一次搜索工程的地图浏览Meilisearch 帮我们快速上路LMDB 保证了底层不动摇Tantivy 提供了自定义的自由度Quickwit 替我们扛住了日志洪流而 Cellulite 则提醒了一个朴素道理——用户永远会给你带来意外把意外处理好的系统才叫成熟。
返回列表