Rust in Everything
基于 tantivy 0.27.0

最小倒排索引

主线位置:三条主线的缩小版。写入主线缩成「分词、塞进一个 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 个 u64footer 是 JSON(版本、crc)+ 长度 + 魔数,前面还有一张按字段的分区表Directory
File::create 后 sync_allopen_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 小的排前面。

📝NOTE
没有蠢问题

问: 用一个 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,按名次并排打印,并附上每篇文档的长度。

❗IMPORTANT
动脑筋:同一份语料、同一个查询 old man,这个最小实现和 tantivy 给出的前 10 名一样吗?

分词、idf、K1、B、平均长度都和 tantivy 一致,tantivy 那边只有一个段、没有删除。前 10 篇文档是不是同一批、顺序是不是相同、分数是不是相等?答案在文末。

这个最小实现没有的东西

每一项都是前面某一页的主题:

动脑筋答案

连跑三次,输出相同(只有耗时不同,三次建索引 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,让写入和查询的内存都有上界。
  • 段合并、提交与可见性、删除、多线程写入、文档存储、快速字段和查询语言,这个最小实现都没有,各自对应前面的一页。

💬 关联讨论

发起讨论
加载中...