完整的中文 RAG 稀疏检索原型:先切块 → 分词 → 建 BM25 索引 → 查询匹配。本文通过一个可运行的 Python 示例,逐步拆解每个环节的原理与实现细节。
1. 整体流程概览#
整体流水线分为四步:
- 文档预处理(Chunking):将长文档切成适当长度的文本块(chunks),便于精准定位信息。
- 中文分词(Tokenization):使用 jieba 等分词工具将中文文本切成词语序列——中文没有天然的空格分隔,必须依赖分词器。
- 构建 BM25 索引:扫描所有 chunks,统计全局 IDF、每个 chunk 的词频(TF)和文档长度等统计量。
- 查询匹配与打分:对用户查询分词后,遍历所有 chunks 实时计算 BM25 分数,返回 Top-N 结果。
2. 完整代码实现#
import jieba
from rank_bm25 import BM25Okapi
from langchain_text_splitters import RecursiveCharacterTextSplitter
from collections import Counter
# ──────────────────────────────
# 1. 文档预处理 + 分词
# ──────────────────────────────
docs = [
"苹果手机最新功能包括AI摄影和长续航。发布会上提到了端侧AI与影像算法升级。",
"香蕉是一种热带水果,富含钾元素,适合作为日常补充能量的食物。",
"iPhone 16 Pro Max评测:屏幕更亮,影像更强,续航也有提升。",
]
# 中文分词函数(jieba 默认精确模式)
def tokenize_cn(text):
return [t.strip() for t in jieba.cut(text) if t.strip()]
# ──────────────────────────────
# 辅助函数:生成稀疏向量
# ──────────────────────────────
def bm25_doc_sparse_vector(bm25: BM25Okapi, doc_index: int) -> dict[str, float]:
"""为指定 chunk 生成 BM25 稀疏向量(每个 token → 权重)"""
freqs = bm25.doc_freqs[doc_index]
dl = bm25.doc_len[doc_index]
denom_const = bm25.k1 * (1.0 - bm25.b + bm25.b * dl / bm25.avgdl) # 长度归一化项
vec: dict[str, float] = {}
for token, tf in freqs.items():
idf = bm25.idf.get(token)
if idf is None:
continue
score = idf * (tf * (bm25.k1 + 1.0)) / (tf + denom_const) # 经典 BM25 TF 饱和公式
if score != 0.0:
vec[token] = float(score)
return vec
def bm25_query_sparse_vector(bm25: BM25Okapi, query_tokens: list[str]) -> dict[str, float]:
"""为查询生成稀疏向量(每个 token → IDF × query_tf)"""
qtf = Counter(query_tokens)
vec: dict[str, float] = {}
for token, tf in qtf.items():
idf = bm25.idf.get(token)
if idf is None:
continue
vec[token] = float(idf * tf)
return vec
def format_sparse_vector(vec: dict[str, float], vocab_index: dict[str, int], top_k: int = 20) -> list[tuple[int, float, str]]:
"""将稀疏向量格式化为 (vocab_id, weight, token) 三元组,按权重降序"""
items = sorted(vec.items(), key=lambda x: x[1], reverse=True)[:top_k]
return [(vocab_index[t], w, t) for t, w in items]
# ──────────────────────────────
# 分块(Chunking)
# ──────────────────────────────
splitter = RecursiveCharacterTextSplitter(
chunk_size=60,
chunk_overlap=12,
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)
chunks = []
chunk_sources = []
for i, doc in enumerate(docs):
for chunk in splitter.split_text(doc):
if chunk.strip():
chunks.append(chunk)
chunk_sources.append(i)
# ──────────────────────────────
# 2. 构建 BM25 索引
# ──────────────────────────────
tokenized_chunks = [tokenize_cn(chunk) for chunk in chunks]
print(tokenized_chunks)
bm25 = BM25Okapi(tokenized_chunks)
vocab_index = {token: i for i, token in enumerate(sorted(bm25.idf.keys()))}
# ──────────────────────────────
# 3. 查询匹配
# ──────────────────────────────
query = "苹果手机最新功能"
tokenized_query = tokenize_cn(query)
scores = bm25.get_scores(tokenized_query)
top_chunks = bm25.get_top_n(tokenized_query, chunks, n=3)
print("Top chunks:", top_chunks)
print("Query sparse vector:", format_sparse_vector(bm25_query_sparse_vector(bm25, tokenized_query), vocab_index, top_k=50))
for i, chunk in enumerate(chunks):
vec = bm25_doc_sparse_vector(bm25, i)
print(f"Chunk[{i}] source_doc={chunk_sources[i]} score={float(scores[i]):.6f} text={chunk}")
print("Sparse vector:", format_sparse_vector(vec, vocab_index, top_k=20))
3. 关键参数详解#
3.1 分块参数(RecursiveCharacterTextSplitter)#
chunk_size=60:每个 chunk 的目标长度上限(按字符数计算,不是按 token/词数)。越小 → chunk 越多、粒度越细,定位更精准但可能丢失上下文;越大 → chunk 越少、每块信息更完整但可能引入噪声。chunk_overlap=12:相邻 chunk 之间重叠的字符数。用途是避免关键信息刚好切在边界,导致"上一块缺后半句、下一块缺前半句"。重叠越大 → 冗余越多、召回更稳但索引成本更高。separators:递归分割的优先级列表。RecursiveCharacterTextSplitter按列表顺序依次尝试用这些分隔符切割——优先在段落边界切(\n\n)、其次换行(\n)、再其次句号(。)、然后逗号(,)……尽量保持语义完整性。如果用当前分隔符切完某段仍超过chunk_size,就递归地用下一个更细的分隔符继续切。
滑动窗口示意:
假设文本长度 200,chunk_size=60,chunk_overlap=12,则步长 = 60 − 12 = 48:
| Chunk | 起始位置 | 结束位置 | 覆盖范围 |
|---|---|---|---|
| 第 1 块 | 0 | 60 | 0 – 60 |
| 第 2 块 | 48 | 108 | 48 – 108(与第 1 块重叠 12 字符) |
| 第 3 块 | 96 | 156 | 96 – 156(与第 2 块重叠 12 字符) |
| 第 4 块 | 144 | 200 | 144 – 200(最后一块可能不足 60) |
边界附近的内容会在相邻两块中都出现一次,确保不丢失关键信息。
3.2 BM25 核心参数#
k1 = 1.5(默认值):控制词频饱和速度。k1 越大,TF 的增长越线性(高频词获得更高分数);k1 越小,TF 很快饱和(出现 1 次和出现 10 次差别不大)。b = 0.75(默认值):控制文档长度归一化强度。b=1 时完全按文档长度归一化(长文档被严重惩罚);b=0 时完全不考虑长度。0.75 是一个经验性的平衡值。
4. 索引构建过程(BM25Okapi 内部)#
当执行 bm25 = BM25Okapi(tokenized_chunks) 时,内部会扫描所有 chunks,统计以下信息:
| 属性 | 含义 | 数据结构 |
|---|---|---|
| bm25.idf | 全局逆文档频率字典:每个唯一 token 的 IDF 值 | dict[str, float] |
| bm25.doc_freqs | 每个文档的词频(TF)统计 | list[dict[str, int]] |
| bm25.doc_len | 每个文档的长度(token 数) | list[int] |
| bm25.avgdl | 所有文档的平均长度 | float |
5. IDF 详解:全局逆文档频率#
5.1 什么是 IDF?#
IDF(Inverse Document Frequency) 是 BM25 的核心组成部分,衡量一个词的"稀有程度"——越稀有的词,区分能力越强,IDF 越高。
5.2 IDF 计算公式#
BM25 Okapi 使用的 IDF 公式为:
其中:
- = 文档总数(本例中 N = 3)
- = 包含 token 的文档数
5.3 IDF 值示例#
本例中 bm25.idf 的部分内容:
{'苹果': 0.5108, '手机': 0.5108, 'AI': 0.5108, '续航': 0.0989, '影像': 0.0989, '。': 0.0989, ...}
| Token 类型 | 出现文档数 n | IDF 计算 | IDF 值 | 含义 |
|---|---|---|---|---|
| 专有词(苹果、手机、AI …) | 1 | ln((3−1+0.5)/(1+0.5)) ≈ 0.5108 | 0.5108 | 稀有 → 高权重,区分度强 |
| 常见词(续航、影像、。) | 2 | ln((3−2+0.5)/(2+0.5)) ≈ 0.0989 | 0.0989 | 较常见 → 低权重 |
| 全出现词(如停用词"的") | 3 | ln((3−3+0.5)/(3+0.5)) ≈ −1.946 | 负值 | 几乎无贡献(rank_bm25 会截断为 0) |
6. 稀疏向量表示#
6.1 文档稀疏向量#
bm25_doc_sparse_vector 返回的是 dict[str, float]——每个 token 映射到它在该 chunk 中的 BM25 贡献权重。
计算公式(每个 token 的权重):
其中:
- = token 在文档 中的出现次数
- = 文档 的长度(token 数)
- = 平均文档长度
- 分母中的 就是代码中的
denom_const,实现长度归一化
6.2 查询稀疏向量#
bm25_query_sparse_vector 的计算更简单:
查询通常很短,每个词出现一次,所以 通常为 1,权重就等于 IDF 值。查询侧没有长度归一化(查询长度固定,无需惩罚)。
6.3 稀疏向量的存储形式#
稀疏向量以 (index, value) 对的形式存储,即 (词汇 ID, 权重值)。只记录非零维度,这正是高效设计——假设词表大小 50000,一个 chunk 只有 20 个唯一词,则只存 20 个维度而非 50000 个,避免内存爆炸。

