Rust in Everything
基于 tantivy 0.27.0

fieldnorm 与删除位图

主线位置:查询主线里 TermScorer 给每个命中文档打分时,查一次这个文档在该字段上的 fieldnorm,作为 BM25 公式里的文档长度;收集结果时,再用存活位图把已删除的文档挡掉。写入主线里,commit 把 delete_term 落成 .del 文件。磁盘主线上这是 .fieldnorm 和 .del 两种文件。

flowchart TB
  tok["字段的词元数"] --> rec["FieldNormsWriter::record"]
  rec --> fid["fieldnorm_to_id"]
  fid --> fnf[".fieldnorm 每篇 1 字节"]
  fnf --> fnr["FieldNormReader"]
  fnr --> bm["Bm25Weight 查表"]
  del["delete_term"] --> adv["advance_deletes"]
  adv --> bits["BitSet 存活位"]
  bits --> delf["uuid.opstamp.del"]
  delf --> alive["AliveBitSet"]
  bm --> col["收集时过滤"]
  alive --> col

两样东西放在一页,因为它们是段里仅有的两个「每篇文档一个小值」的结构:fieldnorm 每篇每字段 1 字节,存活位图每篇 1 位。代码都不长:src/fieldnorm/ 五个文件共 827 行,src/fastfield/alive_bitset.rs 218 行,位图本身在 common/src/bitset.rs(按 tag 0.27.0 统计,含单元测试)。BM25 的公式和多段统计怎么合在 BM25 一页,这里只讲长度从哪来、精度丢了多少。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。语料是 benches/wiki.json 的 1000 篇维基摘要,body 字段用默认分词器。

长度:数的是词元

文本字段的 fieldnorm 是这个字段在这篇文档里经过分词器链之后剩下的词元数。inverted_index_plugin.rs 在一个字段的所有值都分完词之后记一次:

                    if field_entry.has_fieldnorms() {
                        self.fieldnorms_writer
                            .record(doc_id, field, indexing_position.num_tokens);
                    }

indexing_position 在循环外创建,同一字段的多个值累加。数值、日期、布尔、字节这些字段记的是值的个数(同一个文件里每种类型各一段 num_vals)。schema 里关掉 fieldnorm(TextFieldIndexing::set_fieldnorms(false))或者字段不建索引,就不记。

FieldNormsWriter 给每个要记的字段一个 Vec<u8>,下标就是文档号。record 遇到中间跳过的文档补 0,把长度换成一个字节再 push;段定稿时 FieldNormsSerializer 把每个字段的这串字节写进 .fieldnorm 这个复合文件,一个字段一段。所以 .fieldnorm 的大小就是「带 fieldnorm 的字段数 × max_doc」字节,加复合文件的目录。

一个字节怎么装下长度

换算用的是一张 256 项的表 FIELD_NORMS_TABLE:

#[inline]
pub fn id_to_fieldnorm(id: u8) -> u32 {
    FIELD_NORMS_TABLE[id as usize]
}

#[inline]
pub fn fieldnorm_to_id(fieldnorm: u32) -> u8 {
    FIELD_NORMS_TABLE
        .binary_search(&fieldnorm)
        .unwrap_or_else(|idx| idx - 1) as u8
}

表是写死的常量,同一个文件里的测试 test_fieldnorm_byte 用公式逐项核对它:编号 0 到 23 原样,从 24 开始是一个 3 位尾数加指数的小浮点数,每 8 个编号步长翻一倍。结果是长度 0 到 40 都精确,41 以后两两一档、再四个一档……最后一项是 2013265944。模块文档说这是「the exact same scheme as Lucene」。

binary_search 找不到时取前一项,所以存下来的长度总是小于等于真实长度。同一档里最坏的情况是真实长度在档的上沿:每 8 档翻一倍意味着档宽最多约是下沿的 1/8,相对误差不超过 1/9(约 11%)。

用 wiki 的 body 实测,自己用同一个分词器数出真实长度,和 FieldNormReader::fieldnorm 读回的值比:

