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 加出来的。
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%,所有文档的分数都跟着动了一点。删一篇文档能让整个索引的打分基准漂移,这是「先标记、合并时再估算」的代价。
上面那段估算逻辑依赖每个字段都有 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是存活位图(u32max_doc 加u64字),文件名带提交的 opstamp,每次有新删除就写一个包含全部删除的新文件,旧的由 GC 清掉。- 被删文档在合并前照常被遍历、打分,在收集时才过滤;
doc_freq和N都还算着它。 - 合并带删除的段时,
total_num_tokens改用存活文档的 fieldnorm 之和估算:wiki 上删一篇再合并,avgdl降了 1.6%。