文本是怎么变成 BM25 稀疏向量的? 答案就是:先分词,再算 BM25 权重,再变成 sparse vector。

在做 RAG(检索增强生成)项目时,BM25 是一个绕不开的经典算法。但很多人对 BM25 的理解只停留在「关键词匹配」这个层面,对它内部到底做了什么、分词器在哪个环节起作用、sparse vector 里的数字到底怎么来的,其实很模糊。

这篇文章就是要把这些问题一次讲清楚。

完整流程:从 PDF 到检索

先看全貌。BM25 稀疏检索分为离线建库在线查询两个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
                离线建库
PDF

切 Chunk

"公司的年假制度是什么?"

【分词器 jieba】 ← 这是很多人漏掉的这一层

["公司", "年假", "制度", "是", "什么"]

统计 TF / DF / 文档长度

BM25 权重计算

Sparse Vector
{
公司: 0.4,
年假: 1.8,
制度: 1.2
}

存入 Qdrant


在线查询
Query
"年假有几天?"

【同一个分词器】

["年假", "有", "几天"]

BM25 Query Sparse Vector

Qdrant 匹配相同 token

计算 BM25 Score

返回 TopK 结果

Qdrant 官方对 sparse retrieval 的解释也是:每一个 token/词对应稀疏向量中的某个维度,查询时主要匹配共同出现的 token。


为什么中文尤其需要分词器?

英文天然有空格:

1
I like apple phones

切词非常简单:["I", "like", "apple", "phones"]

但中文:

1
苹果手机续航很好

没有空格。计算机得先决定:

  • 苹果手机 / 续航 / 很好
  • 还是 苹果 / 手机 / 续航 / 很 / 好

这就是 tokenizer(分词器) 干的事情。

jieba 就是一个经典的中文分词器:

1
2
3
4
import jieba

list(jieba.cut("苹果手机的续航怎么样"))
# → ["苹果手机", "的", "续航", "怎么样"]

然后 BM25 才能统计:

1
2
"苹果手机" 出现几次   → TF
"续航" 在多少 Chunk 出现 → DF / IDF

最终形成:

1
2
indexes = [38192, 72831, ...]
values = [1.21, 2.43, ...]

也就是你存进 Qdrant 的 sparse vector。


三者的关系

很多人搞混 jieba、BM25、Qdrant 这三者的关系。其实它们完全不是一个层面的东西:

1
2
3
jieba   → 负责:文本 → token
BM25 → 负责:token → 权重
Qdrant → 负责:存 sparse vector + 检索

一句话总结:

jieba 决定”有哪些词”,BM25 决定”这些词有多重要”,Qdrant 决定”哪些 Chunk 最匹配”。


BM25 的权重计算:TF、IDF、文档长度

BM25 给一个词算权重时,核心考虑三个东西:

  1. TF:这个词在当前 Chunk 出现多少次
  2. IDF:这个词在整个知识库里有多稀有
  3. 文档长度:这个 Chunk 是长还是短

经典 BM25 公式:

$$
\text{Score}(Q,D) = \sum_{q_i \in Q} \text{IDF}(q_i) \cdot \frac{\text{TF}(q_i,D) \cdot (k+1)}{\text{TF}(q_i,D) + k \cdot (1 - b + b \cdot \frac{|D|}{\text{avgdl}})}
$$

Qdrant 默认参数:$k = 1.2$,$b = 0.75$。

① TF(词频):出现越多越重要,但有饱和

假设一个 Chunk:

1
2
3
公司规定员工每年享受年假。
工作满一年可以享受年假,
年假申请需要通过公司系统提交。

分词后:

1
2
3
公司 × 2
年假 × 3
申请 × 1

朴素想法是年假最重要(出现 3 次)。但 BM25 不会直接使用原始 TF

如果一个 Chunk 疯狂重复「年假」:

1
2
3 次  → 很相关
30 次 → 应该更相关,但不能是 3 次的 10 倍重要

所以 BM25 有一个 TF 饱和机制——由参数 $k$ 控制:

1
2
3
4
5
6
出现次数
1 → █████
2 → ████████
3 → ██████████
10 → █████████████
100 → ██████████████

越往后收益越小。

② IDF(逆文档频率):越稀有的词越重要

假设 Qdrant 里有 1000 个 Chunk:

1
2
3
4
"公司"    出现在 800 个  → 到处都有,没区分度
"员工" 出现在 600 个 → 很常见
"年假" 出现在 80 个 → 比较重要
"丧假" 出现在 10 个 → 非常有区分度

Qdrant 使用的 IDF 公式:

$$
\text{IDF}(q) = \ln\left(\frac{N - n(q) + 0.5}{n(q) + 0.5} + 1\right)
$$

其中 $N$ = 总 Chunk 数,$n(q)$ = 包含该词的 Chunk 数。

出现越普遍,IDF 越低;出现越稀有,IDF 越高。

③ 文档长度惩罚

假设 Query 是「年假」,有两个 Chunk:

  • Chunk A:「员工年假为5天」(10 个 token,年假出现 1 次)
  • Chunk B:一篇 5000 字的制度文档,中间偶然提了一次「年假」(2000 个 token)

