为什么在 RAG 里会看到 BM25?
最近在看 RAG(Retrieval-Augmented Generation,检索增强生成)时,我经常看到一个名字:BM25。
它不像大语言模型,也不是 Embedding 模型。BM25 是一个经典的文本相关性排序算法:输入一条查询和一批文档,它会给每篇文档计算一个分数,分数越高,代表文档和查询越相关。
这也解释了它为什么会出现在 RAG 中:
- 用户提出问题;
- 检索器从知识库中找出最相关的若干片段;
- 把片段和问题一起交给大模型;
- 大模型根据检索到的上下文生成答案。
BM25 负责的是第 2 步。它不理解世界,也不生成答案,但特别擅长查找关键词、产品名、函数名、错误码、缩写和专有名词。
一句话理解:BM25 是经过“词频饱和”和“文档长度校正”的 TF-IDF。
先从最朴素的关键词计数说起
假设用户查询的是“BM25 RAG”,知识库里有两篇文档:
- 文档 A:
BM25 适合 RAG 关键词检索 - 文档 B:
向量检索可以理解语义
最简单的方法是数一数查询词出现了几次。A 同时包含 BM25 和 RAG,明显应该排在 B 前面。
但只靠计数很快就会遇到三个问题:
- 所有词都同样重要吗? “的”“使用”出现在大量文档里,区分度远低于“BM25”。
- 出现 10 次就一定比出现 1 次相关 10 倍吗? 很可能不是,堆砌关键词不应该无限加分。
- 长文档是否天然占便宜? 一篇 1 万字的文章更容易偶然命中查询词,需要校正。
BM25 的设计,正是在同时处理这三个问题。
BM25 的完整公式
常见的 BM25 公式如下:
符号看起来很多,拆开后其实只有三部分:
| 符号 | 含义 |
|---|---|
| 用户查询 | |
| 当前参与打分的文档 | |
| 查询中的第 个词 | |
| 查询词 在文档 中出现的次数 | |
| $ | D |
| 语料库中文档的平均长度 | |
| 控制词频饱和速度,常见取值约为 | |
| 控制文档长度归一化程度,范围为 ,常用值为 |
对查询里的每一个词,BM25 都计算一次“稀有度 × 词频贡献”,最后再把所有查询词的贡献相加。
第一部分:IDF,越少见的词越重要
IDF 是 Inverse Document Frequency,即逆文档频率。Lucene 使用的一个平滑版本是:
其中:
- 是语料库中的文档总数;
- 是包含词 的文档数。
如果一个词几乎每篇文档都有,那么它很难帮助我们区分文档,IDF 就低;如果一个词只出现在少数文档中,它的区分度高,IDF 就高。
例如知识库有 1000 篇文档:
- “使用”出现在 900 篇里,提供的信息很少;
- “BM25”只出现在 20 篇里,一旦命中就很有价值。
注意,IDF 关心的是“有多少篇文档包含这个词”,而不是这个词在全部文档中一共出现了多少次。
第二部分:TF 饱和,重复出现会加分,但不会无限加分
如果暂时忽略文档长度,词频部分可以近似看作:
当词第一次出现时,相关性明显增加;从 1 次增加到 2 次,仍然有帮助;但从 100 次增加到 101 次,几乎不会带来变化。这就是词频饱和。
控制饱和的速度:
- 越小,曲线越快变平,词出现一次之后再重复的意义很小;
- 越大,词频的影响保留得越久;
- 当 时,只关心词是否出现,不再关心出现次数。
这比直接使用词频更合理,也能降低关键词堆砌对排序的影响。
第三部分:长度归一化,不能让长文档白占便宜
公式中的长度校正项是:
如果当前文档恰好等于平均长度,,这个校正项也等于 1。
- 文档比平均长度短,分母变小,同一次命中更“浓缩”,得分相对更高;
- 文档比平均长度长,分母变大,同一次命中可能只是偶然,得分相对更低。
决定校正有多强:
- :完全忽略文档长度;
- :完全按照文档长度比例进行归一化;
- :常见默认值,也是 Lucene 的默认设置之一。
这里容易产生一个误解:BM25 并不是简单地“打压长文档”。如果长文档中某个词出现了很多次,词频仍然能够抵消一部分长度惩罚。它真正比较的是词频相对于文档长度是否足够突出。
手算一次 BM25
假设分词后的知识库有 5 篇文档:
| 文档 | 分词结果 | 长度 |
|---|---|---|
| D1 | BM25 / 适合 / RAG / 关键词 / 检索 | 5 |
| D2 | 向量 / 检索 / 理解 / 语义 | 4 |
| D3 | BM25 / 通过 / 词频 / 和 / 文档长度 / 计算 / 相关性 | 7 |
| D4 | RAG / 结合 / BM25 / 和 / 向量 / 检索 | 6 |
| D5 | 数据库 / 使用 / 倒排索引 / 加速 / 关键词 / 查询 / 请求 | 7 |
平均文档长度为:
查询为 BM25 RAG,使用 、。BM25 出现在 3 篇文档中,RAG 出现在 2 篇中,因此:
以 D1 为例,两个查询词都出现 1 次,文档长度为 5。单个词的词频与长度因子是:
所以 D1 的总分约为:
同理可得到:
| 文档 | 命中的查询词 | BM25 分数(约) |
|---|---|---|
| D1 | BM25、RAG | 1.498 |
| D4 | BM25、RAG | 1.394 |
| D3 | BM25 | 0.497 |
| D2 | 无 | 0 |
| D5 | 无 | 0 |
D1 与 D4 命中了相同的查询词,每个词也都只出现一次,但 D1 更短,因此得分略高。D3 只命中 BM25,所以排在它们后面。
BM25 分数不是概率,也没有固定上限。分数只适合在同一次查询、同一套索引与打分配置下比较,不应把“分数 2”理解成“相关概率 200%”。
BM25 是怎样快速检索的?
如果每次查询都遍历知识库里的每一篇文档,再计算公式,数据一多就会很慢。工程实现通常使用倒排索引。
普通的正向结构是“文档 → 词”:
D1 -> BM25, RAG, 关键词, 检索
D2 -> 向量, 检索, 语义倒排索引则把方向反过来,记录“词 → 出现在哪些文档”:
BM25 -> D1(tf=1), D3(tf=1), D4(tf=1)
RAG -> D1(tf=1), D4(tf=1)
检索 -> D1(tf=1), D2(tf=1), D4(tf=1)查询 BM25 RAG 时,只需要访问这两个词的 posting list(倒排记录),而不用扫描所有文档。索引里还会保存文档频率、词频和文档长度,这些正好是 BM25 公式需要的数据。
用 Python 跑一个最小示例
可以使用 rank-bm25 快速实验:
pip install rank-bm25 jiebaimport jieba
from rank_bm25 import BM25Okapi
documents = [
"BM25 适合 RAG 关键词检索",
"向量检索可以理解语义",
"BM25 通过词频和文档长度计算相关性",
"RAG 可以结合 BM25 和向量检索",
]
def tokenize(text: str) -> list[str]:
# 示例只做分词和去空白;生产环境还要统一大小写、同义词等规则
return [word.strip() for word in jieba.cut(text) if word.strip()]
tokenized_corpus = [tokenize(document) for document in documents]
bm25 = BM25Okapi(tokenized_corpus, k1=1.2, b=0.75)
query = tokenize("BM25 RAG 检索")
scores = bm25.get_scores(query)
for index in scores.argsort()[::-1]:
print(f"{scores[index]:.3f}\t{documents[index]}")需要特别注意:rank-bm25 不会自动替你完成中文分词、停用词过滤或文本规范化。索引和查询必须使用完全一致的预处理流程,否则文档里是 BM25,查询被处理成 bm25,两者就可能无法命中。
另外,不同实现的 IDF 平滑、负 IDF 处理和默认参数可能不同,因此示例库的分数不一定与 Lucene/Elasticsearch 完全一致。比较结果前要先确认使用的是哪一种公式。
BM25 与向量检索有什么区别?
BM25 属于稀疏检索,依据离散词项是否重合来打分;向量检索属于稠密检索,通过 Embedding 在向量空间中寻找语义相近的内容。
| 对比项 | BM25 | 向量检索 |
|---|---|---|
| 核心依据 | 关键词重合、词频、文档频率 | 向量空间中的语义相似度 |
| 优势 | 精确词、编号、代码、专有名词;可解释;成本低 | 近义词、改写、跨表达方式的语义匹配 |
| 弱点 | 同义不同词时可能完全错过 | 精确字符串和冷门实体有时不稳定 |
| 是否需要训练模型 | 不需要 | 需要 Embedding 模型 |
| 索引形态 | 倒排索引 | 向量索引,如 HNSW |
例如查询“接口返回 504”,BM25 很容易精确命中包含 504 的故障文档;查询“请求一直没有响应”,向量检索可能找到标题为“连接超时处理”的文档,即使两边没有使用完全相同的词。
它们不是非此即彼。实际 RAG 系统常常同时执行两路检索,再融合结果,这就是混合检索(Hybrid Search)。
一种常见的融合方法是 RRF(Reciprocal Rank Fusion,倒数排名融合):
它主要看文档在每一路结果中的名次,而不是直接相加原始分数。这样做很实用,因为 BM25 分数与向量余弦相似度通常不在同一量纲,直接相加往往没有明确意义。
一个典型的 RAG 检索链路可以是:
问题
├─ BM25 召回 Top 50
└─ 向量召回 Top 50
↓
RRF 融合
↓
Reranker 重排 Top 20
↓
取 Top 5 交给 LLM在 RAG 中使用 BM25 的实践建议
1. 分块大小会直接影响得分
BM25 会做长度归一化,因此 chunk 的长度分布很重要。一个 chunk 只有一句话,另一个 chunk 却有十页内容,两者很难公平比较。
分块时可以:
- 让 chunk 长度大体稳定;
- 尽量按段落、标题或语义边界切分;
- 保留标题并拼接到正文中,因为标题往往包含高价值关键词;
- 记录
document_id、章节和页码,方便召回后还原上下文。
2. 中文分词比调参数更值得先关注
英文通常可以按空格和词法规则切分,中文则必须决定如何分词。“大语言模型”可能被切成一个词,也可能被切成“大 / 语言 / 模型”。分词结果会同时影响 TF、DF、IDF 和文档长度。
在急着调整 、 之前,先检查:
- 查询和文档是否使用同一个分词器;
- 英文大小写、全角半角、繁简体是否统一;
- 型号和标识符是否被错误拆分,如
GPT-5、ERR_CONNECTION_RESET; - 是否需要领域词典、同义词表和停用词表。
3. 不要迷信默认参数
、 是很好的起点,不是所有数据集的最优答案。
- 文档长度差异小,可以尝试降低 ;
- 长文档更容易因偶然命中而上榜,可以尝试提高 ;
- 关键词重复次数很有业务意义,可以尝试提高 ;
- 短字段如标题,可以降低长度归一化,甚至单独设置字段权重。
调参时不要只看几个例子,应准备带有相关性标注的查询集合,通过 Recall@K、MRR、nDCG 等指标比较。
4. 多字段文档可以使用 BM25F 思路
标题、正文、标签的价值并不相同。标题里命中一次,通常比正文角落里命中一次更重要。工程上可以给字段设置不同权重,例如:
最终分数 = 3 × title_score + 1 × body_score + 2 × tag_score严格来说,多字段统一建模对应 BM25F;搜索引擎也常通过字段 boost 实现相似效果。
5. 线上系统要保留可解释信息
BM25 的优势之一就是容易解释。调试时建议记录:
- 查询经过分词后得到什么;
- 每个查询词的 DF、IDF;
- 文档中每个词的 TF;
- 文档长度和平均长度;
- BM25、向量检索、融合与重排各阶段的名次。
如果某篇奇怪的文档排到了第一,就能判断是分词问题、稀有词权重过高、chunk 过短,还是融合阶段出了问题。
常见误区
“BM25 就是关键词出现次数”
不准确。它同时考虑词的稀有程度、词频饱和和文档长度。关键词出现次数只是其中一个输入。
“BM25 分数越接近 1 越好”
错误。BM25 分数不是 的相似度或概率,数值大小依赖语料库、查询和具体实现。最重要的是同一查询下的相对排名。
“有了向量数据库,就不需要 BM25”
也不准确。向量检索善于理解语义,BM25 善于精确匹配。对于技术文档、代码、商品型号、药品名、法规编号等内容,两者结合往往更稳。
“换一个 和 就能解决召回问题”
很多时候真正的问题在分词、分块、字段设计或语料质量。参数只能调整已有信号的权重,无法召回一个在词项层面完全没有匹配的文档。
总结
BM25 的公式虽然长,但核心直觉可以压缩成三句话:
- 稀有词比常见词更有区分度,由 IDF 负责;
- 词重复出现有帮助,但收益逐渐饱和,由 TF 饱和负责;
- 长文档更容易偶然命中,需要长度归一化,由 和文档长度负责。
在 RAG 中,BM25 是一个便宜、快速、可解释的关键词检索基线。它和向量检索不是替代关系:BM25 抓住“字面上必须对”的内容,向量检索补上“意思相近但说法不同”的内容,再通过 RRF 和 Reranker 组合,通常能得到更可靠的召回结果。
参考资料
- Stephen Robertson、Hugo Zaragoza:The Probabilistic Relevance Framework: BM25 and Beyond
- Apache Lucene:BM25Similarity API
- Elastic:Practical BM25 — Part 2: The BM25 Algorithm and its Variables
rank-bm25:Python 实现与使用示例