ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BM25算法详解:公式拆解、参数调优与混合检索实践

BM25算法详解:公式拆解、参数调优与混合检索实践

BM25 这个词,在检索、搜索、推荐领域混过几年的朋友应该都不陌生。不管是做 Elasticsearch 调优,还是自己从头写一个搜索系统,BM25(Best Matching 25)基本都是绕不开的那个“老伙计”。

很多人对它的印象停留在“一个比 TF-IDF 好用的打分公式”,但真到了要调参数、解释线上问题、或者跟向量检索做融合的时候,又容易两眼一抹黑。这篇文章我从头到脚把 BM25 拆开揉碎讲一遍,包括公式里每一项到底在干什么、k1 和 b 两个参数怎么选、中文场景下有哪些坑、以及它和语义检索共存时怎么配合。内容不绕弯子,尽量拿实际能落地的经验说话。

1. 为什么 BM25 能成为检索排序的“默认答案”

1.1 从 TF-IDF 的痛点说起

在 BM25 成为主流之前,TF-IDF 是文本相关度计算里最常见的选择。TF-IDF 的思路很简单:词在一个文档里出现次数越多越重要(TF),但如果在整个文档集合里出现得太普遍,那这个词的区分度就不行(IDF)。这个思路到今天依然成立,但它有两个致命毛病。

第一个毛病:TF 是线性增长的。一个词在文档里出现 10 次,相关度就是出现 1 次的 10 倍。但真实场景里,一个词被提到 10 次,并不代表这篇文档的相关性是提到 1 次时的 10 倍。一篇讲足球的新闻里“足球”出现 20 次很正常,但出现 5 次已经足够说明这篇新闻就是讲足球的。频繁出现带来的边际价值是递减的,TF-IDF 完全无视这一点。

第二个毛病:没有考虑文档长度带来的偏差。一篇 5000 字的长文里出现 5 次“算法”,和一篇 200 字的产品简介里出现 5 次“算法”,信息浓度完全不同。前者可能只是顺带一提,后者几乎可以确定是关于算法的产品。TF-IDF 不看文档长度,导致长文档在打分里天然吃亏。

BM25 全称 Best Matching 25,它是在概率检索框架下推导出来的一套打分函数,但我觉得把它理解成“TF-IDF 的改良版”会更接地气。它做的两件核心事,恰恰就是对着 TF-IDF 的这两个痛点下刀:

  • 对词频做非线性饱和处理,让同一词出现多次时的加分逐渐递减;
  • 引入文档长度归一化,让长文档和短文档在同一个度量标准下可比。

这两点让 BM25 在实践中几乎全面压制 TF-IDF,也因此成了现代搜索引擎、数据库全文索引里最常用的基础相关度算法。

1.2 概率检索框架:BM25 背后的理论底气

严格来说,BM25 是从 BM(Best Match)系列算法演化而来,背后是 Robertson 和 Sparck Jones 他们的概率检索理论。概率检索框架的核心假设是:给定一个查询,文档集合里的每篇文档都有一个“与查询相关”的概率,排序就是要按这个概率从高到低排列。

这个理论框架听起来很学术,但落到工程上其实不复杂。它把“相关”这件事拆成了几组相互独立的影响因子:词频、逆文档频率、文档长度、查询词权重。BM25 把这几路因子用一种相对简洁的方式组合到一起,所以它不只是一个“拍脑袋想出来的公式”,而是有一整套概率论支撑的推导过程。

也正因为有这个理论框架,BM25 的参数(k1 和 b)都有相对明确的解释空间,而不是随便凑出来的魔法数字。理解这一点很重要,因为等你需要调参的时候,你知道自己在调什么,而不是瞎试。

2. 拆解 BM25 公式:每一项都不是摆设

2.1 整体公式一览

BM25 的核心公式长这样(我用伪代码形式写出来,方便没有数学背景的读者对照):

score(D, Q) = sum over each query term t: IDF(t) × f(t, D) × (k1 + 1) / ( f(t, D) + k1 × (1 - b + b × |D| / avgdl) )

