本文用一个国标 RAG 的真实查询场景,说明 BM25 解决了什么问题、什么时候必须用它、以及怎么和向量检索做混合。
一、先给一个结论
向量检索擅长"意思相近",BM25 擅长"关键词命中"。国标 RAG 里两者缺一不可。
| 能力 | 向量检索 | BM25 |
|---|---|---|
| "A-WALL" 和 "墙体图层" 语义关联 | 擅长 | 不擅长 |
| 精确匹配 "A-WALL" 这个关键词 | 不擅长 | 擅长 |
| 查 "表A.1" 里的具体某一行 | 容易漏 | 精准命中 |
| 查标准号 "GB/T 18229-2000" | metadata filter 可以 | 全文匹配可以 |
二、场景引入:用户问了一个简单问题
用户输入:
"A-WALL 是哪个专业的图层"
我们的知识库里只有 GB/T 18229-2000,其中附录 A 表 A.1 有这样一行:
A-WALL | 墙体 | 建筑
理想情况下,Top1 应该返回这行。
但纯向量检索和纯 BM25 各自都做不到最好。
三、BM25 是什么(只说人话,不写公式推导)
BM25 是一种基于词频(TF)和逆文档频率(IDF)的文本排序算法。它回答的问题是:
"给定一个查询词,文档库里的哪几篇文档包含这个词的次数最多、且这个词在整体文档中越罕见?"
和向量检索的本质区别
| 维度 | 向量检索 | BM25 |
|---|---|---|
| 工作原理 | 把文本转成高维向量,算余弦相似度 | 统计关键词出现次数和分布 |
| "A-WALL" 和 "墙体" 的关系 | 认为意思相近(embedding 距离近) | 认为是两个完全不同的词 |
| "A-WALL" 在文档里出现了几次 | 不关心 | 非常关心,出现越多分越高 |
| 对同义词的理解 | 天然支持 | 不支持,"墙体"≠"wall" |
为什么叫 "rank-bm25"
Python 库 rank-bm25 是对 BM25 算法的一个轻量实现。它不需要 GPU,不需要预训练模型,只需要:
- 把文档分词(tokenize)
- 统计每个词在每篇文档里的出现次数
- 查询时算 BM25 分数,返回排名
3.1 底层存储:倒排索引长什么样
BM25 不靠神经网络,靠一张倒排索引表。这张表回答的问题是:
"这个词在哪些文档里出现过?出现了几次?"
国标场景的倒排索引实例
假设我们已经把 GB/T 18229-2000 切成 5 个 chunk:
| 文档 | 内容 | 词数 |
|---|---|---|
| doc1 | A-WALL 墙体 建筑 | 3 |
| doc2 | 附录A 图层 代码 说明 A-WALL | 5 |
| doc3 | A-DOOR 门 图层 | 3 |
| doc4 | 表A.1 代码 总览 | 3 |
| doc5 | 第4章 图线 宽度 设置 | 4 |
分词后,BM25 构建的倒排索引长这样:
词项(term) 倒排列表(postings list)
─────────────────────────────────────────────────
A-WALL → [doc1: tf=1, doc2: tf=1]
墙体 → [doc1: tf=1]
建筑 → [doc1: tf=1]
图层 → [doc2: tf=1, doc3: tf=1]
附录A → [doc2: tf=1]
代码 → [doc2: tf=1, doc4: tf=1]
说明 → [doc2: tf=1]
A-DOOR → [doc3: tf=1]
门 → [doc3: tf=1]
表A.1 → [doc4: tf=1]
总览 → [doc4: tf=1]
第4章 → [doc5: tf=1]
图线 → [doc5: tf=1]
宽度 → [doc5: tf=1]
设置 → [doc5: tf=1]
─────────────────────────────────────────────────
除了倒排索引,还存了什么
BM25Okapi 初始化时,还会计算并缓存:
| 数据 | 说明 | 本例中的值 |
|---|---|---|
N |
总文档数 | 5 |
avgdl |
平均文档长度(词数) | (3+5+3+3+4)/5 = 3.6 |
doc_len[i] |
第 i 个文档的长度 | 3, 5, 3, 3, 4 |
k1 |
TF 饱和度参数 | 1.5(默认) |
b |
长度归一化参数 | 0.75(默认) |
这些值只算一次,查询时直接查表,不需要重新遍历文档。
倒排索引的内存占用
| 存储内容 | 量级 | 国标场景 |
|---|---|---|
| 词典(所有不同词) | 几千~几万条 | GB/T 18229-2000 约 500 个不同词 |
| 倒排列表(词→文档映射) | 稀疏矩阵 | 几百条文档 × 几百个词 |
| 文档长度数组 | O(N) | 5 个整数 |
| 总计 | < 1 MB |
3.2 排序计算:TF、IDF 与 BM25 公式实例
公式拆解(只说这一遍)
BM25 对一个查询词 q 和一个文档 D 的打分公式:
score(q, D) = IDF(q) × [ TF(q, D) × (k1 + 1) ] / [ TF(q, D) + k1 × (1 - b + b × |D| / avgdl) ]
拆成三部分理解:
| 部分 | 名称 | 作用 |
|---|---|---|
IDF(q) |
逆文档频率 | 这个词在所有文档中是否罕见?越罕见越值钱 |
TF(q, D) |
词频 | 这个词在本文档里出现了几次? |
| `(1 - b + b × | D | / avgdl)` |
IDF 计算:"A-WALL" 值多少钱
公式:
IDF(q) = ln( (N - n(q) + 0.5) / (n(q) + 0.5) )
-
N= 5(总文档数) -
n(A-WALL)= 2(doc1 和 doc2 包含 A-WALL)IDF(A-WALL) = ln( (5 - 2 + 0.5) / (2 + 0.5) )
= ln( 3.5 / 2.5 )
= ln( 1.4 )
≈ 0.336
解读:A-WALL 只出现在 2/5 的文档中,不算太罕见,IDF 为正值 0.336。如果这个词在所有文档中都出现(比如"的"),IDF 会变成负数,BM25 会认为它对区分文档没有帮助。
TF + 长度归一化:doc1 和 doc2 谁该排前面
两个文档都包含 1 次 "A-WALL",但 doc1 更短(3 词 vs 5 词)。
doc1(|D| = 3):
TF 分量 = 1 × (1.5 + 1) / ( 1 + 1.5 × (1 - 0.75 + 0.75 × 3 / 3.6) )
= 2.5 / ( 1 + 1.5 × (0.25 + 0.625) )
= 2.5 / ( 1 + 1.5 × 0.875 )
= 2.5 / ( 1 + 1.313 )
= 2.5 / 2.313
≈ 1.081
BM25(doc1, "A-WALL") = 0.336 × 1.081 ≈ 0.363
doc2(|D| = 5):
TF 分量 = 1 × 2.5 / ( 1 + 1.5 × (0.25 + 0.75 × 5 / 3.6) )
= 2.5 / ( 1 + 1.5 × (0.25 + 1.042) )
= 2.5 / ( 1 + 1.5 × 1.292 )
= 2.5 / ( 1 + 1.938 )
= 2.5 / 2.938
≈ 0.851
BM25(doc2, "A-WALL") = 0.336 × 0.851 ≈ 0.286
结果:doc1(0.363)> doc2(0.286)。
为什么? 两个文档都只有 1 次 A-WALL,但 doc1 总共只有 3 个词,A-WALL 占的"比重"更大。doc2 有 5 个词,A-WALL 被稀释了。长度归一化把这个差异量化成了分数差。
多词查询:"A-WALL 图层"
用户问 "A-WALL 图层" 时,BM25 分别计算每个词的分数,然后相加。
先算 "图层" 的 IDF:
-
n(图层)= 2(doc2、doc3 包含"图层")IDF(图层) = ln( (5 - 2 + 0.5) / (2 + 0.5) )
= ln( 3.5 / 2.5 )
≈ 0.336
doc2(同时命中两个词):
BM25(doc2, "A-WALL") = 0.286 (前面算过)
BM25(doc2, "图层") = 0.336 × 0.851 = 0.286 (TF 和 A-WALL 相同,都是 tf=1)
BM25(doc2, 总) = 0.286 + 0.286 = 0.572
doc1(只命中 A-WALL):
BM25(doc1, "A-WALL") = 0.363
BM25(doc1, "图层") = 0 (doc1 里没有"图层")
BM25(doc1, 总) = 0.363 + 0 = 0.363
doc3(只命中 图层):
BM25(doc3, "图层") = 0.336 × 1.081 = 0.363 (doc3 长度=3,TF 和 doc1 一样)
BM25(doc3, 总) = 0 + 0.363 = 0.363
最终排名:
| 文档 | 命中词 | BM25 总分 | 排名 |
|---|---|---|---|
| doc2 | A-WALL + 图层 | 0.572 | #1 |
| doc1 | A-WALL | 0.363 | #2 |
| doc3 | 图层 | 0.363 | #2(并列) |
| doc4 | 无 | 0 | --- |
| doc5 | 无 | 0 | --- |
结果解读:
- doc2 虽然文档最长(5 词),但因为同时命中两个查询词,总分最高
- doc1 和 doc3 都只命中一个词,且文档长度相同(3 词),所以分数一样
- 如果 doc1 也包含"图层",它会因为文档更短而超过 doc2
从公式看 BM25 的三个设计意图
| 设计 | 公式体现 | 效果 |
|---|---|---|
| TF 饱和 | TF × (k1+1) / (TF + k1 × ...) |
TF 从 1→2 提升很大,但 10→11 提升很小,防止长文档靠堆词取胜 |
| 长度归一化 | `b × | D |
| 罕见词加权 | IDF = ln((N-n+0.5)/(n+0.5)) |
只在少数文档中出现的词,分数乘数更大 |
四、向量检索在国标 RAG 里的三个盲区
盲区 1:精确关键词匹配不稳定
向量检索把 "A-WALL" 和 "墙体" 的 embedding 算得很近,所以查 "A-WALL" 时,可能先返回:
"附录A 图层代码说明" ← 语义相关,但没有 "A-WALL" 这个词
"A-DOOR 门图层含义" ← 语义: 门 ≈ 墙体? 偏了
"表A.1 图层代码总览" ← 有表A.1,但没定位到 A-WALL 行
真正含 "A-WALL | 墙体 | 建筑" 的那一行,因为文本太短(只有几个字),embedding 的语义信号反而被稀释,排到了第 3 名之后。
盲区 2:编号类查询几乎不可用
用户问:
"4.2.1 条规定了什么"
向量检索会把 "4.2.1" 当做一个 token 编码,但 embedding 模型对数字编号的区分能力很弱。"4.2.1" 和 "4.2.2" 的向量可能非常接近,导致检索结果混乱。
BM25 则把 "4.2.1" 当做一个精确词项,只有包含 "4.2.1" 的文档才能得高分。
盲区 3:ChromaDB 不支持全文搜索
ChromaDB 的 where 条件只能查 metadata:
python
# 可以:查 metadata
where={"table_no": "表 A.1"}
# 不可以:查 document 内容里的关键词
where={"document": {"$contains": "A-WALL"}} # 报错!
如果你想在 chunk 的文本内容里搜索关键词,ChromaDB 做不到。BM25 填补了这个缺口。
五、BM25 在国标 RAG 中的具体作用
作用 1:表格行的精确召回
国标里有大量表格(表 A.1、表 B.1...),每行是一个独立的 chunk。用户问 "A-WALL 的含义" 时:
- 向量检索:可能召回整节内容或表头说明
- BM25:直接命中 "A-WALL" 出现的那一行 chunk
作用 2:条文编号的快速定位
用户问 "第 4 章第 2 节第 1 条":
- 向量检索:"4.2.1" 的 embedding 和 "4.2.2" 可能太像
- BM25:只返回包含 "4.2.1" 的 chunk
作用 3:和向量检索互补,降低漏召率
假设向量检索召回 5 个 chunk,BM25 也召回 5 个 chunk。两者的交集可能只有 2 个,并集有 8 个。混合检索把并集做融合排序,漏召率大幅降低。
六、混合检索:RRF 融合策略
6.1 为什么需要融合
向量检索和 BM25 各自返回一个 TopK 列表,两份列表的排名可能完全不同。我们需要一个方法把两份列表合并成一份。
6.2 RRF(Reciprocal Rank Fusion)
RRF 是最简单有效的融合方法,公式:
score = Σ 1 / (k + rank_i)
rank_i:某个 chunk 在第 i 个检索系统中的排名k:常数,通常取 60- 一个 chunk 在多个系统中排名越靠前,融合分越高
6.3 国标场景的真实计算
查询:"A-WALL 是哪个专业的图层"
| chunk | 向量排名 | BM25排名 | RRF 计算 | 融合排名 |
|---|---|---|---|---|
| "A-WALL | 墙体 | 建筑" | #3 | #1 | 1/63 + 1/61 = 0.0323 | #1 |
| "A-WALL-LINE 墙体轮廓线" | 未进Top3 | #2 | 0 + 1/62 = 0.0161 | #2 |
| "附录A 图层代码说明" | #1 | 未进Top3 | 1/61 + 0 = 0.0164 | #3 |
| "A-DOOR 门图层含义" | #2 | 未进Top3 | 1/62 + 0 = 0.0161 | #4 |
结果解读:
- 纯向量检索的 Top1("附录A 图层代码说明")没有 "A-WALL" 关键词,融合后降到了 #3
- "A-WALL | 墙体 | 建筑" 在 BM25 中排 #1,在向量中排 #3,融合后升到了 #1
- 混合检索的结果比单一检索更可靠
七、代码实现
7.1 环境准备
bash
pip install rank-bm25 chromadb sentence-transformers
7.2 BM25 索引构建
python
from rank_bm25 import BM25Okapi
import json
# 加载所有 chunk(从 SQLite 或 JSON 备份中读取)
with open("chunks.json", "r", encoding="utf-8") as f:
chunks = json.load(f)
# 每个 chunk 对应一个文档
documents = [c["content"] for c in chunks]
chunk_ids = [c["chunk_id"] for c in chunks]
# 中文分词(jieba)
import jieba
tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
# 构建 BM25 索引
bm25 = BM25Okapi(tokenized_docs)
7.3 混合检索完整流程
python
import chromadb
from sentence_transformers import SentenceTransformer
from rank_bm25 import BM25Okapi
import jieba
import numpy as np
class HybridRetriever:
def __init__(self, chroma_path: str, chunks: list, k: int = 60):
self.client = chromadb.PersistentClient(path=chroma_path)
self.collection = self.client.get_collection("standard_chunks")
self.embedding_model = SentenceTransformer("BAAI/bge-large-zh")
# BM25 索引
self.chunks = chunks
self.chunk_id_to_idx = {c["chunk_id"]: i for i, c in enumerate(chunks)}
self.tokenized_docs = [list(jieba.cut(c["content"])) for c in chunks]
self.bm25 = BM25Okapi(self.tokenized_docs)
self.k = k # RRF 常数
def vector_search(self, query: str, top_k: int = 10):
"""向量检索:返回 {chunk_id: rank}"""
embedding = self.embedding_model.encode(query).tolist()
results = self.collection.query(
query_embeddings=[embedding],
n_results=top_k
)
return {
cid: rank + 1
for rank, cid in enumerate(results["ids"][0])
}
def bm25_search(self, query: str, top_k: int = 10):
"""BM25 检索:返回 {chunk_id: rank}"""
tokenized_query = list(jieba.cut(query))
scores = self.bm25.get_scores(tokenized_query)
# 取 top_k
top_indices = np.argsort(scores)[::-1][:top_k]
return {
self.chunks[idx]["chunk_id"]: rank + 1
for rank, idx in enumerate(top_indices)
}
def rrf_fusion(self, vector_ranks: dict, bm25_ranks: dict, top_k: int = 5):
"""RRF 融合:返回排序后的 chunk_id 列表"""
all_ids = set(vector_ranks.keys()) | set(bm25_ranks.keys())
scores = {}
for cid in all_ids:
score = 0.0
if cid in vector_ranks:
score += 1.0 / (self.k + vector_ranks[cid])
if cid in bm25_ranks:
score += 1.0 / (self.k + bm25_ranks[cid])
scores[cid] = score
# 按融合分降序
sorted_ids = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [cid for cid, _ in sorted_ids[:top_k]]
def retrieve(self, query: str, top_k: int = 5):
"""对外接口:混合检索"""
vec_ranks = self.vector_search(query, top_k=top_k * 2)
bm25_ranks = self.bm25_search(query, top_k=top_k * 2)
final_ids = self.rrf_fusion(vec_ranks, bm25_ranks, top_k=top_k)
# 从 ChromaDB 取回完整内容
results = self.collection.get(ids=final_ids)
return [
{
"chunk_id": cid,
"content": doc,
"metadata": meta
}
for cid, doc, meta in zip(
results["ids"], results["documents"], results["metadatas"]
)
]
# 使用
retriever = HybridRetriever("./chroma_db", chunks)
results = retriever.retrieve("A-WALL 是哪个专业的图层", top_k=3)
for r in results:
print(f"[{r['chunk_id']}] {r['content']}")
7.4 关键代码说明
| 方法 | 作用 |
|---|---|
vector_search |
调用 ChromaDB,返回 chunk_id → 排名的映射 |
bm25_search |
调用 BM25Okapi,返回 chunk_id → 排名的映射 |
rrf_fusion |
两个排名列表融合,按 RRF 公式算综合分 |
retrieve |
对外统一接口,返回最终排序的 chunk 列表 |
八、什么时候该用 / 不该用 BM25
必须用 BM25 的场景
| 查询类型 | 例子 | 原因 |
|---|---|---|
| 精确代码查询 | "A-WALL"、"A-DOOR" | 向量对短代码不敏感 |
| 条文编号查询 | "4.2.1"、"表A.1" | 向量对数字编号区分弱 |
| 表格行定位 | "第3行是什么" | 需要精确命中某一行 |
| 专业缩写查询 | "HVAC"、"Riser" | 缩写语义 embedding 弱 |
可以不加 BM25 的场景
| 查询类型 | 例子 | 原因 |
|---|---|---|
| 开放式语义查询 | "怎么区分墙体和柱子" | 向量检索更擅长 |
| 同义词查询 | "wall 图层" ≈ "墙体图层" | BM25 不认识同义词 |
| 长段落理解 | "描述一下图层命名规则" | 需要语义理解,非关键词匹配 |
九、常见误区
误区 1:"BM25 比向量检索更好,可以替代它"
错。 BM25 只认识一模一样的词,不认识同义词。用户问 "wall 图层" 时,BM25 找不到 "墙体",但向量检索可以。
误区 2:"BM25 需要分词,中文分词很麻烦"
对,但不难。 用 jieba 一句话搞定:list(jieba.cut(text))。对于国标文档这种规范文本,分词准确率足够高。
误区 3:"混合检索会把结果搞乱,不如只用一种"
错。 RRF 融合的本质是"多一票优势"。一个 chunk 同时在两个系统里排名靠前,融合后一定更靠前;只在某一个系统里靠前的,融合后排名会下降但不会消失。这不是"搞乱",而是"去噪"。
误区 4:"BM25 索引很大,内存放不下"
国标场景不用担心。 GB/T 18229-2000 全文约 3 万字,切完 chunk 也就几百条。BM25 索引内存占用在 MB 级别。
十、总结
| 问题 | 答案 |
|---|---|
| BM25 是什么 | 基于词频和逆文档频率的关键词排序算法 |
| 解决什么问题 | 向量检索在精确关键词匹配上的盲区 |
| 国标场景的典型盲区 | 代码精确匹配、条文编号、表格行定位 |
| 怎么用 | 向量 TopK + BM25 TopK → RRF 融合 → 最终排名 |
| 要不要用 | 推荐用。对于表格密集型国标文档,混合检索召回率显著高于纯向量 |