为什么是 tantivy
主线位置:三条主线的外面。本页回答全文检索要解决什么、tantivy 替你做了哪些、留给你的是什么;它和 Lucene 的概念怎么对应;和几种常见的全文检索方案比,差别落在哪里;最后是
ARCHITECTURE.md写的设计原则在代码里的样子。
flowchart TB app["你的程序"] --> w["IndexWriter 写入"] app --> s["Searcher 查询"] w --> segs["不可变段"] w --> meta["meta.json"] s --> segs segs --> dir["Directory mmap"] qw["Quickwit 等服务"] --> app
一个 tantivy 索引像一个文件夹,里面放着一本本装订好的小册子(段,segment),还有一张目录页(meta.json)写着「现在算数的是哪几本」。写入的人先在草稿纸上记(索引线程的内存池),攒够了或者要交卷时(commit)把草稿装订成新册子放进文件夹,再换一张新的目录页。装订好的册子不再改:要删一篇,就在册子旁边夹一张作废名单(.del 删除位图);薄册子太多时,有人把几本重抄成一本厚的(合并),再改目录页。来查资料的人复印一份当时的目录页(Searcher 快照),只翻目录页上的那几本;换了目录页也不影响他手里那份。
全文检索要解决什么
ARCHITECTURE.md 开头给的问题是:「Given a large set of textual documents, and a text query, return the K-most relevant documents in a very efficient way.」要快,就得事先建索引;要「最相关」,就得有打分。tantivy 的答案是倒排索引加 BM25:每个词对应一张「包含它的文档」的表,查询时只看查询里那几个词的表,用 BM25 算分,取前 K 个。一次查询里,old man 在三篇文档里只碰了两张表,explain 把分数拆到了每一项。
它的范围不止于此。同一份文档还可以存进按列存储的快速字段(fast field),用来排序、过滤、按值聚合;ARCHITECTURE.md 举的例子是电商的分面计数、房产网站的每平米均价,甚至「use tantivy to power an OLAP database」。src/ 里最大的目录是 aggregation/(36320 行),比查询本身还大,见源码地图。
库,不是服务
README 的定位很直接:「Tantivy is closer to Apache Lucene than to Elasticsearch or Apache Solr in the sense it is not an off-the-shelf search engine server, but rather a crate that can be used to build such a search engine.」doc/src/avant-propos.md 的说法是「Library means that you will have to write code」。落到依赖上:
- 没有网络层。
cargo tree -p tantivy@0.27.0里没有 HTTP 库,也没有 tokio。你在自己的进程里调IndexWriter::add_document、Searcher::search。 - 没有分布式。README 的 Non-features 一节写着「Distributed search is out of the scope of Tantivy」,指向建在它上面的 Quickwit。多个索引怎么分片、怎么合并结果,是上层的事。
- 同步 API,自己管线程。写入用标准库线程和
crossbeam-channel,后台任务用rayon,一次写入列出了writer()之后进程里的全部 9 个线程。放进 async 程序时,要自己把调用挪到阻塞线程池里。 - 扩展点是 trait。
avant-propos.md说要扩展功能「most frequently this will mean writing your ownCollector, your ownScoreror your ownTokenFilter」。对应的 trait 是Collector、Scorer、tantivy-tokenizer-api的Tokenizer/TokenFilter,以及存储层的Directory;0.27.0 又加了SegmentPlugin,可以往段里加自己的文件。
本站的站内搜索就是这样用它的:搜索模块在自己的进程里建一个 MmapDirectory 索引,分词器换成 tantivy-jieba,见 08 在本站中。
和 Lucene 的对应
ARCHITECTURE.md 说 tantivy「is by no means a port of Lucene, its architecture is strongly inspired by it」,读过 Lucene 的人会发现名词几乎一样:
| tantivy | Lucene | 说明 |
|---|---|---|
Index:一个目录,meta.json 列出当前的段 | 一个目录,segments_N 是提交点 | 两者都是「不可变段 + 一个指明当前版本的小文件」 |
Segment,文件名 <uuid>.<扩展名> | segment,文件名 _N.<扩展名> | 见一个段 |
IndexWriter、commit、MergePolicy(默认 LogMergePolicy) | IndexWriter、commit、MergePolicy(默认 TieredMergePolicy) | 见 03 写入 |
IndexReader → Searcher | DirectoryReader → IndexSearcher | |
Directory、MmapDirectory、RamDirectory | Directory、MMapDirectory、ByteBuffersDirectory | tantivy 的读接口返回 FileSlice,不是可 seek 的输入流 |
TextAnalyzer = Tokenizer + TokenFilter 链 | Analyzer = Tokenizer + TokenFilter 链 | 见分词 |
Query → Weight → Scorer | Query → Weight → Scorer | 三层的分工相同,见 Query、Weight、Scorer |
Collector / SegmentCollector | Collector / LeafCollector | 每个段一个子收集器,最后合并 |
快速字段(.fast) | DocValues | README 原话:「equivalent of doc values in Lucene」 |
文档存储(.store) | stored fields | |
fieldnorm(.fieldnorm,一个字节) | norms | |
删除位图(.del,存活的位为 1) | live docs(.liv) | |
BM25,k1 = 1.2、b = 0.75 写死 | BM25Similarity(Lucene 6 起的默认),参数可调 | ARCHITECTURE.md:「The relevance score implemented in the tantivy is not configurable」 |
能从 tantivy 这一侧核实的差别有三处:
- schema 是严格的,而且一对一。
Schema在建索引时定下,存进meta.json,每个字段的类型和索引方式固定。ARCHITECTURE.md的 Limitations 写明「tantivy's schema imposes a 1:1 relationship between a field that is being ingested and a field represented in the search index」:同一段文本想用两种分词器各索引一次,要自己复制成两个字段。 - 文件是切片,不是流。
ARCHITECTURE.md:「Contrary to Lucene however, "files" are quite different from some kind ofio::Readobject.」Directory::open_read返回FileSlice,读出来是OwnedBytes;doc/src/basis.md承认这个 trait「somewhat assumes that your data can be mapped to memory」,缓存全交给操作系统。见 Directory。 - 没有主键,也没有 update。 删除只能按
Term(或者按查询,delete_query在文档里是隐藏的),ARCHITECTURE.md:「Tantivy does not offer any notion of primary id」。更新一篇文档就是先delete_term再add_document;IndexWriter::run能把一个Delete和一个Add放进同一批、拿到连续的 opstamp,但仍是一删一增两个操作。见提交和 06 设计取舍。
README 还有一句「Tantivy is approximately 2x faster than Lucene」,依据是外部的基准测试仓库,本卷离线核实不了,不引用这个数字。
和其他全文检索方案比
| 方案 | 形态 | 索引存在哪 | 打分 | 和 tantivy 的关系 |
|---|---|---|---|---|
| tantivy | Rust 库,嵌进你的进程 | 自己的目录:段文件 + meta.json | BM25 | |
| SQLite FTS5 | SQLite 的虚拟表模块,用 SQL 的 MATCH 查 | 同一个数据库文件里的影子表 | 内置 bm25() 辅助函数 | 都是嵌入式;FTS5 的写入跟着 SQLite 的事务走,tantivy 的写入要等 commit 才可见 |
| PostgreSQL 全文检索 | 数据库内置:tsvector、tsquery,GIN 或 GiST 索引 | 表和索引里 | ts_rank、ts_rank_cd,不是 BM25 | ParadeDB 的 pg_search 扩展把 tantivy 嵌进 PostgreSQL;README 的用户名单里有 ParadeDB,0.27.0 的 SegmentPlugin(#2993)提交说明也写着「We (ParadeDB) plan on using this trait」 |
| meilisearch | Rust 写的搜索服务,HTTP API | 自己的存储,基于 LMDB | 一串排序规则(词、拼写容错、距离等),不是单一的 BM25 分数 | 同是 Rust,但不用 tantivy |
| Elasticsearch | Java 写的分布式搜索服务,REST API | 每个分片是一个 Lucene 索引 | BM25(Lucene 的默认相似度) | 对应关系是「Elasticsearch 之于 Lucene ≈ Quickwit 之于 tantivy」,README 开头就这样介绍 Quickwit |
这张表只列形态上的差别,不比快慢:不同方案的基准结果取决于语料、查询和部署,本卷没有可复现的对比数据。从形态上看选择大致是:已经有 SQLite 或 PostgreSQL、数据量不大、要事务一致,就用数据库自带的全文检索;要一个开箱即用、带 HTTP 接口的服务,就用 meilisearch 或 Elasticsearch;要把检索嵌进自己的 Rust 程序、控制分词、打分和收集的每一步,或者要自己搭一个搜索服务,用 tantivy。
设计原则:内存有上界
ARCHITECTURE.md 列了三条原则:
- 「Search should be O(1) in memory.」
- 「Indexing should be O(1) in memory. (In practice it is just sublinear)」
- 「Search should be as fast as possible」
紧接着是代价:「This comes at the cost of the dynamicity of the index: while it is possible to add, and delete documents from our corpus, the tantivy is designed to handle these updates in large batches.」
在代码里,写入一侧的上界是每个索引线程的内存预算。index_documents 每处理完一批就检查一次:
let mem_usage = segment_writer.mem_usage();
if mem_usage >= memory_budget - MARGIN_IN_BYTES {
debug!(
"Buffer limit reached, flushing segment with maxdoc={}.",
segment_writer.max_doc()
);
break;
}
到了预算就把当前段写到磁盘,换一个新段,内存池从头开始。所以写入的内存跟预算走,不跟语料走。实验 bulk 用一个索引线程、15 MB 预算,把 wiki.json 的 1000 篇重复 k 次写入,一次提交(release,/usr/bin/time -l 的最大常驻内存):
docs=5000 text_bytes=5348365 segments=4
33292288 maximum resident set size
docs=20000 text_bytes=21393460 segments=2
67911680 maximum resident set size
docs=80000 text_bytes=85573840 segments=8
75251712 maximum resident set size
docs=160000 text_bytes=171147680 segments=8
132464640 maximum resident set size
文本从 5 MB 涨到 171 MB(32 倍),最大常驻内存从 33 MB 涨到 132 MB(4 倍)。不是常数:后台合并线程要打开参与合并的段,已经写出的段 mmap 进来以后,被读到的页也算进常驻内存。这就是括号里那句「In practice it is just sublinear」。
查询一侧没有「把索引加载进来」这一步:段文件 mmap 进来,用到哪页读哪页;TopDocs 只留一个大小为 K 的堆;文档存储每个段最多缓存 100 个解压过的块(DOCSTORE_CACHE_CAPACITY)。一个段末尾实测了打开一个小索引到拿回第一篇原文的耗时,热缓存下在 1 ms 以内。
代价那一半落在一次写入里:三篇文档要等 commit 才能被查到,每次提交至少写一个段、六个文件,并同步改写 meta.json;一篇一提交的用法会留下一堆小段。批量写入、批量提交,是这套设计期待的用法。
小结
- tantivy 解决的是「从大量文档里快速找出最相关的 K 篇」,用倒排索引加 BM25;快速字段和聚合让它也能做过滤、排序和统计。
- 它是库:没有网络层、没有分布式、没有 tokio,同步 API,扩展点是
Collector、Scorer、Tokenizer、Directory、SegmentPlugin这些 trait;服务形态是 Quickwit 这样的上层项目。 - 名词几乎都能对上 Lucene:段、
IndexWriter、Query/Weight/Scorer、Collector、DocValues 对快速字段、stored fields 对文档存储、norms 对 fieldnorm;差别在严格的一对一 schema、FileSlice而不是流、没有主键和 update、BM25 参数写死。 - 和 SQLite FTS5、PostgreSQL 全文检索、meilisearch、Elasticsearch 的差别在形态(库还是服务、嵌在数据库里还是独立)、存储位置和打分方式;本卷不比快慢。
- 设计原则是内存有上界:写入按每线程预算切段,实测文本涨 32 倍、常驻内存涨 4 倍;查询直接读 mmap。代价是更新要成批,提交后才可见。