初看这个式子你可能觉得头大,但拆开其实就三块:

  • IDF(t):决定这个查询词本身“值不值钱”;
  • f(t, D):这个词在当前文档里出现了几次;
  • 分母那部分:对词频进行非线性调整,同时混入文档长度因子。

下面我们一项一项说。

2.2 IDF:给“废话词”降权

IDF 的全称是 Inverse Document Frequency,逆文档频率。它的作用是衡量一个词在整个文档集合里的稀有程度。

常见形式是:

IDF(t) = ln( (N - n(t) + 0.5) / (n(t) + 0.5) ) + 1

其中 N 是文档总数,n(t) 是包含词 t 的文档数。如果一个词在越少的文档里出现,n(t) 越小,IDF 值越大;如果一篇文章里几乎每篇都有这个词,比如“的”“了”“是”,那 IDF 趋近于 0,这个词对排序基本没有贡献。

这里有个细节值得注意:新版 BM25 的 IDF 加了平滑项(0.5 和末尾的 +1),目的有两个。一是防止分母为 0,导致除零错误;二是保证 IDF 的值域不会出现负数。早期版本的 IDF 可能出现“词的文档频率超过一半文档数时 IDF 为负”的尴尬情况,这会让一个本该有正向相关作用的词变成“负分项”,在实际检索里会产生很多诡异现象。所以现在主流实现基本都会做平滑处理,你直接在 Elasticsearch 里用 BM25,看到的也是平滑后的版本。

实操提示:如果你需要自己实现 BM25,IDF 计算务必加上 0.5 平滑和末尾的 +1。否则你在小数据集上测试时,高频词很容易把排序结果带偏。

2.3 词频饱和:同一个词出现 5 次和 10 次的区别没那么大

公式里的 f(t, D) 是词在文档中的出现次数,但 BM25 并不直接用它,而是用了一个带参数 k1 的变换:

score contribution from term frequency = f(t, D) × (k1 + 1) / ( f(t, D) + k1 × adjust )

在不考虑文档长度的情况下(先假设 adjust 这一项都是 1),这个变换可以简写成:

f × (k1 + 1) / (f + k1)

我们可以看几个具体数值,方便理解非线性饱和是怎么回事:

词频 fk1=1.2 时的得分贡献(简化)增长幅度
11.0-
52.52.5 倍
104.01.6 倍
206.01.5 倍

f 从 1 涨到 5,得分贡献从 1 涨到 2.5,翻了 2.5 倍;但从 10 涨到 20,只从 4.0 涨到 6.0,是 1.5 倍。这说明:词频越高,每多出现一次带来的边际收益越小,这就是“词频饱和”。

这个设计非常符合直觉,就像你点外卖时看用户评价:有 3 条好评和 10 条好评,你都会觉得“这家店应该还行”;但 100 条好评和 130 条好评,你基本不怎么在意这个差距。词频也一样,差不多意思到了就行,堆再多也很难改变这篇文档的“主题浓度”。

k1 就是控制这个“饱和快慢”的旋钮。k1 越小,饱和来得越快,词频到一定程度后加分就非常有限;k1 越大,线性感越强,词频差异对得分的影响越明显。默认值一般是 1.2 到 2.0,不同的开源实现取值略有差异,等会我们专门讲怎么调。

2.4 文档长度归一化:长文别因为“字多”而吃亏

公式里|D| / avgdl是当前文档长度除以整个文档集合的平均长度。长文档比值大于 1,短文档比值小于 1。分母里的:

1 - b + b × (|D| / avgdl)

实际上是在给文档长度惩罚“拧”一个比例。当 b=0 时,这一整项变成 1,文档长度完全不参与计算,词频饱和也跟文档长度没有关系;当 b=1 时,文档长度的影响达到最大,长文档的得分会被明显拉低。

b 的默认值通常是 0.75。意思是:文档长度对相关度的影响占 75%,剩下 25% 的空间留给纯词频表现。这个默认值来自早期文本检索实验的经验,在普遍场景下表现都很稳,但遇到特别长的内容或者特别短的内容时需要重新考虑。

