Rust in Everything
基于 tantivy 0.27.0

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 是自然对数。

❗IMPORTANT
动脑筋:再提交 1000 篇只有 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。

📝NOTE
冷知识:0.6.0 之前不是 BM25,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 挑的,多段索引里可能偏低。

💬 关联讨论

发起讨论
加载中...