Rust in Everything
基于 tantivy 0.27.0

怎么读这一卷

主线位置:三条主线的总览。本页把写入、查询、磁盘三条主线各拆成几步,标出每一步落在哪个函数、哪一页讲它;再说明实验环境,哪些数字随核数变化;最后逐个核对 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:

项本卷说明
tag0.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.jsonindex/index.rsSchema 与字段、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.rsIndexWriter
3. add_document 从 Stamper 取一个 opstamp,把一个只含一篇文档的批次送进 channelindexer/index_writer.rs、stamper.rsIndexWriter
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.rsSegmentWriter 与内存池、提交
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.rsIndexReader 与 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.rsQuery、Weight、Scorer、BM25
4. 每个段一次 collect_segment;按分数取前 K 时走 collect_segment_top_k → weight.for_each_pruning,全是词项的 OR 在 BooleanWeight::for_each_pruning 里走 block_wandcollector/、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 按序号给出 TermInfotermdict/词典
4. .idx:TermInfo 里的偏移 → SegmentPostings,128 个文档一块 bitpackingpostings/、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,取多次里的最小值并注明负载
tantivycrates.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)和 hashbrownCargo.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 等于什么都没换。

📝NOTE
冷知识:词干提取在 0.27.0 换了一个库

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 写的,一次写入和一个段会讲到这一层;怎么写一个自定义插件不讲。
  • quickwit feature 下的 sstable 词典和异步接口:词典对照一句。
  • 聚合的各个类型、failpoints 测试、compare_hash_only、Windows 上的差异。

阅读路线

前置知识

  • 倒排索引:词 → 包含它的文档列表(倒排表,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。

💬 关联讨论

发起讨论
加载中...