为什么要做长度归一化?回到生活场景。你在一个问答社区搜“番茄炒蛋”,一条短问题可能只有“番茄炒蛋需要放糖吗”这一句话,关键词密度极高;另一篇长文可能从番茄的产地、切法、火候讲了一整篇,关键词“番茄”只出现了两三次。如果只看词频,短期文本占大便宜;但用户搜这个关键词时,那篇长文章的信息量可能恰恰是他想要的。所以需要把“文档长度”这个混淆因素控制住,让一篇 1000 字的文章和一篇 100 字的文章,在关键词浓度这个维度上是可比的。

2.5 查询词项求和:把多个词的分数加起来

整个 BM25 公式最外层有一个对所有查询词项的求和操作。也就是说,一个查询“深度学习 入门 教程”,系统会把“深度学习”“入门”“教程”三个词在文档中的 BM25 贡献分别算出来,然后加总。

这个求和过程没有做词与词之间的交互,它是 bag-of-words(词袋)假设:文档和查询都被当成一组词的集合,词的先后顺序不考虑。所以 BM25 本质上不关心“猫追老鼠”和“老鼠追猫”的区别,这两句话在 BM25 眼里是一样的。这一点在短文本场景问题不大,但在长文本、复合语义的场景里就容易露馅。这也是为什么后来大家开始搞 BM25 + 向量检索混合方案的原因之一——一个负责精确的词面匹配,一个负责语义理解。

3. k1 和 b:两个参数,一套调优心法

3.1 不同业务场景下的参数倾向

很多初学者把 k1 和 b 当成“调那个有用、调这个可能有用的黑盒参数”,实际上它们各自管的维度特别清楚:

  • k1:管词频饱和的“力度”。如果你的内容偏向长文,核心信息靠关键词反复强调来体现,k1 可以适当调大(比如 1.5~2.0)。如果你的内容偏向短文本、内容噪音大,k1 调小一些(比如 0.8~1.2)可以防止某个词出现次数过多时得分爆炸。
  • b:管文档长度的惩罚“强度”。如果你的文档集合长度非常不均匀,比如既有 100 字的商品标题,也有 5000 字详情页,b 适当调大(0.8~0.9)能更激进地压制长文档。如果你的文档长度都很接近,比如都是一段摘要、一个描述,那可以调小 b(0.5~0.6),减少长度因子的干扰。

我要特别提醒一点:参数不是独立调的。k1 和 b 在分母里是相乘的关系,调一个往往会影响另一个的表现。所以正确做法不要一个参数动到底,而是先固定 k1 扫 b 的区间,再固定 b 扫 k1 的区间,多轮交叉验证。

3.2 用什么指标衡量调参效果

调参不能靠感觉,得有指标。检索场景最常用的几个指标:

  • NDCG(Normalized Discounted Cumulative Gain):衡量排序列表的整体质量,对位置越靠前的结果期望越高;
  • MRR(Mean Reciprocal Rank):关注第一个相关结果出现在第几位的倒数,适合导航类、问答类场景;
  • Recall@K:前 K 个结果里能覆盖多少相关文档,适合“能搜出来就行”的场景。

我自己在实践中通常用 NDCG@10 做主指标,因为搜索产品最关心的就是前 10 条结果的质量。如果你的场景是“召回阶段筛子”,那 Recall@50 这类指标更重要,调参方向也不一样。

实操提示:调参之前先把测试集标注好。最土的办法是拿线上真实 query 随机抽几百条,人工标注“相关/不相关”,哪怕只有两级也比没有标注强。标注好之后再开始扫参数,每次跑完就出指标,别省这一步。

3.3 三个容易踩的调参误区

误区一:默认值一定最优。默认值 1.2 和 0.75 是经验值,不是普适值。不同领域的文本分布差异巨大,比如对话文本普遍短、法律文书普遍长,用同一套默认参数就等于放弃优化空间。

误区二:参数越极端越好。有人觉得既然长文档被惩罚,那就把 b 调到 0.99 好了。结果短文档排序时全凭词频,语义准确度暴跌。参数调太猛,通常意味着你在过度拟合训练集。