body length (tokens): min 0 p10 14 median 67 p90 426 max 2850
sum of true lengths 171650, inverted_index.total_num_tokens 171650
docs 1000 exact 410 max abs err 200 max rel err 10.52% (doc 152 true 599 stored 536) mean rel err 2.04%
  id  38  lengths     38..=38      width     1  worst err  0.00%  wiki docs 4
  id  39  lengths     39..=39      width     1  worst err  0.00%  wiki docs 2
  id  40  lengths     40..=41      width     2  worst err  2.44%  wiki docs 20
  id  41  lengths     42..=43      width     2  worst err  2.33%  wiki docs 27
  id  42  lengths     44..=45      width     2  worst err  2.22%  wiki docs 17
  id  49  lengths     60..=63      width     4  worst err  4.76%  wiki docs 25
  id  50  lengths     64..=67      width     4  worst err  4.48%  wiki docs 9
  id  51  lengths     68..=71      width     4  worst err  4.23%  wiki docs 14
  id  75  lengths    376..=407     width    32  worst err  7.62%  wiki docs 17
  id  76  lengths    408..=439     width    32  worst err  7.06%  wiki docs 10
  id  77  lengths    440..=471     width    32  worst err  6.58%  wiki docs 5
  id  98  lengths   2584..=2839    width   256  worst err  8.98%  wiki docs 1
  id  99  lengths   2840..=3095    width   256  worst err  8.24%  wiki docs 1
  id 100  lengths   3096..=3351    width   256  worst err  7.61%  wiki docs 0

表里挑的是 40 附近和中位数、p90、最大值所在的档。1000 篇里 410 篇的长度原样保留(大多是 40 个词元以下的短文),平均相对误差 2.04%,最大 10.52%。第二行说明另一件事:倒排索引里记的 total_num_tokens 是精确的词元总数,不是从 fieldnorm 加出来的。

❗IMPORTANT
动脑筋:wiki 的第 152 篇,body 有 599 个词元,其中某个词出现 14 次。对这个词跑 TermQuery::explain,输出里「dl, length of field」是多少?提示:先在上面的表里找 599 落在哪一档。答案在文末。

BM25 怎么用这个字节

Bm25Weight 在建权重时就把 256 个编号的长度项都算好(compute_tf_cache):

fn cached_tf_component(fieldnorm: u32, average_fieldnorm: Score) -> Score {
    K1 * (1.0 - B + B * fieldnorm as Score / average_fieldnorm)
}

fn compute_tf_cache(average_fieldnorm: Score) -> Arc<[Score; 256]> {
    let mut cache: [Score; 256] = [0.0; 256];
    for (fieldnorm_id, cache_mut) in cache.iter_mut().enumerate() {
        let fieldnorm = FieldNormReader::id_to_fieldnorm(fieldnorm_id as u8);
        *cache_mut = cached_tf_component(fieldnorm, average_fieldnorm);
    }
    Arc::new(cache)
}

打分时 tf_factor 用文档的 fieldnorm 编号直接下标取 cache,一次除法就是 BM25 的词频部分。这是把长度压成一个字节的第二个好处:长度只有 256 种取值,和长度有关的浮点运算能整个提前算完。TermScorer 从 FieldNormReader::fieldnorm_id 读这个编号,就是 mmap 里的一次字节下标。

平均长度 avgdl 是 total_num_tokens / total_num_docs(Bm25Weight::for_terms):分子是倒排索引里记的精确总数,分母是各段的 max_doc 之和。于是分子精确、每篇文档的 dl 却是向下取整的,长文档的 dl / avgdl 偏小,分数偏高。用上面的 wiki 数据,按 tantivy 的 K1 = 1.2、B = 0.75 分别用真实长度和存下的长度算词频部分:

avgdl 171.650: tf=1 max score inflation from quantization 8.80%
avgdl 171.650: tf=3 max score inflation from quantization 6.80%

最多抬高 8.8%,只发生在长文档上;同一档里的文档在长度这一项上完全不可区分。

字段没有 fieldnorm,或者查询不需要打分时,TermWeight::fieldnorm_reader 退回 FieldNormReader::constant(max_doc, 1):每篇文档的长度都当成 1。短语查询的几个 weight 也是同样的退路。

删除:.del 是存活位图

