最小倒排索引
主线位置:三条主线的缩小版。写入主线缩成「分词、塞进一个
HashMap、写一个文件」,磁盘主线缩成「二分查词、解码一段 VInt」,查询主线缩成「每个词算 BM25、累加、堆里留前 K 个」。每一步都对应着前面某一页讲的真实实现,本页只讲思路和关键片段,并说明 tantivy 在同一步多做了什么。
flowchart TB txt["文档文本"] --> tok["tokenize"] tok --> mem["HashMap 倒排表"] mem --> fin["finalize 写段文件"] fin --> seg["mini.seg"] seg --> open["open 读词表"] q["查询 old man"] --> tok2["tokenize"] tok2 --> look["二分查词"] open --> look look --> dec["解码倒排表"] dec --> bm["BM25 累加"] bm --> heap["小顶堆取前 K"]
代码只用标准库,lib.rs 共 186 行(exp-tantivy/design-mini/src/lib.rs,含注释),本页摘关键片段,完整实现放在对应课程里。为了能和 tantivy 对照,main.rs 用同一份语料再建一个只有 body 字段(TEXT)的 tantivy 索引,跑同一个查询。实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建;语料是 benches/wiki.json 的 1000 篇 body。
分词
pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
text
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty() && t.len() < 40)
.map(|t| t.to_lowercase())
}
这三步照抄的是 tantivy 默认分词器 default 的链:SimpleTokenizer 在非字母数字字符处切开,RemoveLongFilter::limit(40) 丢掉 40 字节及以上的词元,LowerCaser 转小写。两边分出来的词一致,后面的对照才有意义。真实的分词器是一条 TokenStream 链,每个词元还带位置和字节偏移,分词器按名字存在 schema 里,见分词。
内存倒排表
#[derive(Default)]
pub struct SegmentWriter {
postings: HashMap<String, Vec<(u32, u32)>>, // term -> [(doc, tf)], doc ids ascending
doc_lens: Vec<u32>, // doc -> number of tokens
}
impl SegmentWriter {
pub fn add_document(&mut self, text: &str) -> u32 {
let doc = self.doc_lens.len() as u32;
let mut len = 0;
for token in tokenize(text) {
len += 1;
let list = self.postings.entry(token).or_default();
match list.last_mut() {
Some((d, tf)) if *d == doc => *tf += 1,
_ => list.push((doc, 1)),
}
}
self.doc_lens.push(len);
doc
}
// ...
}
doc id 就是加入的顺序,所以每张倒排表天然按 doc id 递增,同一篇文档里重复出现的词只把最后一项的词频加一。doc_lens 记每篇的词元数,BM25 要用。
tantivy 的 SegmentWriter 做的是同一件事,但不用 HashMap<String, Vec<_>>:词和倒排表都放在 stacker 的 ArenaHashMap 和 MemoryArena 里,地址是 32 位偏移,内存用量可以精确统计,到了预算就切段(SegmentWriter 与内存池)。这里的 Vec<u32> 文档长度,在 tantivy 里压成每篇一个字节的 fieldnorm(fieldnorm 与删除位图),后面的对照会看到这一个字节的影响。
一个段文件
finalize 把词排好序,写成一个不再修改的文件:
[term 0 的倒排表][term 1 的倒排表]...[文档长度][词表][footer: 4 个 u64]
倒排表 = vint(n),再 n 组 (vint(doc 差值), vint(tf))
词表 = 每个词 vint(长度) + 字节 + vint(倒排表偏移) + vint(doc_freq)
关键是差分加变长整数:
for (term, list) in &terms {
table.push((term.as_str(), buf.len() as u64, list.len() as u32));
put_vint(&mut buf, list.len() as u64);
let mut prev = 0;
for &(doc, tf) in list {
put_vint(&mut buf, (doc - prev) as u64);
put_vint(&mut buf, tf as u64);
prev = doc;
}
}
最后 32 字节是 footer:文档长度区、词表区的起点,词数和文档数。读的时候先读 footer,再按偏移去取,和 tantivy 从文件尾部一层层剥的读法一样。
tantivy 在每一处都更进一步:
| 这里 | tantivy | 页面 |
|---|---|---|
| 一个文件装下所有东西 | 每种数据一个文件(.term .idx .pos .fieldnorm .store .fast),按字段分区 | 一个段 |
| 逐个 VInt 编码 doc 差值 | 128 个一块,差分后 bitpacking,只有尾块用 VInt;每块记跳表和 block-max | 倒排表 |
| 不存位置 | .pos 存每次出现的位置,短语查询用 | 位置 |
| 排好序的词表,读进内存二分 | FST 把词映射到序号,TermInfoStore 再查到偏移和 doc_freq,不必整表加载 | 词典 |
| footer 是 4 个 u64 | footer 是 JSON(版本、crc)+ 长度 + 魔数,前面还有一张按字段的分区表 | Directory |
File::create 后 sync_all | open_write 要求文件不存在,terminate 时同步,meta.json 原子替换 | 提交 |
同样 1000 篇,这个文件 461,073 字节;tantivy 的 body(TEXT,带词频和位置)一个段里,.term 205,698、.idx 190,176、.fieldnorm 1,105 字节,三者合计 396,979,另有 .pos 240,635 字节。
读回来:查词、解码
SegmentReader::open 用 std::fs::read 把整个文件读进 Vec<u8>,解析 footer、文档长度和词表。查一个词就是在排好序的词表上二分,再从偏移处解码:
fn postings(&self, term: &str) -> Option<(u32, Vec<(u32, u32)>)> {
let i = self.terms.binary_search_by(|(t, _, _)| t.as_str().cmp(term)).ok()?;
let (_, mut pos, df) = self.terms[i];
let n = get_vint(&self.bytes, &mut pos);
let mut doc = 0;
let list = (0..n)
.map(|_| {
doc += get_vint(&self.bytes, &mut pos) as u32;
(doc, get_vint(&self.bytes, &mut pos) as u32)
})
.collect();
Some((df, list))
}
两处和 tantivy 不同。一是整个文件读进堆里,打开的时间和内存都随索引变大;tantivy 用 mmap,打开只是映射,用到哪页读哪页(设计取舍里实测了代价)。二是一次把整张倒排表解码成 Vec;tantivy 的 SegmentPostings 一次只解一块 128 个,seek 先查跳表跳过整块(倒排表)。
BM25 和前 K 个
pub fn search(&self, query: &str, k: usize) -> Vec<(f32, u32)> {
const K1: f32 = 1.2;
const B: f32 = 0.75;
let n = self.doc_lens.len() as f32;
let mut scores: HashMap<u32, f32> = HashMap::new();
for term in tokenize(query) {
let Some((df, list)) = self.postings(&term) else { continue };
let idf = (1.0 + (n - df as f32 + 0.5) / (df as f32 + 0.5)).ln();
for (doc, tf) in list {
let len_norm = K1 * (1.0 - B + B * self.doc_lens[doc as usize] as f32 / self.avg_len);
*scores.entry(doc).or_default() += idf * (K1 + 1.0) * tf as f32 / (tf as f32 + len_norm);
}
}
let mut heap = BinaryHeap::new(); // min-heap of size k via Reverse
for (doc, score) in scores {
heap.push(Reverse((Score(score), Reverse(doc))));
if heap.len() > k {
heap.pop();
}
}
// ...
}
idf、K1、B 和 tantivy 的 bm25.rs 一字不差:tantivy 把 idf × (K1 + 1) 预先乘进权重,把 K1 × (1 − B + B × 长度 / 平均长度) 按 fieldnorm 的 256 种取值预先算成一张表,打分时查表(BM25)。平均长度两边都是「总词元数 ÷ 文档数」。查询的多个词是 OR:哪个词命中就加哪个词的分。堆里放 Reverse,堆顶是当前第 K 名,新来的比它好就挤掉它,最后排序输出。Score 是给 f32 补上全序的包装,Reverse(doc) 让同分时 doc id 小的排前面。
问: 用一个 HashMap 把所有命中文档的分数攒起来再挑前 K,tantivy 为什么不这么做?
答: 这个 HashMap 的大小等于命中的文档数,查一个常见词就要给几乎每篇文档开一项,违背了 ARCHITECTURE.md 里「Search should be O(1) in memory」那一条。tantivy 的倒排表按 doc id 有序,多个词的并集由 BufferedUnionScorer 在一个固定的窗口里合并:HORIZON 是 64 × 64 = 4096 个 doc id,窗口滑过去,分数缓冲区复用。收集一侧,TopNComputer 维护一个阈值,低于第 K 名的文档直接丢掉;纯 OR 查询还能用 block-max 信息整块跳过不可能进前 K 的文档(布尔查询、收集器)。内存只跟 K 和窗口大小有关,不跟命中数有关。
main.rs 先用这几段代码给 1000 篇 body 建索引、写出 mini.seg,再用 tantivy 给同样的文本建一个单段索引(QueryParser 只查 body),两边各取前 10,按名次并排打印,并附上每篇文档的长度。
old man,这个最小实现和 tantivy 给出的前 10 名一样吗?分词、idf、K1、B、平均长度都和 tantivy 一致,tantivy 那边只有一个段、没有删除。前 10 篇文档是不是同一批、顺序是不是相同、分数是不是相等?答案在文末。
这个最小实现没有的东西
每一项都是前面某一页的主题:
- 多个段与合并。 这里只有一个段,写完就结束。tantivy 每次提交至少多一个段,查询时逐段执行、再合并各段的结果(一次查询),后台按策略合并(合并)。
- 提交与可见性。 没有
meta.json、opstamp、原子替换和 reader 的重新加载(提交、IndexReader 与 Searcher)。 - 删除。 没有删除位图,删一篇只能重建(fieldnorm 与删除位图)。
- 多线程写入与内存上界。 一个线程、内存随语料增长(IndexWriter)。
- 原文与其他字段。 没有文档存储、快速字段和 schema(文档存储、快速字段、Schema 与字段)。
- 查询语言。 只有 OR,没有 AND、短语、前缀、模糊(查询解析器、短语与其他查询)。
动脑筋答案
连跑三次,输出相同(只有耗时不同,三次建索引 20.4、25.9、20.3 ms,查询 0.007 到 0.008 ms)。第一次的输出:
mini: 1000 docs, segment file 461073 bytes, build 20.4 ms, search 0.008 ms
rank mini(doc score) tantivy(doc score) len -> 1-byte fieldnorm
1 225 8.24297 225 8.30196 870 -> 856
2 948 7.49725 948 7.56005 1066 -> 1048
3 427 6.37763 427 6.39058 55 -> 54
4 796 5.98939 796 6.02377 87 -> 84
5 517 5.96643 517 5.96643 31 -> 31
6 54 5.84396 54 5.90903 679 -> 664
7 959 5.81015 959 5.81015 23 -> 23
8 319 4.83240 182 4.85172 42 -> 42 <- differs
9 182 4.81217 319 4.83240 352 -> 344 <- differs
10 762 4.72020 762 4.74418 155 -> 152
最后一列是最小实现这一行那篇文档的真实长度,以及 tantivy 把它压成一个字节再还原出来的长度(FieldNormReader::fieldnorm_to_id 再 id_to_fieldnorm)。
- 同一批文档,第 8、9 名对调。
- 长度能被一个字节精确表示的文档,分数完全相等:517(31)、959(23)、319(42)两边分数一字不差,说明公式、
idf和平均长度都对上了。 - 其余文档 tantivy 的分数都略高:fieldnorm 的编码向下取整(870 存成 856),长度变短,长度惩罚变小,分数变大。差多少取决于量化丢掉了多少长度:225 号 870 个词元存成 856,差 0.059;427 号 55 个存成 54,差 0.013。
- 第 8、9 名对调就是这样来的:182 号有 352 个词元,tantivy 当成 344,分数从 4.81217 升到 4.85172,超过了长度精确、两边都是 4.83240 的 319 号。
一个字节的 fieldnorm 换来的是每篇文档每个字段只占 1 字节、打分时查一张 256 项的表;代价是长文档之间的分数有这样的小偏差。编码细节见 fieldnorm 与删除位图。
小结
- 最小倒排索引就是四步:分词;按词把
(doc, tf)追加进内存表、记下每篇长度;排序后差分加 VInt 写成一个不再修改的文件;查询时二分查词、解码、按 BM25 累加、小顶堆留前 K。 - 分词照搬 tantivy 的
default链、BM25 照搬bm25.rs的常数和公式后,同样 1000 篇文档上前 10 是同一批,只有第 8、9 名因 fieldnorm 的一字节量化对调;长度能精确表示的文档分数完全相同。 - tantivy 在每一步都更进一步:内存池而不是
HashMap,128 个一块的 bitpacking 和跳表,FST 词典,mmap 而不是整读,窗口化的并集和带阈值的 TopN,让写入和查询的内存都有上界。 - 段合并、提交与可见性、删除、多线程写入、文档存储、快速字段和查询语言,这个最小实现都没有,各自对应前面的一页。