怎么读这一卷
主线位置:三条主线的总览。本页把写入、查询、磁盘三条主线各拆成几步,标出每一步落在哪个函数、哪一页讲它;再说明实验环境,哪些数字随核数变化;最后逐个核对
Cargo.toml里的 feature,其中quickwit会换掉词典的格式,用它写的索引在默认构建下打得开、一查就报错。一次写入、一次查询和一个段在本机上把三条线各跑一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1["01 全景 再到 05"]
q -->|读懂实现| all[00 到 09 按顺序]
q -->|只关心写入| w["一次写入 再到 03"]
q -->|只关心格式| d["一个段 再到 04"]
锁定版本
tantivy 的仓库是一个 Cargo 工作区:主 crate tantivy 在根目录,九个子 crate(columnar、sstable、stacker、bitpacker、common、ownedbytes、query-grammar、tokenizer-api、jitexpr)在各自的目录里。它们一起打 tag,但版本号不统一。本卷锁 0.27.0:
| 项 | 本卷 | 说明 |
|---|---|---|
| tag | 0.27.0 | 带注释的 tag,指向 67889269c(2026-10-17,「Version bump and changelog for tantivy 0.27 (#3197)」) |
| crates.io 包 | 与 tag 相同 | tantivy-0.27.0 包里的 .cargo_vcs_info.json 记的是 67889269cae6… |
| 子 crate 版本 | 见源码地图 | tantivy-columnar、-sstable、-stacker、-bitpacker、-common、-query-grammar 是 0.27.0;tantivy-tokenizer-api 是 0.7.0;ownedbytes 是 0.9.0;jitexpr 是 0.1.0 |
| 上一个版本 | 0.26.x 在单独的维护分支上:0.26.0(2026-03-31)、0.26.1(2026-04-21)、0.26.2(2026-06-18) | git merge-base --is-ancestor 0.26.1 0.27.0 返回假;本站原来用 0.26.1,现已升到 0.27.0,见 08 在本站中 |
| tag 之后 | main 到 360a5ebbb(2026-10-27)有 7 个提交 | 没有发版,本卷不跟;涉及时用 git log 0.27.0..origin/main -- <文件> 核实,提一句 |
源码链接一律指向 tag:https://github.com/quickwit-oss/tantivy/blob/0.27.0/<路径>#L<行>,行号是条目开始的那一行。子 crate 的路径在同一个仓库里,如 stacker/src/memory_arena.rs。
仓库里有 README.md、ARCHITECTURE.md、CHANGELOG.md、doc/src/ 下一本没写完的小书和 examples/ 下 23 个例子。本卷把它们当资料读,引用前在源码或实验里核实。举一个例子:Index::writer 的文档说锁文件已存在时返回 Error::FileAlreadyExists,0.27.0 里没有这个变体;在同一个目录上开第二个 writer,实测得到的是:
second writer: Failed to acquire Lockfile: LockBusy. Some("Failed to acquire index lock. If you are using a regular directory, this means there is already an `IndexWriter` working on this `Directory`, in this process or in a different process.")
而且锁文件 .tantivy-writer.lock 在 writer 释放之后还留在目录里:MmapDirectory::acquire_lock 用的是 flock,锁在文件句柄关闭时释放,文件本身不删。directory_lock.rs 里「This lock file, when present, is in charge of preventing other processes...」的说法对 MmapDirectory 不成立,看文件在不在判断不了有没有 writer。
三条主线
写入主线:加 3 篇文档,提交一次
flowchart TB create["create_in_dir"] --> writer["Index writer 起线程"] writer --> add["add_document 打 opstamp"] add --> ch["有界 channel"] ch --> sw["SegmentWriter 进内存池"] sw --> pc["prepare_commit 收段"] pc --> fin["finalize 写六个文件"] fin --> su["SegmentUpdater 写 meta"] su --> gc["GC 与考虑合并"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. Index::create_in_dir 打开 MmapDirectory,写下只有 schema、没有段的 meta.json 和 .managed.json | index/index.rs | Schema 与字段、Directory |
2. Index::writer:线程数取 min(available_parallelism, 8),每线程预算不足 15 MB 时改成 预算 / 15 MB;writer_with_options 拿 .tantivy-writer.lock,IndexWriter::new 建容量 10000 的 crossbeam channel、起索引线程、一个 segment_updater 线程和 4 个 merge_thread_* | index/index.rs、indexer/index_writer.rs | IndexWriter |
3. add_document 从 Stamper 取一个 opstamp,把一个只含一篇文档的批次送进 channel | indexer/index_writer.rs、stamper.rs | IndexWriter |
4. 索引线程在 index_documents 里调 SegmentWriter::add_document:倒排索引(分词、词频、位置、fieldnorm)、快速字段、文档存储各记一份 | indexer/segment_writer.rs、postings/、stacker/ | 分词、SegmentWriter 与内存池 |
5. prepare_commit 换掉 channel,等每个索引线程把手里的段定稿(finalize_inner)成文件,再立刻起一批新线程 | indexer/index_writer.rs、segment_writer.rs | SegmentWriter 与内存池、提交 |
6. PreparedCommit::commit 交给 SegmentUpdater::schedule_commit:落删除 → 原子写 meta.json → 垃圾回收 → 考虑合并 | indexer/segment_updater.rs | 提交 |
7. consider_merge_options 问 LogMergePolicy 要合并候选,交给合并线程 | indexer/segment_updater.rs、log_merge_policy.rs、merger.rs | 合并 |
查询主线:old man,取前 10
flowchart TB rd["IndexReader"] --> se["Searcher 快照"] se --> qp["QueryParser 解析"] qp --> wt["query weight 算 BM25 统计"] wt --> seg["每段 Scorer"] seg --> col["TopDocs 剪枝收集"] col --> mg["合并各段"] mg --> doc["searcher doc 取原文"]
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. Index::reader:默认 ReloadPolicy::OnCommitWithDelay,起一个线程每 500 ms 算一次 meta.json 的 crc32;searcher() 拿到一组 SegmentReader 的快照 | reader/mod.rs、core/searcher.rs | IndexReader 与 Searcher |
2. QueryParser::parse_query:query-grammar 解析出 UserInputAst,按默认字段和分词器展开成 LogicalAst,再变成 BooleanQuery(默认 OR) | query-grammar/、query/query_parser/ | 查询解析器 |
3. Searcher::search → search_with_executor:query.weight() 用整个 Searcher 的文档数和词频算 BM25 权重 | core/searcher.rs、query/bm25.rs | Query、Weight、Scorer、BM25 |
4. 每个段一次 collect_segment;按分数取前 K 时走 collect_segment_top_k → weight.for_each_pruning,全是词项的 OR 在 BooleanWeight::for_each_pruning 里走 block_wand | collector/、query/boolean_query/ | 布尔查询、收集器 |
5. merge_fruits 合并各段的前 10;Searcher::doc 从文档存储解压一个块取出原文 | collector/、store/reader.rs | 文档存储 |
磁盘主线:一个词怎么找到
| 步骤 | 源码 | 讲它的页 |
|---|---|---|
1. 目录里有什么:meta.json、.managed.json、两个锁文件,每个段六个 <uuid>.<扩展名> 文件 | index/segment_component.rs、index/index_meta.rs | 一个段 |
2. ManagedDirectory::open_read mmap 文件、切掉末尾的 footer、检查版本;crc32 只在 Index::validate_checksum 时算 | directory/ | Directory |
3. .term:TermDictionary::get,FST 给出词的序号,TermInfoStore 按序号给出 TermInfo | termdict/ | 词典 |
4. .idx:TermInfo 里的偏移 → SegmentPostings,128 个文档一块 bitpacking | postings/、bitpacker/ | 倒排表 |
5. .pos:PositionReader,短语查询才读 | positions/ | 位置 |
6. .fieldnorm:每篇文档每个字段一个字节,BM25 的 dl 从这里来 | fieldnorm/ | fieldnorm 与删除位图 |
7. .store:StoreReader::get,16 KB 一块、lz4 压缩 | store/ | 文档存储 |
8. .fast:列存,排序、聚合、范围查询用 | fastfield/、columnar/ | 快速字段 |
三条线在 SegmentReader 交汇:写入主线生产段,磁盘主线说明段长什么样,查询主线在每个段上各跑一遍再合并。
实验环境
| 项 | 本卷 |
|---|---|
| 平台 | M2 Max、macOS 27、rustc 1.99.0;12 核(8 性能核 + 4 能效核),available_parallelism() 是 12;写明 debug 或 release;计时用 release,取多次里的最小值并注明负载 |
| tantivy | crates.io 上的 =0.27.0,默认 feature;模板另带 tantivy-jieba 0.19、tempfile、serde_json、rand 0.9、criterion 0.5;对照本站升级前的版本时另有一份 =0.26.1 的模板 |
| 语料 | 仓库 benches/ 下的 wiki.json(1000 篇英文维基摘要,1.1 MB)、hdfs.json、gh.json、alice.txt;中文用本站内容仓库的 Markdown,页面上只给统计数字 |
| 默认例子 | examples/basic_search.rs 的三篇文档(《老人与海》《人鼠之间》《弗兰肯斯坦》),title 是 TEXT | STORED,body 是 TEXT;查询 old man |
所有实验都只在 macOS 上做。会随机器变化的数字页面上会注明:
Index::writer(budget)的索引线程数是min(核数, 8)再受预算限制,本机writer(50_000_000)是 3 个,writer(200_000_000)是 8 个;同样 3 篇文档,提交后的段数就是拿到文档的线程数,见一次写入。- 文件大小、
meta.json里的段 UUID 每次运行都不同;目录清单和meta.json引用的都是同一次运行的原样输出。 - 计时受并行跑的其他实验影响,页面上的耗时都注明次数和取法。
feature 各打开什么
Cargo.toml 的 [features] 一共 11 个,默认开前五个:
| feature | 打开什么 | 关掉或打开时的变化 |
|---|---|---|
mmap(默认) | 依赖 fs4、tempfile、memmap2;编译 MmapDirectory,以及 Index::create_in_dir、open_in_dir、create_from_tempdir | 关掉后只剩 RamDirectory 和你自己实现的 Directory,src/ 里有 19 处 cfg(feature = "mmap") |
stopwords(默认) | 编译 stop_word_filter/stopwords.rs 里各语言的停用词表,StopWordFilter::new(Language) 才存在 | 默认分词器不用它;要自己把 StopWordFilter 加进分词链 |
lz4-compression(默认) | 依赖 lz4_flex;文档存储的默认压缩器是 Compressor::Lz4 | 关掉后 Compressor::default() 依次退到 zstd(如果开了)或 None |
columnar-zstd-compression(默认) | 转发 tantivy-columnar 的 zstd-compression,再转到 tantivy-sstable:sstable 块超过 2048 字节时用 zstd 压 | 影响快速字段里字符串和字节列的字典 |
stemmer(默认) | 依赖 frostem(18 种语言);TokenizerManager::default() 才注册 en_stem | 关掉后用 en_stem 的字段会找不到分词器;本站用 jieba,不受影响 |
zstd-compression | 依赖 zstd;Compressor::Zstd 可用于文档存储 | 只开它不改默认值,要在 IndexSettings::docstore_compression 里选 |
quickwit | 打开可选依赖 sstable、futures-util、futures-channel:词典从 FST 换成 sstable,多出 doc_async 等异步读取接口 | 写出来的索引默认构建读不了,见下文;src/ 里 33 处 |
jitexpr | 依赖子 crate jitexpr(Cranelift 写的表达式 JIT)和 hashbrown | Cargo.toml 注释「EXPERIMENTAL. The API is likely to change」;本卷只在聚合页提一句 |
failpoints | 依赖 fail 并开 fail/failpoints:fail_point! 宏才展开 | 只给 tests/failpoints/ 用 |
compare_hash_only | 转发 stacker/compare_hash_only:索引时的哈希表只比 64 位 ahash,不比词本身 | 注释写明「may lead to extremely rare missing terms」 |
unstable | 空 feature,#[cfg(all(test, feature = "unstable"))] 下的 bench 用 | 与使用无关 |
sstable、fail、memmap2 这些不在 [features] 里的名字也能写进依赖方的 features:它们是可选依赖自动生成的同名 feature。卷目录里「sstable feature 下的另一种词典」的说法不对:termdict/mod.rs 按 quickwit 选择 fst_termdict 还是 sstable_termdict,src/ 里没有一处 cfg(feature = "sstable")。只开 sstable 等于什么都没换。
stemmer 这个 feature 在 0.26.x 打开的是 rust-stemmers。0.27.0 收进了 #3028(3a55bc986,「Replace rust-stemmers with frostem for stemming」),Cargo.toml 里 stemmer = ["rust-stemmers"] 改成了 stemmer = ["frostem"],并显式列出 18 种语言。提交说明给的理由是「rust-stemmers is unmaintained and lags behind upstream Snowball. frostem tracks snowball main and regenerates algorithms automatically.」,CHANGELOG 在 Bugfixes 里补了一句「fixing a panic on some Greek words」。首次发布于 0.27.0。所以上表的 stemmer 一行在本站的 0.26.1 上对应的是另一个库;Language 枚举的 18 个变体里有阿拉伯语、希腊语、俄语、泰米尔语,README 里「stemming available for 17 Latin languages」的说法和代码对不上。
quickwit 写的索引,默认构建读不了
实验 intro-qw 开 features = ["quickwit"],用一个线程写一篇文档、提交;然后用默认 feature 的 intro-run 打开同一个目录,按一次查询的方式查 old man(release):
written with quickwit: tantivy v0.27.0, index_format v7
segments: 1
seg 0: e6e7ce4fc00c41f0b992146dae1fcd89 max_doc=1
query: BooleanQuery { subqueries: [(Should, TermQuery(Term(field=0, type=Str, "old"))), (Should, TermQuery(Term(field=1, type=Str, "old"))), (Should, TermQuery(Term(field=0, type=Str, "man"))), (Should, TermQuery(Term(field=1, type=Str, "man")))], minimum_number_should_match: 1 }
Error: IoError(Custom { kind: Other, error: "Unsupported dictionary type, compiled tantivy with Fst, but got SSTable" })
两边的 version_string() 一样,index_format 都是 7,meta.json 里也没有任何字段记录词典的类型;Index::open_in_dir、建 reader、拿 searcher 都成功,第一次读某个字段的词典时才报错。原因在 TermDictionary::open:每个字段的词典末尾 4 字节是一个 u32 类型号(DictionaryType::Fst = 1、SSTable = 2),打开时和编译期的 CURRENT_TYPE 比较,不同就报上面这条错。如果在依赖图的某处有 crate 开了 quickwit,feature 统一会让整个程序换成 sstable 词典,此前写的索引就都不能查了。
不讲什么
jitexpr子 crate(15605 行):只在聚合提一句。SegmentPlugin的自定义扩展:0.27.0 起三个内置组件(倒排索引、快速字段、文档存储)本身就是用这个 trait 写的,一次写入和一个段会讲到这一层;怎么写一个自定义插件不讲。quickwitfeature 下的 sstable 词典和异步接口:词典对照一句。- 聚合的各个类型、
failpoints测试、compare_hash_only、Windows 上的差异。
阅读路线
- 只想会用:01 全景 的四页;Schema 与字段、分词;提交;IndexReader 与 Searcher、查询解析器、收集器。
- 想读懂实现:按顺序读。02 讲清楚一篇文档变成哪些
Term,03 讲清楚它们怎么进内存池、怎么写成段,04 的格式才好读;05 在 04 的格式上跑。 - 只关心写入:一次写入 → IndexWriter → SegmentWriter 与内存池 → 提交 → 合并。
- 只关心磁盘格式:一个段 → Directory → 词典 → 倒排表 → 位置 → 文档存储 → 快速字段 → fieldnorm 与删除位图。
前置知识
- 倒排索引:词 → 包含它的文档列表(倒排表,posting list);词典(term dictionary)负责从词找到倒排表。
- BM25 的大意:词在文档里出现得越多、在整个语料里越少见、文档越短,分越高。公式在 BM25 里逐项对代码。
- Rust:trait 对象、
Send/Sync、Arc;标准库线程和 channel。tantivy 不依赖 tokio,cargo tree -p tantivy@0.27.0里没有它;写入用自己的线程和crossbeam-channel,SegmentUpdater、合并和多线程查询用rayon线程池。 - mmap:文件映射进地址空间,读多少由操作系统按页调入。tantivy 的读路径几乎都建立在这上面。
- 读过 serde 卷 会帮助理解 文档 一页,读过 bytes 卷 会帮助理解
OwnedBytes。
小结
- 锁 tag
0.27.0(67889269c),crates.io 包就是这个 commit;子 crate 版本号不统一;0.26.x 是另一条分支;之后 main 上的 7 个提交不跟。 - 写入主线:
create_in_dir→writer起线程拿锁 →add_document打 opstamp 进 channel →SegmentWriter→prepare_commit收段、定稿 →SegmentUpdater落删除、写meta.json、GC、考虑合并。 - 查询主线:
reader→Searcher快照 →QueryParser默认 OR →weight用全局统计 → 每段Scorer,TopK 走 Block-Max WAND → 合并 →doc解压原文。 - 磁盘主线:footer →
.term→.idx→.pos→.fieldnorm→.store→.fast。 - 默认 feature 是
mmap、stopwords、lz4-compression、columnar-zstd-compression、stemmer;换词典格式的是quickwit不是sstable,quickwit写的索引在默认构建下查询时报Unsupported dictionary type。 - 锁文件
.tantivy-writer.lock是flock,文件会一直留着;第二个 writer 报LockBusy,不是文档说的FileAlreadyExists。