tantivy 不原地改段。delete_term 只在删除队列里记一笔(提交 一页讲队列和 opstamp),commit 时对每个受影响的段调用 advance_deletes:

    let max_doc = segment_reader.max_doc();
    let mut alive_bitset: BitSet = match segment_entry.alive_bitset() {
        Some(previous_alive_bitset) => (*previous_alive_bitset).clone(),
        None => BitSet::with_max_value_and_full(max_doc),
    };
    // ...
    let num_alive_docs: u32 = alive_bitset.len() as u32;
    let num_deleted_docs = max_doc - num_alive_docs;
    if num_deleted_docs > num_deleted_docs_before {
        // There are new deletes. We need to write a new delete file.
        segment = segment.with_delete_meta(num_deleted_docs, target_opstamp);
        let mut alive_doc_file = segment.open_write(SegmentComponent::Delete)?;
        write_alive_bitset(&alive_bitset, &mut alive_doc_file)?;
        alive_doc_file.terminate()?;
    }
  • 位图是存活位图:从全 1 开始,命中删除条件的文档清掉。代码里类型叫 AliveBitSet,判断是 is_alive。
  • 新位图从上一次的位图复制,再叠加这次的删除,所以最新的 .del 总是包含这个段全部的删除,读时只需要一个文件。
  • 只有真的多删了文档才写新文件。删除条件一篇都没命中时,这个段的元数据不变。

文件名由 SegmentMeta::relative_path 生成:段的 uuid 加上 .{delete_opstamp}.del。段的其他文件写完就不再变,.del 却每次删除都要换一个,带上 opstamp 就成了不同的文件名:新文件写好、meta.json 原子地指向它之后,还在用旧 searcher 的线程照样读旧文件,GC 等没人引用了再删。实测,三篇文档提交,删一篇提交,再删一篇提交:

add a -> opstamp 0
add b -> opstamp 1
add c -> opstamp 2
commit -> opstamp 4
-- after first commit
   meta.json 774 B
delete b -> opstamp 6
commit -> opstamp 7
-- after deleting b
   a5adec81717f4ffebee3c267875ca309.7.del 106 B
   meta.json 831 B
a5adec81717f4ffebee3c267875ca309.7.del bytes: [03, 00, 00, 00, 05, 00, 00, 00, 00, 00, 00, 00, 7b, 22, 76, 65, ...]
max_doc 3 num_docs 2 num_deleted 1 is_alive(0..3) [true, false, true]
Count(AllQuery) 2  doc_freq(body:man) 3
delete c -> opstamp 9
commit -> opstamp 10
-- after deleting c (writer closed)
   a5adec81717f4ffebee3c267875ca309.10.del 106 B
   meta.json 833 B
meta.json segments: [{"deletes":{"num_deleted_docs":2,"opstamp":10},"max_doc":3,"segment_id":"a5adec81-717f-4ffe-bee3-c267875ca309"}]
  • 文件名里的 opstamp 是提交的 opstamp(7),不是 delete_term 那一次操作的 opstamp(6):advance_deletes 拿到的 target_opstamp 是这次提交的。
  • 内容是 BitSet::serialize 的格式:先是 u32 小端的 max_value(03 00 00 00,3 篇),然后每 64 篇一个 u64 小端字。05 是二进制 101:0 号和 2 号活着,1 号(b)删了。后面 7b 22 76 65 开头的 JSON 和最后 8 个字节是目录层加的 footer(见 Directory)。3 篇文档的 .del 共 106 字节,位图只占 12 字节。
  • 第二次删除后目录里只剩 .10.del,.7.del 已经被删掉;meta.json 里这个段的 deletes 记着 num_deleted_docs 和 opstamp,打开段时按它拼出文件名。

读的一侧,SegmentReader::open 在元数据说有删除时才打开 .del,AliveBitSet::open 只是包住 mmap 的字节,构造时数一遍 1 的个数作为 num_docs。

被删的文档还在倒排表里

.del 只是标记,倒排表、fieldnorm、doc store 一个字节都没动。查询照常在倒排表里遍历到被删的文档、照常给它打分,到 default_collect_segment_impl 才按位图过滤:

        (Some(alive_bitset), true) => {
            weight.for_each(reader, &mut |doc, score| {
                if alive_bitset.is_alive(doc) {
                    segment_collector.collect(doc, score);
                }
            })?;
        }

后果就是上面输出里的那一行:Count(AllQuery) 是 2,doc_freq(body:man) 还是 3。BM25 的统计也一样,idf 用的 doc_freq 和 N(各段 max_doc 之和)都把被删的文档算在内,直到合并把它们真正去掉。

