BM25 混合检索详解:为什么向量检索不够,还要加一个关键词检索

本文用一个国标 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,不需要预训练模型,只需要:

  1. 把文档分词(tokenize)
  2. 统计每个词在每篇文档里的出现次数
  3. 查询时算 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 融合 → 最终排名
要不要用 推荐用。对于表格密集型国标文档,混合检索召回率显著高于纯向量
相关推荐
悟乙己2 小时前
python DoWhy 库使用案例: SaaS 公司的客服案例
开发语言·python
虾..2 小时前
大模型认识
人工智能·llm·rag
在学了加油2 小时前
Inception v1学习笔记
笔记·python·学习
Cthy_hy2 小时前
Python算法竞赛:集合去重+字典映射 核心用法一站式整理
数据结构·python·算法
索西引擎2 小时前
【langchain 1.0】ChromaDB 原生 API 实战:为 LangChain 向量库打造管理工具集
python·ai·langchain
Sirius.z2 小时前
第J6周:Inception v1算法实战
python
山上三树2 小时前
Python 高频报错速查表(开发通用版)
开发语言·python
Wonderful U2 小时前
AI智能日志异常检测告警平台:告别人工排查,秒级定位线上故障
数据库·人工智能·python·django
MY_TEUCK2 小时前
【MYTRUCK - AI 应用】MetaGPT 0.8.2 安装与排错完整实录(Python 3.10 + 虚拟环境)
开发语言·人工智能·python·ai