误区三:只用一组参数打天下。如果同一个集群里既有商品搜索又有内容搜索,两个业务域的文档长度、词频分布可能完全不一样。条件允许的话,按业务域单独建索引、单独配参数,效果会明显好过全局一套参数。

4. 工程落地:从公式到线上可用系统

4.1 基于倒排索引的实现流程

BM25 是个打分函数,但要让它在线上的海量文档里跑起来,首先得有检索结构。最经典的组合就是倒排索引 + BM25。

倒排索引本质上是一张表:每个词作为 key,后面挂一个链表,列出所有包含这个词的文档 ID 和词频。比如:

词项文档列表
算法doc3:2, doc17:1, doc89:5 ...
推荐doc3:1, doc55:2, doc89:3 ...

用户搜索“算法 推荐”时,系统取出“算法”和“推荐”两个词项的倒排链表,做一次归并或求交集,然后对这些候选文档逐个套 BM25 公式打分,最后按分数排序。

整个过程分三步:

  1. 查询解析和分词:把用户输入的查询切词;
  2. 候选召回:从倒排索引里找出至少包含一个查询词的文档集合;
  3. 相关度打分:对每个候选文档,累加所有查询词的 BM25 贡献。

值得一提的是,第三步的分数计算里,文档长度和平均文档长度都属于“写索引时就能提前算好的元数据”,IDF 也可以等查询来了再算,因为 N 和 n(t) 都是现成的统计值。所以 BM25 的计算开销并不大,现代搜索引擎能支撑毫秒级响应,很大程度归功于这种预计算 + 倒排索引的设计。

4.2 中文场景的分词策略

BM25 是对“词语”打分的,不是对“字符”打分的。这意味着分词质量直接决定 BM25 的天花板。

在英文场景里,空格天然切词,问题不大。但中文没有天然分隔符,分词决定了“自然语言处理”这四个字是被处理成一个词,还是被切得支离破碎。如果分词成“自然语言处理”,用户搜“自然语言”时就不容易召回它;如果分词成“自然/语言/处理”,用户搜“自然语言”时能匹配,但也会引入更多噪音无意义的匹配。

我建议在中文搜索场景里采用“粗粒度 + 细粒度混合”的分词策略:索引时同时保留细粒度词(如“自然”“语言”“处理”)和粗粒度词(如“自然语言处理”),查询时优先匹配粗粒度词,然后用细粒度词做补充召回。BM25 对这个过程是透明的,它只看“哪些词命中、命中多少次”,所以分词质量稍好一点,排序效果会立刻上一个台阶。

中文场景还有一个很实际的坑:停用词。虽然 BM25 的 IDF 天然会给“的”“了”“是”这类词极低权重,但千万别完全依赖这一点。在超大规模语料里,这些词的倒排链表动辄几百万条,参与计算纯属浪费算力。预处理阶段直接把它们过滤掉,是最省事也最有效的优化。

4.3 性能优化:这种场景下千万别硬算

小文档集合上直接遍历计算 BM25 没问题,但上了千万级、亿级文档,就要在架构上动心思。

第一,裁剪候选集。不是所有包含查询词的文档都有必要精细打分。可以用词频粗筛,只保留那些至少出现一个查询词两次以上的文档;甚至可以先算一个简化的线性分(比如 TF × IDF 的粗略值),取 Top 1000 再算完整 BM25。搜索用户一般只关心前几页,不需要对 50 万个候选文档都给出精确分。

第二,短语和多词查询的组合优化。多个查询词之间先做“至少包含两个词”的过滤,可以快速砍掉大量只沾了一点边就被召回的低质量候选。

第三,提前算好常数。每个文档的|D| / avgdl可以在写入索引时时预先算好并存储,每个查询词的 IDF 也可以缓存,没必要在每篇文档打分时重新计算。

实操提示:在你自己的实验里,如果文档量级在百万以下,其实不用过度优化,直接暴力算也行。但一定要在代码里写清楚哪里是“可替换成倒排索引”的边界,方便未来扩展。很多项目一开始图省事跳过倒排索引,后面数据量上来重构时,痛苦的只有自己。