7. BM25 检索打分流程#
当执行 bm25.get_scores(tokenized_query) 时,内部流程如下:
7.1 查询预处理#
- 对 query 分词得到
tokenized_query - 计算查询的稀疏向量:每个 query token 的权重 ≈ IDF(因为 query tf 通常为 1)
7.2 遍历所有 chunks 实时打分#
对每个 chunk ,总分数的计算公式:
关键细节:
- 如果 chunk 中有该 query token → 计算 BM25 子分数
- 如果 chunk 中没有该 query token → 子分数 = 0(稀疏匹配的本质)
- 所有 query token 的子分数求和 → 该 chunk 的总分数
- 不是遍历所有 token 的 IDF(IDF 是全局共享的,查表即可),而是遍历所有 chunks 的 TF + 长度,结合全局 IDF 计算
7.3 排序返回#
scores:一个 numpy array,包含所有 chunks 的总分数get_top_n:根据分数降序,取 Top-N 个 chunks(返回原文)
8. 运行输出与分析#
8.1 分词结果#
[
['苹果', '手机', '最新', '功能', '包括', 'AI', '摄影', '和', '长', '续航', '。', '发布会', '上', '提到', '了', '端侧', 'AI', '与', '影像', '算法', '升级', '。'],
['香蕉', '是', '一种', '热带', '水果', ',', '富含', '钾', '元素', ',', '适合', '作为', '日常', '补充', '能量', '的', '食物', '。'],
['iPhone', '16', 'Pro', 'Max', '评测', ':', '屏幕', '更亮', ',', '影像', '更强', ',', '续航', '也', '有', '提升', '。']
]
注意:本示例中因为原文档较短(均 < 60 字符),
RecursiveCharacterTextSplitter实际上没有进一步切块,每篇文档保持为一个 chunk。在实际生产中,文档通常更长,会产生多个 chunks。
8.2 检索结果#
查询 "苹果手机最新功能" 的 Top 3 结果:
| 排名 | Chunk | 来源文档 | BM25 得分 | 匹配原因 |
|---|---|---|---|---|
| 🥇 1 | 苹果手机最新功能包括AI摄影和长续航… | doc[0] | 1.9078 | 完全命中"苹果""手机""最新""功能"四个高 IDF 词 |
| 🥈 2 | iPhone 16 Pro Max评测… | doc[2] | 0.0000 | 无直接词汇匹配("iPhone" ≠ "苹果"——BM25 无法理解同义词) |
| 🥉 3 | 香蕉是一种热带水果… | doc[1] | 0.0000 | 完全无关 |
8.3 查询稀疏向量#
Query: [(38, 0.5108, '苹果'), (21, 0.5108, '手机'), (29, 0.5108, '最新'), (13, 0.5108, '功能')]
四个词的 IDF 都是 0.5108(各自只在 1 个文档中出现),权重相等。
8.4 文档稀疏向量(Chunk[0] 示例)#
Chunk[0]: [(AI, 0.6945), (苹果, 0.4769), (手机, 0.4769), (最新, 0.4769), ...]
- "AI" 权重最高(0.6945):因为 "AI" 在此 chunk 中出现了 2 次(TF=2),IDF 也高,所以贡献最大
- 其他词出现 1 次,权重相近(约 0.477)
- "续航""影像" 等跨文档出现的词权重很低(约 0.092),因为 IDF 低
9. rank_bm25 vs 生产级方案#
| 维度 | rank_bm25(内存) | Milvus / Elasticsearch BM25 | ||
|---|---|---|---|---|
| 检索机制 | 遍历所有 chunks 实时计算 | 倒排索引:先过滤包含 query token 的 chunks → 再打分 | ||
| 时间复杂度 | O(N),线性扫描全部 chunks | O(k),k 为包含 query token 的 chunk 数(远小于 N) | ||
| 万级 chunks 速度 | 较慢(百毫秒级) | 快(毫秒级) | ||
| 增量更新 | 需重建整个索引 | 支持实时增量写入 | ||
| 适用场景 | 小规模测试 / 原型验证 | 生产环境(推荐) |
10. 传统 BM25 vs Learned Sparse(如 BGE-M3)#
| 维度 | 传统 BM25(本文内容) | Learned Sparse(BGE-M3、SPLADE 等) |
|---|---|---|
| 权重来源 | 统计规则(IDF + TF) | 神经网络端到端学习 |
| 语义理解 | ❌ 纯词汇匹配,无法处理同义词 | ✅ 能捕捉语义相似性(如"苹果"↔"iPhone") |
| 可解释性 | ✅ 强,每个词的贡献清晰可查 | ⚠️ 较弱,权重由模型学习得到 |
| 训练成本 | 无需训练 | 需要预训练模型 + 可能需要微调 |
| 增量更新 | 需重建索引 / 使用倒排索引 | 向量数据库实时 upsert |
| 推荐场景 | 关键词精确匹配、专有名词敏感场景 | 现代 Hybrid RAG(推荐,与稠密检索互补) |
11. 总结与最佳实践#
评论
还没有评论,来发第一个吧
