从关键词到智能检索:深入理解 BM25
以 Dify 知识库混合检索为例,一步步搞懂 BM25 的原理、能力边界和工程落地。
开场:一个你一定遇到过的问题
假设你搭建了一个 AI 知识库应用(比如 Dify),用户提了一个问题:
"查一下张三本月的拜访明细"
知识库里有几百条文档。系统是怎么从这些文档里找到最相关的那几条返回给用户的?
这就是检索(Retrieval)要解决的核心问题。
Dify 的知识库提供了三种检索模式:
- 向量检索——基于语义相似度
- 全文检索——基于关键词匹配(BM25)
- 混合检索——两者结合
今天我们聚焦其中的 BM25——一个看似简单却极其有效的关键词匹配算法。
第一章:BM25 是什么?一句话版本
BM25 简单说,就是通过关键词去对比知识库中的文档,找出最相关的那些返回给你。
它的全称是 Best Match 25(第 25 次迭代改进后的最佳匹配模型),但你完全不需要记这个名字。
你只需要记住它做的事:用户搜了什么词,文档里有没有这些词,有多少,据此打一个分。
听起来很简单?但是——
第二章:最朴素的想法——数关键词,行不行?
🤔 思考一下:如果我们用最简单的方式来检索——数一下用户的关键词在每篇文档里出现了多少次,出现最多的排最前面——这个方案可行吗?
试试看:
| 文档 | 内容摘要 | "拜访"出现次数 |
|---|---|---|
| 文档 A | 张三本月拜访了 3 个客户的详细记录 | 1 次 |
| 文档 B | 公司拜访制度规定:所有拜访需提前登记,拜访后需填写拜访记录表,拜访审批流程...(500字规章制度) | 5 次 |
| 文档 C | 客户经理拜访明细表:包含拜访日期、客户名称... | 2 次 |
按"出现次数越多越相关"的规则,文档 B 排第一。
但你觉得,用户问"张三本月的拜访明细",文档 B(一篇冗长的规章制度)真的最相关吗?
显然不是。这暴露了第一个问题——
第三章:问题一——长文档天然占便宜,怎么办?
一篇 500 字的规章制度,"拜访"出现 5 次很正常。一篇 50 字的拜访记录摘要,"拜访"出现 2 次,其实密度更高。
🤔 思考一下:怎么让短文档和长文档公平竞争?
直觉告诉我们:不能只看绝对次数,要考虑文档长度。
这就是 BM25 的第一个武器——
📐 文档长度归一化(Length Normalization)
BM25 引入了一个参数 b(默认 0.75),用来调节"文档长度的惩罚力度":
- 计算每篇文档的长度相对于所有文档平均长度的比值
- 文档越长于平均值,得分打折越多
- 文档越短于平均值,得分相对越高
直觉理解:同样命中 2 次"拜访",50 字短文档里命中和 500 字长文档里命中,短的那个更可能是真正相关的。
| b 的值 | 效果 |
|---|---|
| b = 0 | 完全不考虑文档长度(长文档不受惩罚) |
| b = 1 | 严格按长度比例归一化 |
| b = 0.75 | 默认值,适合大多数场景 |
好,长文档占便宜的问题解决了。但还有第二个问题——
第四章:问题二——出现 100 次 vs 出现 5 次,真的差 20 倍吗?
假设有一篇文档,"拜访"出现了 100 次(可能是一篇拜访记录大全),另一篇出现了 5 次。
🤔 思考一下:100 次的那个,真的比 5 次的那个相关度高 20 倍吗?
不太对。出现 5 次已经说明这篇文档跟"拜访"高度相关了,出现 100 次只是因为文档更长或者更啰嗦,不能给 20 倍加分。
这就是 BM25 的第二个武器——
📈 词频饱和(TF Saturation)
BM25 对词频的处理不是线性的,而是递减的:
| "拜访"出现次数 | 贡献的分数(示意) |
|---|---|
| 1 次 | 1.0 |
| 2 次 | 1.6 |
| 5 次 | 2.1 |
| 10 次 | 2.3 |
| 100 次 | 2.4 |
出现次数越多,边际收益越小。 从 0 到 1 是质变(这个词到底有没有),从 5 到 100 只是小幅增加。
这个饱和速度由参数 k1(默认 1.2)控制。k1 越大,饱和越慢,对高词频文档越宽容。
好,词频的问题也解决了。还有第三个问题——
第五章:问题三——"的""了""是"出现 1000 次,能说明什么?
用户搜"查一下张三的拜访明细",分词后得到:["查", "一下", "张三", "的", "拜访", "明细"]。
其中"的"这个字,几乎在每一篇文档里都会出现。
🤔 思考一下:一个在所有文档里都存在的词,对"区分哪篇文档更相关"有帮助吗?
没有。"的"出现在每篇文档里,说明它对区分文档没有任何价值——它是噪声。
反过来,"张三"可能只在 3 篇文档里出现——这个词的区分度非常高。
这就是 BM25 的第三个武器——
🔍 逆文档频率(IDF, Inverse Document Frequency)
核心直觉:一个词在越多文档里出现,它越没有区分价值;在越少文档里出现,它越有价值。
$$\text{IDF}(t) = \log\left(\frac{N - df + 0.5}{df + 0.5} + 1\right)$$
- $N$:知识库总文档数
- $df$:包含这个词的文档数
举例(假设知识库有 100 篇文档):
| 词 | 出现在多少篇文档中 | IDF(约) | 解读 |
|---|---|---|---|
| "的" | 99 篇 | ≈ 0.01 | 几乎没有区分价值 |
| "拜访" | 30 篇 | ≈ 1.2 | 有一定区分价值 |
| "张三" | 3 篇 | ≈ 3.5 | 区分价值非常高 |
| "Q4季度" | 1 篇 | ≈ 4.6 | 极高区分价值 |
IDF 自动帮我们解决了"无意义的助词和停用词"的问题——不需要手动维护停用词表,BM25 通过 IDF 自然地给它们接近 0 的权重。
第六章:三个武器合体——BM25 完整公式
现在把三个核心因素合在一起:
$$\text{score}(d, q) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{\text{TF}(t,d) \cdot (k1 + 1)}{\text{TF}(t,d) + k1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}$$
不需要记住公式,记住本质就行:
BM25 = 对每个查询词,计算 "词频(有饱和上限)× IDF(区分价值)",再用文档长度做归一化,最后把所有查询词的分数加起来。
用一张图概括:
用户输入:"查一下张三的拜访明细"
│
▼
┌─ 分词 ─┐
│ │
"查" "一下" "张三" "的" "拜访" "明细"
│ │
▼ ▼
┌───────────────────────────┐
│ 对每个词,在每篇文档中: │
│ │
│ ① 查词频 TF(出现几次) │
│ ② 加饱和(防止高频垄断) │
│ ③ 除以文档长度(公平竞争) │
│ ④ 乘以 IDF(区分价值) │
│ │
│ 所有词的分数加起来 = 文档得分 │
└───────────────────────────┘
│
▼
按得分排序,返回 Top K
一个具体的打分过程
假设知识库有 100 篇文档,平均长度 50 词。
文档 A(30 词):张三本月拜访了华南区3个客户,拜访明细如下...
用户搜索:"张三 拜访 明细"
| 查询词 | TF(出现次数) | 饱和后 TF | IDF | 文档长度惩罚 | 该词贡献分 |
|---|---|---|---|---|---|
| 张三 | 1 | 0.85 | 3.5 | 0.85(短文档加成) | 2.53 |
| 拜访 | 2 | 1.15 | 1.2 | 0.85 | 1.17 |
| 明细 | 1 | 0.85 | 2.8 | 0.85 | 2.02 |
| 合计 | 5.72 |
文档 B(200 词):公司拜访管理制度:拜访流程规定...拜访审批...拜访记录...
| 查询词 | TF | 饱和后 TF | IDF | 文档长度惩罚 | 该词贡献分 |
|---|---|---|---|---|---|
| 张三 | 0 | 0 | 3.5 | — | 0 |
| 拜访 | 5 | 1.35 | 1.2 | 1.25(长文档惩罚) | 1.30 |
| 明细 | 0 | 0 | 2.8 | — | 0 |
| 合计 | 1.30 |
结果:文档 A(5.72)远胜文档 B(1.30)。 虽然 B 里"拜访"出现了 5 次,但:
- "张三""明细"根本没出现 → 0 分
- 文档太长 → 长度惩罚
- "拜访"词频虽高但饱和了 → 收益有限
BM25 用三个武器的组合拳,让"真正相关的短文档"胜过"虚胖的长文档"。
第七章:BM25 能解决什么,不能解决什么?
到这里你已经理解了 BM25 的打分逻辑。但别急着觉得它万能——
🤔 思考一下这几个场景,BM25 能正确处理吗?
| 场景 | 用户输入 | BM25 能处理吗 |
|---|---|---|
| ① 精确关键词 | "张三 拜访明细" | ✅ 完美胜任 |
| ② 专有名词 | "华南区大客户部" | ✅ 精确匹配擅长 |
| ③ 同义词 | "走访" = "拜访" | ❌ 不认识同义词 |
| ④ 否定词 | "没有拜访" vs "有拜访" | ❌ 两句 BM25 得分几乎相同 |
| ⑤ 语序 | "谁拜访了客户" vs "客户拜访了谁" | ❌ 词袋模型,不看顺序 |
| ⑥ 口语化 | "最近有没有人去过啊" | ❌ 关键词覆盖率低 |
本质原因:BM25 是"词袋模型"
BM25 把文档当成一个装满单词的袋子,只关心有没有这个词、有几个,不关心词的顺序和语义关系。
"查询有拜访记录的客户经理" → 词袋:{查询, 有, 拜访, 记录, 客户经理}
"查询无拜访记录的客户经理" → 词袋:{查询, 无, 拜访, 记录, 客户经理}
两个词袋几乎一样(只多了一个"无"/"有"),但语义天差地别。更糟糕的是,"无"和"有"的 IDF 都很低(太常见),几乎不贡献分数。
BM25 能力边界总结
| BM25 擅长 ✅ | BM25 不擅长 ❌ |
|---|---|
| 精确关键词命中 | 语义理解 |
| 专有名词、产品编码匹配 | 同义词识别 |
| 大规模文档的快速初筛 | 否定、转折等逻辑关系 |
| 计算速度极快(毫秒级) | 口语化、模糊的表达 |
| 可解释(知道为什么命中) | 词序和上下文依赖 |
第八章:补短板——Dify 混合检索是怎么做的?
既然 BM25 有这些盲区,Dify 为什么还要用它?
答案是:不是单独用,而是和向量检索互补。 这就是"混合检索"。
向量检索的原理(一句话版)
把文档和查询都编码成一组数字(向量),计算两个向量之间的"距离"。距离越近 = 语义越相似。
- "走访客户" 和 "拜访客户"——词不一样,但向量很接近 → 能匹配上 ✅
- "没有拜访" 和 "有拜访"——向量模型能理解否定 → 能区分 ✅
但向量检索也有弱点:
- 对精确的专有名词(如"华南区""张三")不如关键词匹配敏感
- 计算速度比 BM25 慢
- 有时会"过度联想"(返回语义相近但实际不相关的文档)
取长补短:BM25 + 向量检索
| BM25 | 向量检索 | |
|---|---|---|
| 精确关键词 | ✅ 强 | ❌ 弱 |
| 语义理解 | ❌ 弱 | ✅ 强 |
| 专有名词 | ✅ 强 | ❌ 弱 |
| 同义词 | ❌ 不认识 | ✅ 能匹配 |
| 速度 | 极快(<10ms) | 较慢(50~200ms) |
两个都召回,再合并排序,就能互补短板。
RRF:怎么合并两路结果?
Dify 混合检索的合并策略叫 RRF(Reciprocal Rank Fusion,倒数排名融合)。
核心思路很直觉:不看分数(两路分数量纲不同没法比),只看排名。
$$\text{RRF_score}(doc) = \sum_{i} \frac{1}{k + rank_i}$$
- $k$:平滑参数,通常取 60
- $rank_i$:这篇文档在第 $i$ 路检索器中的排名
举例:
| 文档 | BM25 排名 | 向量检索排名 | RRF 分(k=60) |
|---|---|---|---|
| 文档 A | 第 1 名 | 第 3 名 | 1/61 + 1/63 = 0.0321 |
| 文档 B | 第 5 名 | 第 1 名 | 1/65 + 1/61 = 0.0318 |
| 文档 C | 第 2 名 | 第 2 名 | 1/62 + 1/62 = 0.0323 |
文档 C 胜出——两路检索器都认为它排第 2,综合下来最稳。
用一个类比:像用户选店。两个App(大众点评=BM25、米其林=向量检索)分别给餐厅打分,但一个用百分制、一个用星星,分数根本没法直接比!用户只看各自榜单的排名——在两个App里都排前面的餐厅,就是双榜认证的最稳妥首选。
完整的 Dify 混合检索链路
用户提问
│
├─→ [BM25 全文检索] → 排名 Top 20
│
├─→ [向量检索] → 排名 Top 20
│
└──→ [RRF 合并排序]
│
▼
Top K 结果(通常 3~5 条)
│
▼
[可选:Rerank 精排] ← 用专门的语义模型做二次排序
│
▼
[送给 LLM 生成回答]
第九章:Rerank——第二轮精排
🤔 思考一下:RRF 合并后的 Top 5 结果就够好了吗?
大部分场景够了。但有一些 corner case:
- BM25 把一篇关键词高度匹配但实际不相关的文档排到第 1
- 向量检索把一篇语义相似但领域不对的文档排到第 1
- 两路都把它排到了前面 → RRF 也把它排到了前面
Rerank 模型(如 Cohere Rerank、BGE Rerank)做的事:
接收用户查询和候选文档,用一个专门训练的交叉编码模型重新打分。这个模型能理解更细粒度的语义关系,包括否定、转折等。
RRF 合并 Top 20 → Rerank 精排 → 最终 Top 3~5
Rerank 为什么不直接用来检索?
因为它是"逐对比较"的,需要把查询和每一篇候选文档配对计算,太慢了。只适合对已经缩小范围的候选集做精排。
这就是一个漏斗架构的经典思路:
- 召回层(BM25 + 向量):快速但粗糙,尽可能捞全
- 精排层(Rerank):慢但精准,在小范围内精细排序
第十章:怎么知道检索效果好不好?——评估指标
你优化了分词词典、调了 BM25 参数、换了 Rerank 模型……效果到底是变好了还是变差了?
不能靠"感觉"。需要量化指标。
搭建评估的前提:Ground Truth 测试集
你需要准备一组"标准答案":
test_set = [
{"query": "张三本月拜访了哪些客户", "relevant_docs": ["doc_001", "doc_003"]},
{"query": "哪些人本季度没去拜访", "relevant_docs": ["doc_007"]},
{"query": "拜访次数月度变化趋势", "relevant_docs": ["doc_012", "doc_015"]},
# ... 建议至少 30~50 条
]
指标一:Hit Rate@K(命中率)
在 Top K 个召回结果里,有没有包含正确答案?
$$\text{HitRate@K} = \frac{\text{命中的查询数}}{\text{总查询数}}$$
- HitRate@5 = 0.8 意味着:80% 的查询,正确答案在前 5 条里
- 这是最基础的指标——先保证召回了,再谈排序
- 目标:HitRate@5 ≥ 0.85 才适合上线
指标二:MRR(Mean Reciprocal Rank,平均倒数排名)
正确答案排在第几位?越靠前越好。
$$\text{MRR} = \frac{1}{N} \sum_{i=1}^{N} \frac{1}{\text{rank}_i}$$
| 查询 | 正确答案排名 | 倒数排名 |
|---|---|---|
| Q1 | 第 1 名 | 1/1 = 1.0 |
| Q2 | 第 3 名 | 1/3 = 0.33 |
| Q3 | 没召回 | 0 |
| MRR | (1.0 + 0.33 + 0) / 3 = 0.44 |
- MRR 越接近 1,正确答案越经常排在第 1 位
- 适合评估排序质量
两个指标配合使用
| 情况 | HitRate@5 | MRR | 诊断 |
|---|---|---|---|
| 理想状态 | 0.9 | 0.85 | 召回好,排序也好 |
| 召回了但排不好 | 0.9 | 0.3 | 正确答案在前 5 但经常排在第 4、5 位,需要优化排序 |
| 根本没召回 | 0.4 | 0.3 | 大量查询没命中,优先解决召回问题(分词、意图库覆盖) |
评估代码示例
import jieba
from rank_bm25 import BM25Okapi
# 知识库文档
documents = [
"查询指定客户经理的拜访明细,按日期筛选",
"查询本月所有客户经理的拜访记录列表",
"查询本月零拜访的客户经理名单",
"按月统计各客户经理拜访数量,生成趋势数据",
]
tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
bm25 = BM25Okapi(tokenized_docs)
# 测试集
test_set = [
("张三上个月的拜访明细", [0, 1]),
("哪些客户经理没有去拜访", [2]),
("拜访次数的变化趋势", [3]),
]
# 计算指标
def evaluate(test_set, bm25, k=3):
hit_count = 0
reciprocal_ranks = []
for query, relevant_ids in test_set:
tokens = list(jieba.cut(query))
scores = bm25.get_scores(tokens)
top_k = scores.argsort()[::-1][:k].tolist()
hit = any(rid in top_k for rid in relevant_ids)
if hit:
hit_count += 1
rr = 0.0
for rank, doc_id in enumerate(top_k, start=1):
if doc_id in relevant_ids:
rr = 1.0 / rank
break
reciprocal_ranks.append(rr)
hit_rate = hit_count / len(test_set)
mrr = sum(reciprocal_ranks) / len(reciprocal_ranks)
print(f"HitRate@{k}: {hit_rate:.2f}")
print(f"MRR: {mrr:.2f}")
evaluate(test_set, bm25, k=3)
每次改动分词词典、调参、调整知识库后,重跑一遍评估脚本——用数据说话,而不是凭感觉。
第十一章:工程落地的关键细节
理解了原理之后,在实际项目里用 BM25,还有几个绕不开的工程问题。
1. 中文分词是前提,不是优化项
BM25 的一切都建立在"分词"之上。英文天然用空格分词,中文必须借助分词器。
# 不分词 → BM25 完全失效
"查一下张三的拜访明细".split()
# → ['查一下张三的拜访明细'] ← 整句话变成一个 token,和知识库匹配不上
# 用 jieba 分词 → BM25 正常工作
import jieba
list(jieba.cut("查一下张三的拜访明细"))
# → ['查', '一下', '张三', '的', '拜访', '明细']
在 Dify 里,ES 的 IK 分词器承担了这个工作。但分词质量直接影响 BM25 效果:
| 分词问题 | 例子 | 影响 |
|---|---|---|
| 业务词被切碎 | "未拜访" → "未" + "拜访" | 否定词语义丢失 |
| 专有名词未识别 | "华南区" → "华南" + "区" | 匹配精度下降 |
| 过度分词 | "客户经理" → "客户" + "经理" | 误匹配到只包含"客户"的文档 |
解决方案:自定义分词词典,把业务专有名词加进去。
# jieba 加自定义词
jieba.add_word("未拜访", freq=10000)
jieba.add_word("华南区", freq=10000)
jieba.add_word("客户经理", freq=10000)
# ES 的 IK 分词器也支持自定义词典
2. 否定词的处理策略
BM25 天然不理解否定词,但可以通过查询改写来缓解:
negation_map = {
"没有拜访": "未拜访",
"没去拜访": "未拜访",
"一次都没": "未拜访",
"没拜访": "未拜访",
}
def rewrite_query(query: str) -> str:
for pattern, replacement in negation_map.items():
if pattern in query:
query = query.replace(pattern, replacement)
return query
# "谁一次都没去拜访" → "谁未拜访"
# 配合 "未拜访" 在词典中不被拆开,BM25 就能正确匹配
成本极低(纯字符串替换,0ms),能覆盖 80% 高频否定表达。
3. 知识库文档的设计也影响 BM25 效果
不是所有文档形态对 BM25 都友好的:
| 文档设计 | 对 BM25 的影响 |
|---|---|
| 每条文档短小精悍(50~200 字) | ✅ 好,文档长度归一化效果最佳 |
| 一篇文档塞了几千字 | ❌ 差,关键词被稀释,长文档被过度惩罚或惩罚不够 |
| QA 对形式(问题 + 答案) | ✅ 好,用户提问直接和"问题"部分匹配 |
| 纯段落堆砌 | ❌ 差,BM25 不知道哪段和查询最相关 |
Dify 支持 QA 分段、父子分段等方式——对 BM25 来说,QA 分段是效果最好的形式,因为你可以把不同的问法都放在"Q"里,让 BM25 去匹配。
第十二章:完整的检索优化路线图
把前面所有内容串起来,一个知识库检索系统的优化路径是这样的:
阶段 0:基线搭建
└─ 准备 30~50 条测试集,计算当前 HitRate@5 和 MRR
└─ 这是你优化的起跑线,没有基线就无法衡量任何改进
阶段 1:分词优化
└─ 把业务专有名词加入分词词典
└─ 否定词统一改写
└─ 重新跑评估 → 对比基线
阶段 2:知识库文档优化
└─ 长文档拆分成短段落
└─ 重要文档补充 QA 对
└─ 重新跑评估 → 对比阶段 1
阶段 3:混合检索调优
└─ 调整 BM25 和向量检索的权重
└─ 引入 Rerank 做精排
└─ 重新跑评估 → 对比阶段 2
阶段 4:持续迭代
└─ 收集线上真实 query,分析 bad case
└─ 补充知识库、优化分词、扩充同义词
└─ 定期重新评估
每个阶段的核心动作:改一个变量 → 跑评估 → 看指标涨没涨。 不要一次改多个东西,否则不知道是哪个改动起了作用。
总结:BM25 的真实定位
| 问题 | 答案 |
|---|---|
| BM25 是什么 | 词频 × IDF × 文档长度归一化的关键词匹配算法 |
| 它解决什么 | 从大量文档中快速找出包含关键词的候选文档(召回) |
| 排序怎么做 | 给每篇文档打分:词频有饱和、长度要归一、常见词降权 |
| 无意义助词怎么处理 | IDF 自动将"的""了""是"等高频词的权重降到接近 0 |
| 长文档问题怎么处理 | 文档长度归一化,短文档和长文档公平竞争 |
| 它不能解决什么 | 同义词、否定词、语序、语义理解 |
| 如何补短板 | 分词词典 + 查询改写 + 向量检索互补 + Rerank 精排 |
| Dify 混合检索原理 | BM25 + 向量检索各自召回 → RRF 按排名合并 → 可选 Rerank |
| 如何评估效果 | HitRate@K 评召回,MRR 评排序,建测试集量化对比 |
一句话记住 BM25:它是检索漏斗的第一层过滤器——速度极快、成本极低、精确匹配擅长,但语义理解不行,需要搭档来补位。
附录:快速参考卡片
BM25 三要素速记
BM25 得分 = Σ (词频饱和 × IDF 区分度 ÷ 文档长度归一化)
① TF(词频):出现越多越相关,但有上限(k1=1.2)
② IDF(逆文档频率):越稀有的词越有价值
③ 文档长度:越短的文档,同等命中下越相关(b=0.75)
Dify 混合检索链路速记
用户提问 → BM25 召回 + 向量召回 → RRF 合并排序 → [Rerank 精排] → LLM 生成回答
评估指标速记
HitRate@K:Top K 里有没有正确答案(越高越好,≥0.85)
MRR:正确答案排第几(越接近 1 越好)
浙公网安备 33010602011771号