BM25
主线位置:查询主线里出现两次。
query.weight()时为每个词算一次与文档无关的部分(idf、avgdl、256 项的长度表),每个段上TermScorer::score再为每篇文档查表算出词频部分。old man的 4.297436 就是四个子句各自的 BM25 之和。
flowchart TB stats["Searcher 统计"] --> nn["N 各段 max_doc 之和"] stats --> tok["各段总词数之和"] stats --> df["n 各段 doc_freq 之和"] nn --> idf["idf"] df --> idf tok --> avg["avgdl"] nn --> avg idf --> w["weight = idf x 2.2"] avg --> cache["cache 256 项"] fid["fieldnorm 字节"] --> cache cache --> tfp["tf 部分"] tf["词频"] --> tfp w --> score["score"] tfp --> score
全部实现在 src/query/bm25.rs,240 行,其中测试只有一个。
实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。实验程序是 srcha-lab/src/bin/bm25.rs,语料是 benches/wiki.json(1000 篇英文维基摘要),schema 是 title(TEXT | STORED)、body(TEXT),一个线程写成一个段。
公式和代码的对应
一个词 t 对文档 d 的分数:
score(t, d) = idf(t) × (k1 + 1) × tf / (tf + k1 × (1 − b + b × dl / avgdl))
idf(t) = ln(1 + (N − n + 0.5) / (n + 0.5))
多个词的查询(BooleanQuery 的 Should/Must 子句)把各个词的分数加起来。各项在 tantivy 里的来源:
| 符号 | 含义 | 来源 |
|---|---|---|
k1 | 词频饱和参数 | 常量 K1 = 1.2 |
b | 长度归一化参数 | 常量 B = 0.75 |
N | 文档总数 | total_num_docs:各段 max_doc 之和 |
n | 含这个词的文档数 | doc_freq:各段词典里的 doc_freq 之和 |
avgdl | 字段平均长度 | total_num_tokens(field) / total_num_docs |
dl | 这篇文档这个字段的长度 | fieldnorm 字节换算回的长度(有量化) |
tf | 词在这篇文档这个字段里出现的次数 | 倒排表里的词频 |
K1 和 B 是模块私有的 const,没有任何接口能改。idf 的写法里 ln 的参数总大于 1,所以 idf 恒为正:一个词出现在所有文档里时,idf 是 ln(1 + 0.5 / (N + 0.5)),很小但不是 0,也不会是负数。函数开头 assert!(doc_count >= doc_freq),自己实现统计提供者、给出 n > N 时会 panic。
Bm25Weight
Bm25Weight 把公式拆成「每次查询一次」和「每篇文档一次」两部分:
pub struct Bm25Weight {
idf_explain: Option<Explanation>,
weight: Score,
cache: Arc<[Score; 256]>,
average_fieldnorm: Score,
}
weight = idf × (1 + K1),在new里算好。cache[i] = K1 × (1 − B + B × dl(i) / avgdl):fieldnorm 只有 256 种取值,分母里和长度有关的部分对每个取值预先算好。这张表和长度量化的关系见 fieldnorm 与删除位图,那一页实测过量化最多把长文档的分数抬高 8.8%。idf_explain只为explain保留;索引时算块上界用的new_without_explain不建它。
每篇文档的打分是一次查表、一次除法、一次乘法(score、tf_factor):
pub fn score(&self, fieldnorm_id: u8, term_freq: u32) -> Score {
self.weight * self.tf_factor(fieldnorm_id, term_freq)
}
pub fn max_score(&self) -> Score {
self.score(255u8, 2_013_265_944)
}
pub(crate) fn tf_factor(&self, fieldnorm_id: u8, term_freq: u32) -> Score {
let term_freq = term_freq as Score;
let norm = self.cache[fieldnorm_id as usize];
term_freq / (term_freq + norm)
}
tf / (tf + norm) 小于 1,词频越大越接近 1,这就是「饱和」:一个词出现 100 次和 20 次差别不大。max_score 用一个接近 2^31 的词频把这个分式推到 1,结果就是 weight,即「这个词在任何文档里都不可能超过的分数」。Block-Max WAND 把它当作每个词的上限,见布尔查询。
多个词共用一个 Bm25Weight 的情况只有短语类查询:for_terms 收到多个 Term 时要求它们在同一个字段,idf 取各个词 idf 之和,词频换成短语出现的次数,见短语与其他查询。
统计从哪来
for_terms 不直接读 Searcher,而是读一个 Bm25StatisticsProvider:
pub trait Bm25StatisticsProvider {
fn total_num_tokens(&self, field: Field) -> crate::Result<u64>;
fn total_num_docs(&self) -> crate::Result<u64>;
fn doc_freq(&self, term: &Term) -> crate::Result<u64>;
}
Searcher 的实现(impl Bm25StatisticsProvider for Searcher)对每个段求和:total_num_tokens 加各段倒排索引里记的字段总词数,total_num_docs 加各段的 max_doc,doc_freq 加各段词典里的 doc_freq。它通过 EnableScoring::Enabled 里的 statistics_provider 传给每个查询;Searcher::search 传的是 Searcher 自己,search_with_statistics_provider 可以换成别的,比如分布式查询里汇总多个索引的统计。这个 trait 是 PR #1855(「Make BM25 scoring more flexible」,0.20)加的。
这几个数有三点要注意:
- 按整个
Searcher算。同样的文档分成一个段还是三个段,分数完全一样,一次查询做过这个对比。 - 用
max_doc,不是num_docs。被删除、还没合并掉的文档仍然算在 N 和 n 里,它们的词也算在总词数里,见 fieldnorm 与删除位图。 - N 是全部文档,不是有这个字段的文档。avgdl 的分母也是它。一个字段只在一部分文档里有值时,N 偏大、avgdl 偏小。下面的动脑筋就是这种情况。
对着 explain 手算一遍
bm25.rs 在 body 上查 football,取前 3,再逐篇从段里读出 fieldnorm 字节和词频,按 tantivy 的公式用 f32 重算:
segments 1 N 1000 total_num_tokens(body) 171650 avgdl 171.6500 n(football) 51
DocAddress { segment_ord: 0, doc_id: 994 } score 5.736597 | tf 4 fieldnorm_id 44 dl 48 idf 2.967173 tfpart 0.878797 hand 5.736597
DocAddress { segment_ord: 0, doc_id: 325 } score 5.725129 | tf 3 fieldnorm_id 23 dl 23 idf 2.967173 tfpart 0.877041 hand 5.725129
DocAddress { segment_ord: 0, doc_id: 327 } score 5.725129 | tf 3 fieldnorm_id 23 dl 23 idf 2.967173 tfpart 0.877041 hand 5.725129
第一名的 explain:
{
"value": 5.736597,
"description": "TermQuery, product of...",
"details": [
{
"value": 2.2,
"description": "(K1+1)"
},
{
"value": 2.967173,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5))",
"details": [
{
"value": 51.0,
"description": "n, number of docs containing this term"
},
{
"value": 1000.0,
"description": "N, total number of docs"
}
]
},
{
"value": 0.8787974,
"description": "freq / (freq + k1 * (1 - b + b * dl / avgdl))",
"details": [
{
"value": 4.0,
"description": "freq, occurrences of term within document"
},
{
"value": 1.2,
"description": "k1, term saturation parameter"
},
{
"value": 0.75,
"description": "b, length normalization parameter"
},
{
"value": 48.0,
"description": "dl, length of field"
},
{
"value": 171.65,
"description": "avgdl, average length of field"
}
]
}
],
"context": [
"Term=Term(field=1, type=Str, \"football\")"
]
}
逐项:
- idf:
ln(1 + (1000 − 51 + 0.5) / (51 + 0.5)) = ln(1 + 18.4369) = ln(19.4369) = 2.96717。 - avgdl:171650 / 1000 = 171.65。分子是
body在整个段里的词元总数,精确值;分母是max_doc。 - dl:fieldnorm 编号 44 换回长度 48。0 到 40 是精确的,再往上每档有间隔:48 的下一档是 50,真实长度是 48 或 49,都向下记成 48。
- 词频部分:
4 / (4 + 1.2 × (0.25 + 0.75 × 48 / 171.65)) = 4 / (4 + 1.2 × 0.459729) = 4 / 4.551675 = 0.878797。 - 合计:
2.2 × 2.967173 × 0.878797 = 5.736597,和TopDocs给的分数、explain的value、手算的结果一致到第六位小数。
第二、三名的 tf 少一次但 body 短一半,词频部分 0.877041,只差 0.2%:BM25 的长度项让「短文档里出现 3 次」和「长一倍的文档里出现 4 次」几乎打平。
explain 里的 dl 打印的是换算回来的长度,不是文档的真实词数;description 里写的 log 是自然对数。
title、没有 body 的文档,football 的第一名还是第 994 篇吗?新文档不含 football,body 上的词数也不变。提示:N、n、avgdl 各自怎么变,分别把分数往哪个方向推?答案在文末。
boost
BoostQuery 不改 BM25 的任何一项,只把系数往下传:BoostWeight::scorer 用 boost * self.boost 调内层的 scorer,到 TermWeight 里变成 Bm25Weight::boost_by,乘进 weight。所以 boost 也会乘进 max_score 和块上界,剪枝照样有效。explain 时 BoostWeight::explain 在内层的说明外面再包一层「Boost x2 of ...」,值乘上 boost;内层 TermWeight::explain 自己总是按 boost 1 建 scorer。
TermQuery 的文档至今还是旧公式TermQuery 的文档注释写着分数是「idf * sqrt(term_freq / field norm)」。这是 0.6.0 之前的打分:TermScorer::score 算 self.idf * (term_freq / field_norm).sqrt(),idf 是 1.0 + ln(num_docs / (doc_freq + 1)),没有饱和,长度直接做分母。PR #260(「Closes #245 = BM25.」,首次发布在 0.6.0)换成 BM25,提交说明只有一句「Scores are the same as Lucene.」;TermWeight 里的 num_docs、doc_freq 字段换成了 weight 和一张 256 项的 cache,就是今天 Bm25Weight 里那两个字段的前身。explain 的格式注释也写着「The explain format is directly copied from Lucene's.」。文档注释没有跟着改,在 0.27.0 里仍然描述旧公式。
块上界用的是段自己的 avgdl
倒排表每个满块记下一个 (fieldnorm_id, tf),查询时用当前的 Bm25Weight 算出这块的分数上界(倒排表)。这一对是在写段时挑的:PostingsSerializer::write_block 用一个只有这个段统计的 Bm25Weight,按 tf_factor 在块里取最大的那篇,而这个 Bm25Weight 的 avgdl 是本段的词数除以本段的文档数(FieldSerializer::create)。
idf 不影响「哪篇最大」,avgdl 会:avgdl 小时长度惩罚重,短文档占优;avgdl 大时惩罚轻,词频高的长文档占优。查询时的 avgdl 来自整个 Searcher,和写段时不同,存下的那一对就可能不是这块里真正分数最高的,上界偏低。TermScorer::block_max_score 的注释承认这一点(「the result may not be exact. In this case a lower value is returned」),并说只有一个段时结果保证正确。偏低的后果是剪枝可能跳过真正的前 K 名,布尔查询造了一个能复现的例子。
动脑筋答案
不是。第一名换成了第 325 篇:
== after adding 1000 title-only docs
segments 2 N 2000 total_num_tokens(body) 171650 avgdl 85.8250 n(football) 51
DocAddress { segment_ord: 0, doc_id: 325 } score 6.821104 | tf 3 fieldnorm_id 23 dl 23 idf 3.659821 tfpart 0.847173 hand 6.821104
DocAddress { segment_ord: 0, doc_id: 327 } score 6.821104 | tf 3 fieldnorm_id 23 dl 23 idf 3.659821 tfpart 0.847173 hand 6.821104
DocAddress { segment_ord: 0, doc_id: 332 } score 6.821104 | tf 3 fieldnorm_id 23 dl 23 idf 3.659821 tfpart 0.847173 hand 6.821104
doc 994 now: 6.704992
- N 从 1000 变成 2000:新文档没有
body,但total_num_docs加的是max_doc。idf 从 2.967 升到 3.660,所有含football的文档等比例变高,不改变排名。 - avgdl 的分子还是 171650,分母翻倍,从 171.65 掉到 85.825。
dl / avgdl对每篇文档都翻倍,长度惩罚整体变重,越长的文档吃亏越多:第 994 篇(dl 48,tf 4)的词频部分从 0.8788 降到 0.8327(6.704992 / (2.2 × 3.659821)),第 325 篇(dl 23,tf 3)只从 0.8770 降到 0.8472。原来只差 0.2% 的两篇就此换位。
一个字段越稀疏,它的 avgdl 被压得越低。同一个索引里混放「有正文」和「只有标题」的文档,正文字段的排序会受到另一类文档数量的影响。
小结
- 公式是标准 BM25,
k1 = 1.2、b = 0.75是常量;idf 是ln(1 + (N − n + 0.5) / (n + 0.5)),恒为正。 Bm25Weight在建Weight时算好weight = idf × 2.2和 256 项的长度表,每篇文档只做查表、除法、乘法;max_score就是weight。- 统计来自
Bm25StatisticsProvider,默认是Searcher:各段max_doc、总词数、doc_freq相加,包含已删除的文档,也包含没有这个字段的文档。 - 对着
explain手算第 994 篇的 5.736597,和TopDocs、explain一致到六位小数;加入 1000 篇没有body的文档后 avgdl 减半,排名翻转。 - boost 乘进
weight,块上界和剪枝跟着变;TermQuery文档里的idf * sqrt(tf / fieldnorm)是 0.6.0 以前的旧公式。 - 块上界的
(fieldnorm_id, tf)是按写段时本段的 avgdl 挑的,多段索引里可能偏低。