BM25 稀疏向量是怎么来的?聊聊分词器在 RAG 中的角色
文本是怎么变成 BM25 稀疏向量的? 答案就是:先分词,再算 BM25 权重,再变成 sparse vector。
在做 RAG(检索增强生成)项目时,BM25 是一个绕不开的经典算法。但很多人对 BM25 的理解只停留在「关键词匹配」这个层面,对它内部到底做了什么、分词器在哪个环节起作用、sparse vector 里的数字到底怎么来的,其实很模糊。
这篇文章就是要把这些问题一次讲清楚。
完整流程:从 PDF 到检索
先看全貌。BM25 稀疏检索分为离线建库和在线查询两个阶段:
1 | 离线建库 |
Qdrant 官方对 sparse retrieval 的解释也是:每一个 token/词对应稀疏向量中的某个维度,查询时主要匹配共同出现的 token。
为什么中文尤其需要分词器?
英文天然有空格:
1 | I like apple phones |
切词非常简单:["I", "like", "apple", "phones"]
但中文:
1 | 苹果手机续航很好 |
没有空格。计算机得先决定:
苹果手机 / 续航 / 很好- 还是
苹果 / 手机 / 续航 / 很 / 好
这就是 tokenizer(分词器) 干的事情。
jieba 就是一个经典的中文分词器:
1 | import jieba |
然后 BM25 才能统计:
1 | "苹果手机" 出现几次 → TF |
最终形成:
1 | indexes = [38192, 72831, ...] |
也就是你存进 Qdrant 的 sparse vector。
三者的关系
很多人搞混 jieba、BM25、Qdrant 这三者的关系。其实它们完全不是一个层面的东西:
1 | jieba → 负责:文本 → token |
一句话总结:
jieba 决定”有哪些词”,BM25 决定”这些词有多重要”,Qdrant 决定”哪些 Chunk 最匹配”。
BM25 的权重计算:TF、IDF、文档长度
BM25 给一个词算权重时,核心考虑三个东西:
- TF:这个词在当前 Chunk 出现多少次
- IDF:这个词在整个知识库里有多稀有
- 文档长度:这个 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 | 公司规定员工每年享受年假。 |
分词后:
1 | 公司 × 2 |
朴素想法是年假最重要(出现 3 次)。但 BM25 不会直接使用原始 TF。
如果一个 Chunk 疯狂重复「年假」:
1 | 3 次 → 很相关 |
所以 BM25 有一个 TF 饱和机制——由参数 $k$ 控制:
1 | 出现次数 |
越往后收益越小。
② IDF(逆文档频率):越稀有的词越重要
假设 Qdrant 里有 1000 个 Chunk:
1 | "公司" 出现在 800 个 → 到处都有,没区分度 |
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 | Chunk:"公司 员工 年假 申请 流程" |
只算文档内部 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 | 文档侧 |
官方明确说明:使用 Modifier.IDF 后,document sparse vector 不需要自己包含 IDF,Qdrant 在查询评分阶段自动应用 IDF。
所以完整的 BM25 链路是:
1 | 文本 → Tokenizer → Token + TF → BM25 TF饱和 + 长度归一化 → Sparse Vector → 存入 Qdrant |
最后那个 Σ(求和) 也很关键:Query 如果是「员工年假怎么申请」,那么「员工」「年假」「申请」各自贡献一个分数,全部加起来就是最终得分。
PDF 更新后需要全量重算 BM25 吗?
不一定。
关键要把 BM25 里的两部分拆开:
1 | BM25 = 文档自身权重 + 全局 IDF |
| 部分 | 依赖什么 | 更新影响 |
|---|---|---|
| 文档自身权重 | 当前 Chunk 的 TF、长度、k/b/avg_len | 只影响自己 |
| IDF | 整个 Collection 的文档统计 | Qdrant 自动维护 |
由于 Qdrant 的 Modifier.IDF 把 IDF 放在查询阶段动态计算,文档增删后:
1 | PDF 更新 |
但 avg_len 是个例外
avg_len 不像 IDF 那样随 Collection 自动更新,它是 BM25 embedding 配置的一部分。如果你一直保持 avg_len = 256 不变,新增文档完全没问题。
只有当你修改了 avg_len 参数(比如从 256 改成 500),才需要全量重建所有 sparse vector——因为新旧 Chunk 的权重计算基准不一致了。
增量更新的最佳实践
结合 PDF 哈希 diff 和 Qdrant 原生 BM25:
1 | PDF 更新 |
1 | PDF hash 没变 → 什么都不做 |
一句话总结
BM25 本质是在 TF-IDF 基础上,引入 TF 饱和和文档长度归一化:词在当前文档出现越多越重要,但收益逐渐饱和;词在整个语料库越稀有,IDF 越高;同时会惩罚过长文档。
而 Qdrant 的 Modifier.IDF 设计让 IDF 动态化,使得文档增删只需更新受影响的 Chunk,不需要全库重算——这正是企业级 RAG 增量更新的基础。
附:Hybrid Search 完整心智模型
如果你的 RAG 系统同时使用 Dense 和 Sparse 检索(Hybrid Search),完整流程是:
1 | Chunk |
jieba 在这个流程中的位置:在 BM25 sparse vector 生成之前,负责把文本切成 token。
本文整理自与 ChatGPT 的对话,感谢它在 RAG 技术细节上的耐心讲解。

