BM25:从词频、IDF 到 RAG 关键词检索
statistic
本文字数:4k 字 | 阅读时长 ≈ 15 min

BM25:从词频、IDF 到 RAG 关键词检索

statistic
本文字数:4k 字 | 阅读时长 ≈ 15 min

当我们在搜索框中输入一句话时,系统需要从大量文档中找出最相关的几篇,并决定它们的排列顺序。BM25 就是一种经典的文本相关性排序方法:它根据查询词在文档中的出现情况,为每篇文档计算一个分数,分数越高,说明文档与当前查询越相关。

BM25 不需要训练神经网络,也不需要把文本编码成稠密向量。它主要依赖词项是否精确匹配,因此特别擅长检索人名、产品型号、错误码和专业术语。如今它仍被广泛用于搜索系统,也经常作为 RAG 的关键词检索器,与向量检索配合使用。

本文先从一个搜索问题出发,再分别理解 IDF、词频饱和与文档长度归一化,最后实现一个可以直接运行的 BM25。

1. BM25 要解决什么问题

假设文档库中有三篇文档:

D1:BM25 是一种关键词检索方法
D2:向量检索使用语义表示
D3:混合检索结合 BM25 和向量检索

现在查询:

Q:向量检索

最简单的办法是统计查询词在每篇文档中出现了几次,但这会遇到几个问题:

BM25 同时处理了这三个问题。它并不是判断文档是否相关的分类器,而是一个排序函数:对同一个查询下的候选文档打分,然后比较相对大小。

BM 是 Best Matching 的缩写。BM25 是 Okapi 信息检索系统上一系列 Best Match 模型中的一个版本,其中的 25 是这套模型演化过程中的编号,没有需要代入公式的数学含义。

2. BM25 公式

设查询为 \(Q\),文档为 \(D\)。一个常见的 BM25 写法是:

\[ \operatorname{score}(D,Q) =\sum_{q_i\in Q} \operatorname{IDF}(q_i) \cdot \frac{f(q_i,D)(k_1+1)} {f(q_i,D)+k_1\left(1-b+b\frac{|D|}{\operatorname{avgdl}}\right)} \]

其中:

符号 含义
\(q_i\) 查询中的第 \(i\) 个词项
\(f(q_i,D)\) \(q_i\) 在文档 \(D\) 中出现的次数
$ D
\(\operatorname{avgdl}\) 文档库的平均文档长度
\(k_1\) 控制词频增长的饱和速度
\(b\) 控制文档长度归一化的强度

查询中的每个词项都会产生一部分分数,最后将它们相加。公式看起来比较长,核心其实只有三部分:IDF、词频饱和和长度归一化。

2.1 IDF:少见的词更重要

如果一个词几乎出现在每篇文档中,那么看到这个词并不能帮助我们区分文档;如果一个词只出现在少量文档中,它通常携带更多信息。因此,BM25 会根据文档频率为词项分配不同权重。

本文采用 Lucene 中使用的 IDF 形式:

\[ \operatorname{IDF}(q_i) =\log\left(1+\frac{N-n(q_i)+0.5}{n(q_i)+0.5}\right) \]

其中,\(N\) 是文档总数,\(n(q_i)\) 是包含词项 \(q_i\) 的文档数量。

当 \(n(q_i)\) 较小时,IDF 较大;当一个词出现在大部分文档中时,IDF 就会变小。公式外面的 \(1+\) 可以保证这里的 IDF 为正数。

需要注意,BM25 并不存在唯一的工程实现。论文、Lucene、Elasticsearch 和不同 Python 库可能采用略有差异的 IDF 或平滑方式,所以同一组数据的具体分数不一定完全相同,但排序思想是一致的。Lucene 的 BM25Similarity 文档给出了这里使用的 IDF 公式及默认参数。

2.2 词频饱和:出现更多,但收益递减

在其他条件不变时,查询词在文档中出现得越多,文档通常越相关。不过,这个关系不应该无限线性增长。

先暂时忽略文档长度,BM25 中与词频有关的部分可以近似看成:

\[ \frac{f(k_1+1)}{f+k_1} \]

当词频 \(f\) 从 0 增加到 1 时,分数明显增加;随着 \(f\) 继续增大,曲线会逐渐变平,最终趋近于 \(k_1+1\)。这就是词频饱和。

\(k_1\) 控制饱和速度:

Lucene 的默认值是 \(k_1=1.2\),实际系统也常在大约 \(1.2\) 到 \(2.0\) 的范围内调节。参数应根据自己的数据和检索指标确定,而不是认为某个经验值在所有任务上都最优。

2.3 文档长度归一化:避免长文档天然占优

长文档包含的词更多,因此更容易偶然命中查询词。BM25 使用下面这一项修正词频:

\[ 1-b+b\frac{|D|}{\operatorname{avgdl}} \]

