从关键词到智能检索:深入理解 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 为什么不直接用来检索?

因为它是"逐对比较"的,需要把查询和每一篇候选文档配对计算,太慢了。只适合对已经缩小范围的候选集做精排。

这就是一个漏斗架构的经典思路:

  1. 召回层(BM25 + 向量):快速但粗糙,尽可能捞全
  2. 精排层(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 越好)

posted on 2026-04-22 18:28  云卷云舒灬  阅读(88)  评论(0)    收藏  举报

导航