合并时还会动 avgdl 的分子。新段的 total_num_tokens 由 estimate_total_num_tokens_in_single_segment 逐个旧段估出来:

    if !reader.has_deletes() {
        return Ok(reader.inverted_index(field)?.total_num_tokens());
    }
    if let Some(fieldnorm_reader) = reader.fieldnorms_readers().get_field(field)? {
        let mut count: [usize; 256] = [0; 256];
        for doc in reader.doc_ids_alive() {
            let fieldnorm_id = fieldnorm_reader.fieldnorm_id(doc);
            count[fieldnorm_id as usize] += 1;
        }

没有删除的段沿用精确值;有删除的段,词元总数改成「存活文档的 fieldnorm 之和」,而 fieldnorm 是向下取整的。wiki 分成两个各 500 篇的段,删掉 0 号文档(它的 fieldnorm 是 44),再把两段合并:

[seg max_doc 500 tokens 93941] [seg max_doc 500 tokens 77709] before: segments 2 max_doc 1000 num_docs 1000 total_num_tokens 171650 (sum of stored fieldnorms of alive docs 165372) avgdl 171.650
[seg max_doc 500 tokens 93941] [seg max_doc 500 tokens 77709] after deleting doc 0: segments 2 max_doc 1000 num_docs 999 total_num_tokens 171650 (sum of stored fieldnorms of alive docs 165328) avgdl 171.650
[seg max_doc 999 tokens 168793] after merge: segments 1 max_doc 999 num_docs 999 total_num_tokens 168793 (sum of stored fieldnorms of alive docs 165328) avgdl 168.962

删除生效后、合并之前,统计完全没变。合并后总数从 171650 掉到 168793:被删的那篇只有四五十个词元,其余约 2800 个是带删除的那个段从精确值换成 fieldnorm 之和丢掉的。avgdl 从 171.650 变成 168.962,降了 1.6%,所有文档的分数都跟着动了一点。删一篇文档能让整个索引的打分基准漂移,这是「先标记、合并时再估算」的代价。

📝NOTE
冷知识:数值字段曾经不记 fieldnorm,删除再合并之后就搜不到了

上面那段估算逻辑依赖每个字段都有 fieldnorm。0.19 之前,u64、i64、f64、bool、字节和日期字段建了索引却不记 fieldnorm。提交 63bc390b0(PR #1620)的说明把后果讲得很直接:「Fixes broken search (no results) with BM25 for u64, i64, f64, bool, bytes and date after deletion and merge. There were no fieldnorms recorded for those field. After merge InvertedIndexReader::total_num_tokens returns 0 (Sum over the fieldnorms is 0). BM25 does not work when total_num_tokens is 0.」这个 PR 给这些类型都加上了 fieldnorms_writer.record(doc_id, field, num_vals),就是本页第一节说的「记值的个数」,并用一串「删除、合并、提交」的操作序列做回归测试(test_bug_1617_*)。CHANGELOG 记在 0.19 的 Bugfixes 里,首次发布在 0.19。

动脑筋答案

docs 1000 exact 410 max abs err 200 max rel err 10.52% (doc 152 true 599 stored 536) mean rel err 2.04%
...
explain doc 152 term "lavant":
...
        {
          "value": 536.0,
          "description": "dl, length of field"
        },
        {
          "value": 171.65,
          "description": "avgdl, average length of field"
        }

536。599 落在 536 到 599 那一档(编号 80,步长 64,FIELD_NORMS_TABLE 里 536 的下一项是 600),存下的是档的下沿 536,少了 63,是这 1000 篇里相对误差最大的一篇。explain 打印的 dl 就是查表还原出来的 536,不是 599;avgdl 是精确的 171.65。这一篇的词频部分因此比用真实长度算的略高。

小结

  • fieldnorm 是字段的词元数(数值类字段是值的个数),每篇文档每字段 1 字节,.fieldnorm 大小是「字段数 × max_doc」。
  • 256 项的表:0 到 40 精确,之后每 8 档步长翻倍;向下取整,相对误差不超过约 11%。wiki 实测平均 2.04%、最大 10.52%。
  • BM25 把 256 种长度的词频分母提前算成表;avgdl 的分子用精确总数,dl 用取整值,长文档最多抬高 8.8%(tf=1)。
  • .del 是存活位图(u32 max_doc 加 u64 字),文件名带提交的 opstamp,每次有新删除就写一个包含全部删除的新文件,旧的由 GC 清掉。
  • 被删文档在合并前照常被遍历、打分,在收集时才过滤;doc_freq 和 N 都还算着它。
  • 合并带删除的段时,total_num_tokens 改用存活文档的 fieldnorm 之和估算:wiki 上删一篇再合并,avgdl 降了 1.6%。

💬 关联讨论

发起讨论
加载中...