当文档长度刚好等于平均长度时,这一项等于 1。当文档比平均长度长时,分母增大,相同词频得到的分数会降低;短文档则会受到相对较小的惩罚。

\(b\) 控制归一化强度:

长度归一化隐含了一个假设:长文档中的一次命中通常没有短文档中的一次命中那么集中。如果文档长度主要由固定模板、表格或代码造成,这个假设未必成立,此时就需要重新调节 \(b\),或者在建立索引前清理无关内容。

3. 手算一个例子

回到开头的三篇文档。为方便计算,我们直接给出分词结果:

D1 = [BM25, 是一种, 关键词, 检索, 方法]
D2 = [向量, 检索, 使用, 语义, 表示]
D3 = [混合, 检索, 结合, BM25, 和, 向量, 检索]
Q  = [向量, 检索]

三篇文档的长度分别为 5、5 和 7,因此:

\[ \operatorname{avgdl}=\frac{5+5+7}{3}\approx5.667 \]

“向量”出现在 2 篇文档中,“检索”出现在全部 3 篇文档中。按照上一节的 IDF 公式:

\[ \operatorname{IDF}(\text{向量}) =\log\left(1+\frac{3-2+0.5}{2+0.5}\right) \approx0.4700 \]

\[ \operatorname{IDF}(\text{检索}) =\log\left(1+\frac{3-3+0.5}{3+0.5}\right) \approx0.1335 \]

“向量”比“检索”少见,因此权重更高。取 \(k_1=1.5\)、\(b=0.75\),代入完整公式后得到:

文档 命中情况 BM25 分数
D1 只命中“检索” 0.1410
D2 命中“向量”和“检索”各一次 0.6373
D3 命中“向量”一次、“检索”两次 0.6023

最终排序为 \(D2>D3>D1\)。

D3 中“检索”出现了两次,但第二次出现的收益已经开始饱和,而且 D3 比平均文档更长,所以它没有超过更短、更集中的 D2。这正好体现了 BM25 的三个主要因素:稀有词权重、词频饱和和长度归一化。

这里的文档库非常小,数值只用于说明计算过程。实际系统中的 IDF 会在完整索引上统计,增加、删除文档后也可能变化。

4. BM25 与 TF-IDF 的关系

TF-IDF 同样使用词频 TF 和逆文档频率 IDF,它的基本思想是:某个词在当前文档中出现得多、在整个文档库中出现得少,那么这个词对当前文档就更重要。

BM25 可以看作在这种思想上进一步加入了两个实用约束:

因此,BM25 不是简单地把 TF 与 IDF 相乘。它用一个非线性函数重新组织词频和长度,使排序更适合实际文档检索。

对比项 TF-IDF BM25
稀有词权重 使用 IDF 使用 IDF
词频增长 取决于具体 TF 定义 明确进行饱和处理
长度归一化 常通过向量归一化处理 在公式中由 \(b\) 控制
可调参数 取决于实现 主要是 \(k_1\) 和 \(b\)

5. 使用 Python 实现 BM25

下面实现本文使用的公式。为了把重点放在 BM25 上,输入使用已经分词的 token 列表:

import math
from collections import Counter


class BM25:
    def __init__(self, corpus, k1=1.5, b=0.75):
        if not corpus:
            raise ValueError("corpus must not be empty")
        if k1 < 0:
            raise ValueError("k1 must be non-negative")
        if not 0 <= b <= 1:
            raise ValueError("b must be between 0 and 1")

        self.corpus = corpus
        self.k1 = k1
        self.b = b
        self.doc_count = len(corpus)
        self.doc_lengths = [len(doc) for doc in corpus]
        self.avg_doc_length = sum(self.doc_lengths) / self.doc_count
        self.term_frequencies = [Counter(doc) for doc in corpus]

        # document_frequency[token]:包含该 token 的文档数量
        self.document_frequency = Counter()
        for doc in corpus:
            self.document_frequency.update(set(doc))

    def idf(self, token):
        df = self.document_frequency.get(token, 0)
        return math.log(
            1 + (self.doc_count - df + 0.5) / (df + 0.5)
        )

    def score(self, query, doc_index):
        frequencies = self.term_frequencies[doc_index]
        doc_length = self.doc_lengths[doc_index]
        length_norm = 1 - self.b + self.b * (
            doc_length / self.avg_doc_length
        )

        score = 0.0
        # 当前常见的短查询场景只考虑查询词是否出现,避免重复查询词重复计分
        for token in set(query):
            frequency = frequencies.get(token, 0)
            if frequency == 0:
                continue

            numerator = frequency * (self.k1 + 1)
            denominator = frequency + self.k1 * length_norm
            score += self.idf(token) * numerator / denominator

        return score

    def search(self, query, top_k=None):
        results = [
            (index, self.score(query, index))
            for index in range(self.doc_count)
        ]
        results.sort(key=lambda item: item[1], reverse=True)
        return results if top_k is None else results[:top_k]