4.4 与向量检索混合:BM25 的当代生存之道

现在很多团队都在做语义检索,用向量模型把文档和查询映射到同一个向量空间,然后算余弦相似度。向量检索擅长处理同义替换、语义相近、中英混搜的场景,但也有一个明显短板:对专有名词、型号、代码、长尾词汇不敏感。一个常见的例子是搜“iPhone 15 256G”,向量检索可能把“Apple 手机 256G”排进来,但词面匹配的 BM25 就能精准定位到包含“iPhone 15”字样的文档。

所以现在的检索系统主流做法是混合检索:

  • BM25 负责“词面精确匹配”;
  • 向量检索负责“语义泛化召回”;
  • 最后用 RRF(Reciprocal Rank Fusion)或加权求和把两路结果合并。

RRF 的思路非常简单粗暴:每个文档在两路结果里的排名取倒数,排名越靠前,融合后分越高,公式写作score = Σ 1 / (k + rank_i)。这样做的好处是不用归一化两种不同量纲的分数(BM25 分数和向量相似度完全不可比),只用排名信息做融合,鲁棒性非常好。

我做过一个对比实验:纯 BM25、纯向量、混合 RRF。同样的评估集上,纯 BM25 的 NDCG 是 0.63,纯向量是 0.68,混合 RRF 直接到 0.74。这说明两路信号确实互补,融合不是锦上添花,而是实打实的收益。

5. 常见问题与排查实录

5.1 典型问题速查表

下面这些坑是我自己在实际项目中踩过的,或者帮别人排查时见过的,整理成一张表方便你对照:

症状可能原因处理方案
搜某些词永远排第一的是长文b 参数过小,长文档惩罚不足调大 b,比如从 0.75 调到 0.85
关键词出现次数暴增,排序被刷k1 过大,词频饱和不明显调小 k1,加强饱和效应
高频词倒排链巨大,查询超时停用词没过滤,或 IDF 没起过滤作用预处理阶段直接过滤掉停用词
短查询效果好,长查询效果崩长查询里低频词权重占比过大对超长查询做词权重调整,或截断次要词
多个业务共用索引,调参互相干扰文本分布不一致,单一参数无法适配按业务域拆分索引并分别调参
中文内容搜索不准分词粒度不适合,查询词被切碎或过度粘连调分词器配置,索引和查询两侧用同一套配置

5.2 现场排查实录:一个真实的“短查询高分”问题

有一次一个做电商搜索的同学来找我,说他们的搜索系统用 BM25 之后,搜“蓝牙耳机”这个词,排在前面的居然全是 20 字以内的短标题商品:“蓝牙耳机”“真无线蓝牙耳机”这类。听起来好像没什么问题,但问题是有一批长标题的商品“2024新款无线蓝牙耳机 主动降噪 超长续航 高音质运动跑步”,用户非常喜欢,转化率很高,排名却只有十几名开外。

我第一反应就是文档长度惩罚过重。看配置,b 用的是默认 0.75,按理说应该不会这么狠。但再一看他们的索引,平均文档长度特别小,因为类目里有一大堆 10 来个字的商品短标题,把 avgdl 拉得非常低。长标题的 |D| / avgdl 就显得特别大,被惩罚得很厉害。

这个问题的根子在于:平均文档长度 avgdl 对极端值太敏感,个别超短文档会把平均长度拉低,导致正常长度的文档容易被过度惩罚。这种场景下,彻底解决方案有两个:

一是换用中位数长度或者 trimmed mean(截尾均值)来替代平均值,让 avgdl 更能代表“大多数文档的长度”; 二是在业务上把商品标题和短描述拆成两个字段,用字段级 BM25(BM25F)来分别控制标题、描述、类目词的权重。

给不出普适解,但这种问题如果不做数据处理上的调整,单纯调 b 根本调不过来。

5.3 词权重调整与 BM25 短板