如果只看 TF,两者都是 1。但显然 A 的主题浓度更高。

BM25 加入了 $\frac{|D|}{\text{avgdl}}$(当前文档长度 / 平均文档长度)来惩罚过长文档,由参数 $b$ 控制。


实际算一个例子

假设:

1
2
3
4
Chunk:"公司 员工 年假 申请 流程"
长度 |D| = 5
"年假" 出现 1 次,TF = 1
Qdrant 默认:k = 1.2, b = 0.75, avg_len = 256

只算文档内部 BM25 TF 权重(不算 IDF):

$$
\frac{1 \times (1.2 + 1)}{1 + 1.2 \times (1 - 0.75 + 0.75 \times \frac{5}{256})} = \frac{2.2}{1 + 1.2 \times 0.2646} = \frac{2.2}{1.3176} \approx 1.6697
$$

巧合的是,Qdrant 官方文档里有一个类似例子,生成的 sparse vector 值正是 1.6697302

这个数字到底怎么来的,现在你明白了。


IDF 去哪了?Qdrant 的巧妙设计

这里有一个重要的细节。

很多人以为 BM25 sparse vector 里的数字已经包含了 IDF。但在 Qdrant + Modifier.IDF 的设计下:

文档侧只存储 TF 权重(含 TF 饱和 + 长度归一化),IDF 由 Qdrant 在查询阶段动态计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
          文档侧

Tokenizer

TF + 长度归一化 + TF 饱和

Sparse Vector(不含 IDF)

查询侧

找到 Query 与文档的共同 token

Qdrant 根据当前 Collection 计算 IDF

文档 TF 权重 × IDF = 最终 Score

官方明确说明:使用 Modifier.IDF 后,document sparse vector 不需要自己包含 IDF,Qdrant 在查询评分阶段自动应用 IDF

所以完整的 BM25 链路是:

1
2
3
文本 → Tokenizer → Token + TF → BM25 TF饱和 + 长度归一化 → Sparse Vector → 存入 Qdrant

Query → Tokenize → 匹配 token → Qdrant 动态乘 IDF → Σ 所有匹配词 → 最终 Score

最后那个 Σ(求和) 也很关键:Query 如果是「员工年假怎么申请」,那么「员工」「年假」「申请」各自贡献一个分数,全部加起来就是最终得分


PDF 更新后需要全量重算 BM25 吗?

不一定。

关键要把 BM25 里的两部分拆开:

1
BM25 = 文档自身权重 + 全局 IDF
部分 依赖什么 更新影响
文档自身权重 当前 Chunk 的 TF、长度、k/b/avg_len 只影响自己
IDF 整个 Collection 的文档统计 Qdrant 自动维护

由于 Qdrant 的 Modifier.IDF 把 IDF 放在查询阶段动态计算,文档增删后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PDF 更新

删除这个 PDF 对应的旧 Chunk

重新解析这个 PDF

只给新 Chunk 生成 BM25 sparse vector

upsert 到 Qdrant

结束

其他 PDF:
❌ 不重新切 Chunk
❌ 不重新生成 sparse vector
❌ 不重新计算 IDF(Qdrant 自动维护)

但 avg_len 是个例外

avg_len 不像 IDF 那样随 Collection 自动更新,它是 BM25 embedding 配置的一部分。如果你一直保持 avg_len = 256 不变,新增文档完全没问题。

只有当你修改了 avg_len 参数(比如从 256 改成 500),才需要全量重建所有 sparse vector——因为新旧 Chunk 的权重计算基准不一致了。


增量更新的最佳实践

结合 PDF 哈希 diff 和 Qdrant 原生 BM25:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
       PDF 更新

根据 document_id 找旧 Chunk

删除旧 Chunk

重新解析 PDF

新 Chunk
↙ ↘
Dense BM25
Embedding Sparse Vector
↘ ↙
Qdrant

IDF 自动随语料更新
1
2
PDF hash 没变  → 什么都不做
PDF hash 变了 → 删除旧 chunks → 重建该 PDF 的 dense + BM25 → 其他 PDF 完全不碰

一句话总结

BM25 本质是在 TF-IDF 基础上,引入 TF 饱和和文档长度归一化:词在当前文档出现越多越重要,但收益逐渐饱和;词在整个语料库越稀有,IDF 越高;同时会惩罚过长文档。

而 Qdrant 的 Modifier.IDF 设计让 IDF 动态化,使得文档增删只需更新受影响的 Chunk,不需要全库重算——这正是企业级 RAG 增量更新的基础。


附:Hybrid Search 完整心智模型

如果你的 RAG 系统同时使用 Dense 和 Sparse 检索(Hybrid Search),完整流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
          Chunk
↙ ↘
Dense Embedding Tokenizer
↓ ↓
dense vector tokens

BM25

sparse vector
↘ ↙
Qdrant

Dense + Sparse

Fusion

Rerank

最终结果

jieba 在这个流程中的位置:在 BM25 sparse vector 生成之前,负责把文本切成 token。


本文整理自与 ChatGPT 的对话,感谢它在 RAG 技术细节上的耐心讲解。