documents = [
    "BM25 是一种 关键词 检索 方法",
    "向量 检索 使用 语义 表示",
    "混合 检索 结合 BM25 和 向量 检索",
]

# 示例文本已经用空格切好词
corpus = [document.split() for document in documents]
query = "向量 检索".split()

bm25 = BM25(corpus)
for index, score in bm25.search(query):
    print(f"D{index + 1}: {score:.4f}  {documents[index]}")

输出为:

D2: 0.6373  向量 检索 使用 语义 表示
D3: 0.6023  混合 检索 结合 BM25 和 向量 检索
D1: 0.1410  BM25 是一种 关键词 检索 方法

这段代码为了展示公式,会遍历全部文档。生产级搜索系统不会在每次查询时这样计算,而是使用倒排索引:先记录每个词出现在哪些文档中,查询时只给命中查询词的文档打分,从而避免扫描整个文档库。

6. 中文检索首先要解决分词

英文通常可以按单词切分,而中文句子没有天然空格。如果把整句话当成一个 token,“向量检索”和“向量检索方法”就无法匹配;如果简单按单字切分,又可能产生大量含义不完整的公共字。

因此,中文 BM25 的效果很大程度上取决于分析流程:

原始文本
  ↓
文本清洗与字段选择
  ↓
中文分词 / 字符切分 / n-gram
  ↓
大小写、全半角与同义词归一化
  ↓
建立倒排索引
  ↓
BM25 打分

实际使用时需要保证文档和查询采用相同的分词与归一化规则。对于技术博客,还要谨慎处理 BM25、PyTorch、CUDA-12.4、报错信息和代码符号等专有 token;这些词往往正是关键词检索最有价值的部分。

停用词也不能机械删除。“的”“了”等高频词通常区分能力较低,IDF 本身已经会降低它们的权重;但在短查询或固定表达中,删除某些词也可能改变含义。是否停用、如何分词,应通过真实查询集进行评估。

7. BM25 与向量检索

BM25 和向量检索使用的是两种不同信号:

例如,查询“显卡显存不够”时,一篇只写了“GPU OOM”的文档可能在语义上相关,但如果没有进行同义词扩展,BM25 很难将两者联系起来。反过来,查询精确错误码、函数名或模型编号时,向量检索可能返回语义大致相关的内容,而 BM25 往往能更稳定地抓住精确字符串。

场景 BM25 向量检索
错误码、型号、函数名 擅长 可能发生模糊匹配
同义表达、自然语言描述 依赖词项重合 擅长
是否需要训练或编码模型 不需要 通常需要 embedding 模型
结果解释 可拆解到每个查询词 相对困难
新文档加入索引 统计词项并更新索引 需要计算 embedding

两者不是互相替代的关系。选择哪一种,取决于查询形式、文档类型和业务目标。

8. BM25 在 RAG 中的位置

RAG 一般先从知识库中召回候选文档,再把相关内容交给大模型生成答案。BM25 可以直接承担关键词召回,也可以和向量检索组成混合检索:

                     ┌─ BM25 关键词召回 ─┐
用户问题 → 查询处理 ┤                    ├→ 合并结果 → Reranker → LLM
                     └─ 向量语义召回 ────┘

一种常见做法是分别取 BM25 和向量检索的 Top-K,再通过加权分数或 Reciprocal Rank Fusion(RRF)合并排名,最后使用 Reranker 精排。这样既能保留专有名词和精确关键词,也能覆盖同义表达。

不过,BM25 分数只适合在当前查询的候选文档之间比较,它不是相关概率;不同查询的分数范围也可能不同。因此,混合检索时不能想当然地把 BM25 分数与余弦相似度直接相加,需要先做分数归一化,或者使用只依赖排名的融合方法。

9. 局限与实践建议

BM25 简单、快速且可解释,但它仍有明确的边界:

实践中可以先把 BM25 当作可靠的基线:准备一组真实查询和人工相关性标注,评估 Recall@K、MRR 或 NDCG,再判断问题究竟来自分词、索引字段、参数,还是需要增加向量召回与重排序。没有评估集时,只看几个演示查询很容易得到错误结论。

10. 总结

BM25 的核心可以概括为三句话:

  1. 在文档库中越少见的查询词,权重通常越高。
  2. 查询词在文档中重复出现会提高分数,但收益逐渐饱和。
  3. 相同词频下,过长的文档会受到一定的长度惩罚。

它不依赖神经网络,却能提供很强的关键词检索基线。在现代 RAG 系统中,BM25 也没有因为向量检索出现而失去价值:前者擅长精确匹配,后者擅长语义匹配,把两种信号结合起来通常比只依赖其中一种更稳健。

BM25 的概率相关性背景、不同变体和参数推导,可以继续参考 Robertson 与 Zaragoza 的综述 The Probabilistic Relevance Framework: BM25 and Beyond。

Sep 06, 2026
Aug 01, 2026