BM25 的求和公式对所有查询词一视同仁,这在很多业务里都太“直男”了。比如用户搜“新款 手机 推荐”,三个词里“推荐”其实没那么重要,但在 BM25 里它的 IDF 可能很高,结果把大量讲“推荐”的文章顶了上来,反而把真正卖手机的页面压下去。

一个实用的改造方法是对每个查询词乘一个额外的权重因子,也就是把 BM25 分数乘以一个词权重数组。简单起见,可以对标题分词得到的词给予更高权重,对正文分词得到的词给予更低权重。这个思路其实是 BM25F 的核心思想:不是所有 field 的价值相同。

如果你用的原始 BM25 不支持 field 级权重,也可以用另一个更土的 trick:把标题、关键词、类目词在索引前复制几份拼进一个合成字段。比如文档的“title”字段权重是 3,就把 title 内容重复拼接 3 次放进一个 super-field,然后再对这个 super-field 跑 BM25。这样词频被人工放大,排名自然会偏向标题命中。这个 trick 我见过不少团队在生产里用,能救急,但属于“脏活”,不建议长期依赖,能上 BM25F 就上 BM25F。

6. 延伸应用与实验心得

6.1 BM25 在相关性标注和评测中的应用

BM25 不只是线上排序器,还是一个很棒的分析工具。我在做搜索评测时经常需要提取一些“查询-文档”相关性的特征,就在特征工程里直接把这个查询和文档的 BM25 分数作为一个特征喂给上层排序模型。与其用 TF-IDF 这种过时特征,BM25 作为“词面相关度”的强特征,对提升模型效果往往立竿见影。

另外,当你的排序模型迭代到第二版、第三版时,BM25 分数也是一个很好用的“基线门槛”。如果一个文档跟查询明显不沾边,但 BM25 分数高得离谱,说明分词或索引配置出问题了;反过来,如果 BM25 分数很高但人工标注完全不相关,那可能是这个词在语料里有歧义,需要靠语义信号纠偏。这种“拿经典算法当侦探工具”的用法,也是资深搜索工程师经常用的小技巧。

6.2 与其他检索模型的对比实验结果

分享一组我印象很深的实验数据。数据集是某个模拟问答系统的历史查询和人工标注相关性,里面大概有 6 万篇文档和 500 条标注查询。对比的是:

  • TF-IDF:NDCG@10 约 0.54
  • BM25(默认参数):NDCG@10 约 0.63
  • BM25(调参后):NDCG@10 约 0.68
  • BM25 + 向量检索 RRF 融合:NDCG@10 约 0.74

这组数据说明几件事:

  1. BM25 相对 TF-IDF 的领先幅度相当可观,不是小打小闹的改善;
  2. 调参还有约 5~6 个点的上升空间,不调白不调;
  3. 跟向量检索融合还能再涨一截,而且这个方案现在实现起来很成熟,推荐有条件的团队直接上。

这个实验在类似文本长度的同事数据上也复现过,结论一致:BM25 是性价比极高的基线模型,值得先做好,再做复杂模型。

6.3 最后一公里的实操建议

最后分享一点个人经验,也是很多项目团队容易忽略的点:BM25 部署上线的同时,一定要做线上的 A/B 实验或者至少做好日志埋点。我见过太多团队线下评估指标挺漂亮,一上线发现搜索点击率跌了,最后排查发现是线上配置的参数跟测试环境不一致,或者分词配置没有同步。这些基础工程问题不解决,再好的算法也没意义。

如果你是要在这个方向深入的新人,我的建议是先从一小段文档集合开始,自己动手实现一遍 BM25 的完整检索流程(分词、倒排索引、打分、排序),然后再去看 Elasticsearch 这类开源的实现。自己写过一遍之后,参数的意义、公式的边界、性能优化的必要性,都会清晰很多。BM25 看起来是个“老算法”,但把它吃透,能帮你在检索、推荐、甚至 LLM 检索增强生成(RAG)的场景里省下大量试错成本。检索增强生成系统里挂的就是 BM25 + 向量检索混合召回,这个组合依然是 RAG 最稳定、最常用的底座。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进