在企业级智能问答系统的落地过程中,有一个几乎绕不过去的坎:检索效果差。你可能遇到过这样的场景——用户问“我的订单为啥没发货”,关键词检索(BM25)只能匹配“订单”“发货”,把一篇讲“售后流程”的知识库文章排到后面;而向量检索(dense)明明召回了一堆语义相近的段落,却在“精确匹配”的合同编号、型号规格上全军覆没。把 BM25 和 dense 两个通道的结果用 RRF(Reciprocal Rank Fusion,倒数排名融合)融合起来,是目前工业界最务实、最可靠的混合检索方案之一。这篇文章是“从零到一搭建企业级智能问答系统”系列的第6篇,我会从设计思路、代码实现到调优踩坑,带你完整落地一个 mixed retrieval 模块,适合正在做 RAG、智能客服、知识库问答的工程师直接参考。
1. 为什么单一检索永远不够:BM25 和 dense 各自的“盲区”
1.1 先聊聊传统关键词检索:BM25 的强悍与局限
BM25 是信息检索领域的老牌算法,核心思路是对查询词和文档的词频、逆文档频率做加权打分。它的工程实现非常成熟,企业里大多数搜索引擎、数据库全文索引都跑在 BM25 或其变体上。我自己的经验里,BM25 最讨喜的地方是“确定性”:查询词在文档里出现了就是出现了,没出现就是没出现,每一分都有明确的词频依据,出现问题时很容易回溯排查,这在企业场景里非常重要。
但 BM25 有一个著名的短板,叫“词汇鸿沟”。用户表达和文档表达之间经常没有共享词:用户口语化地说“我想退钱”,知识库文档里却写着“退款申请流程”,两个句子在字面上几乎没有交集,BM25 很难把它们关联起来。我在客服问答项目里碰到过最典型的例子:用户问“东西坏了怎么处理”,库里有篇文章标题是“商品质量异常处置规范”,词面完全不沾边,纯 BM25 召回结果惨不忍睹。这不是算法调参能解决的问题,而是表达粒度不同带来的结构性缺陷。
1.2 dense 向量检索:语义召回能力与它的暗坑
dense 检索解决的就是“词汇鸿沟”。做法是把文本用神经网络编码成向量,让“坏了”和“质量异常”这种语义相近、字面不同的表达在向量空间里距离更近。现在的 RAG 系统基本默认用 dense 作为主召回通道,原因很简单:它能抓到同义词、同义句式,甚至跨语言表达,召回率明显高于关键词。
但 dense 不是万能的,我在生产环境里踩过几个坑。第一个是对精确实体匹配不友好:向量模型可以把“苹果手机”和“iPhone”关联起来,但没法保证“PO-2024-080912”这种订单号被完整、精确地对应上,向量化之后一个字符的错误可能导致匹配漂移。第二个是领域术语偏移:通用 embedding 模型在电商、医疗、法律这些行业上的表现会打折扣,除非你做过微调,否则“描述性匹配”会有失真。第三个问题更隐蔽——dense 的结果缺乏可解释性,线上出问题时很难说清楚某一条结果为什么排前面。
1.3 为什么混合检索方案选择了 RRF 而不是简单加权
既然两类检索各有优势,自然会想到合并。最直接的做法是给两个通道的分数做加权求和,比如final_score = 0.4 * bm25_score + 0.6 * dense_score。听起来合理,但落地就出问题:BM25 的分值分布和向量余弦相似度的分布完全不是一个量纲,BM25 可能打到 20 多分,dense 相似度在 0.8 左右,你根本无法设计一个稳定的权重。标准化处理可以缓解,却会引入额外的参数和偏差。
RRF 的核心思路更聪明:不比较分数,只比较名次。它把每个通道的结果按排名都给一个“排名分”,然后跨通道求和。这样做的好处是绕开了分数不可比的问题,你不需要为两个通道设计权重,也不需要标准化分值分布。它天然允许“这个查询关键词强、那个查询语义强”的灵活局面,因为每个查询对通道的依赖可以不同,但融合规则始终保持简单、稳定。这也是 RRF 在企业级系统里比加权融合更受欢迎的根本原因。
2. 混合检索整体设计:两个召回通道如何协同工作
2.1 先画一张系统逻辑图:并行召回、融合、精排
整个混合检索链路可以拆成四个阶段。查询进来之后,第一步是查询理解与预处理,包括分词、实体识别、意图归一化等;第二步是并行召回,同一个 query 分别发给 BM25 通道和 dense 通道,两个通道互不依赖、同时执行;第三步是RRF 融合,把两个列表合并去重,计算融合分并排序,输出候选集;第四步是精排,对候选集里的文本用更强的模型(比如交叉编码器 reranker)逐条打分,输出最终结果。
这里强调一下,召回阶段一定要并行,不要串行。串行意味着你要么先跑 BM25 再跑 dense,要么让 dense 决定在 BM25 结果之上做扩展,这会把一个通道的漏召回限制死。检索的第一目标是“高召回率”,所有可能相关的段落都应该被捞上来,相关性排序和过滤可以交给后面的融合与精排阶段去处理。
2.2 RRF 公式详解:为什么 k=60 是经验值
RRF 的公式长这样:对于每个候选文档 d,它在第 i 个召回列表中的排名为 rank_i,那么融合分就是:
score(d) = Σ_i 1 / (k + rank_i)其中 k 是一个平滑常数,用来控制排名的衰减速率。这个公式的含义很直观:一份文档在某个通道里排名越靠前,对融合分的贡献越大;但即使它在单个通道里排在很后面,只要在其他通道里表现好,整体分数也不会受致命影响。
为什么 k 通常取 60?这是搜广推社区里经过大量试验沉淀出来的经验值,在理论推导和实际效果的平衡中表现比较稳定。k 太小会让头部排名的权重过大,前几名几乎是最终结果的唯一决定者;k 太大则会让排名差异被摊平,融合结果接近简单平均。我用过 30、60、90,综合多次实验,60 在大多数场景下不需要调整就能获得稳健结果。后面我会专门讲怎么判断是否需要调整。
举一个手算的例子:文档 A 在 BM25 通道排第 1,在 dense 通道排第 30,k=60 时融合分 = 1/61 + 1/90 ≈ 0.0275。文档 B 在两个通道都排第 10,融合分 = 1/70 + 1/70 ≈ 0.0286。可以看到,B 虽然没有单通道第一,但在两个通道里都稳定靠前,反而超过了 A。这就是 RRF 最核心的特性:它在奖励“两个通道都认可的结果”,而非“单通道的一匹黑马”。这个特性让系统在大多数查询上表现得更加稳定,不轻易被某个有偏的通道带偏。
2.3 RRF 之后为什么还要接精排
很多人以为融合排序完了就能直接返回给用户,这是对“粗排”和“精排”的误解。召回阶段选的是“可能相关”,融合阶段排的是“大体靠谱”,真正决定用户体验的是最后一步精排。企业级问答系统通常会在 RRF 融合后接一个 reranker——一般是 cross-encoder 结构,把 query 和候选文档拼接在一起送入模型,输出一个相关性分数。
为什么需要精排?因为 RRF 融合用到的 rank 信息毕竟太粗糙,它不知道文档里哪一段和 query 的意图真正对上。召回和融合阶段的目标是缩小范围,把原来的十万级文档缩小到几十条候选,然后靠精排这一层去分辨这些候选中谁才是真正命中的。在实际项目中,RRF 加上 reranker 的组合,相比单独用某一个通道,线上 nDCG 指标通常能提升 10% 到 20%,这个收益非常可观。
3. 从零到一实现:完整的代码与参数详解
3.1 数据准备与预处理:先统一文本清洗和分词逻辑
进入代码之前,先强调一个最容易忽视的坑:两个通道看似独立,实际上都必须建立在同一个预处理标准之上。如果 BM25 通道用 jieba 分词、dense 通道用模型自带的 tokenizer,那两边处理出来的文本单元可能差别很大,导致同样一篇文档在两边理解出了不同的含义。
我习惯用一个公共函数统一做清洗:去掉 HTML 标签、统一全半角符号、过滤掉无意义字符,然后对 BM25 做中文分词。下面是一个标准的数据类定义:
from dataclasses import dataclass, field from typing import List, Optional import re @dataclass class Document: doc_id: str text: str metadata: dict = field(default_factory=dict) def clean_text(text: str) -> str: """统一文本清洗:去HTML、统一全半角、压缩空白""" text = re.sub(r"<[^>]+>", "", text) # 去 HTML 标签 text = text.replace(" ", " ").replace(",", ",").replace("。", ".").replace(";", ";").replace(":", ":") text = re.sub(r"\s+", " ", text).strip() return text这里有一个我自己很早踩过的坑:全半角符号不统一,会让 BM25 的分词结果产生大量不必要的噪音。比如“(订单)”和“(订单)”在倒排索引里会被当成不同的词元,用户搜“订单”只能命中其中一种。做了统一清洗之后,召回的稳定性明显提升。
3.2 BM25 通道实现:用 rank_bm25 构建倒排索引
BM25 我直接用rank_bm25库,它的BM25Okapi实现足够干净,适合作为教学和中小规模的检索方案。先把每篇文档切词,构建语料,然后建立索引。如果你在业务环境已经在用 Elasticsearch,其实可以直接把查询打到 ES 的全文检索接口上拿 BM25 得分,这里为了把原理讲透、方便复现,先用纯 Python 实现。
import jieba from rank_bm25 import BM25Okapi class BM25Retriever: def __init__(self, k1: float = 1.5, b: float = 0.75): self.k1 = k1 self.b = b self.corpus = [] self.doc_ids = [] self._bm25 = None def build(self, docs: List[Document]) -> None: """灌入全部文档,建立 BM25 索引""" self.doc_ids = [doc.doc_id for doc in docs] self.corpus = [self._tokenize(doc.text) for doc in docs] self._bm25 = BM25Okapi(self.corpus, k1=self.k1, b=self.b) @staticmethod def _tokenize(text: str) -> List[str]: """中文分词:精确模式并去掉单字/停用词(可按需扩展)""" return [w for w in jieba.lcut(text) if len(w.strip()) > 1] def retrieve(self, query: str, top_n: int = 50) -> List[tuple[str, float]]: query_tokens = self._tokenize(query) if not query_tokens: return [] scores = self._bm25.get_scores(query_tokens) ranked_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_n] return [(self.doc_ids[i], float(scores[i])) for i in ranked_idx]这里有两个细节值得说明。第一,k1=1.5, b=0.75是 BM25 的经典默认参数,rank_bm25底层默认就是这组值,一般情况下不需要动。第二,tokenize 里我刻意过滤了单字,这是从中文场景里用 VM 换来的经验:很多单字在检索时会产生大量噪声,比如“货”“单”这类字几乎在每篇文档里都有,留着它们只会稀释关键词的区分度。如果你的文档是英文,则不需要做单字过滤,直接用空格切分加小写化就够。
3.3 dense 通道实现:用 embedding + Faiss 做向量召回
dense 通道的两大组件:文本编码器和高性能向量索引。编码器我推荐sentence-transformers加载中文 embedding 模型,项目里跑过比较稳定的组合是BAAI/bge-large-zh-v1.5,官方在中文语义任务上做了优化,无需微调就能在大多数知识库场景下工作。向量索引用 Faiss,它是目前最成熟的近似最近邻搜索库。
from sentence_transformers import SentenceTransformer import faiss import numpy as np class DenseRetriever: def __init__(self, model_name: str = "BAAI/bge-large-zh-v1.5", device: str = "cpu"): self.model = SentenceTransformer(model_name, device=device) self.dim = self.model.get_sentence_embedding_dimension() self.index = None self.doc_ids = [] self._doc_id_to_pos = {} def build(self, docs: List[Document], batch_size: int = 32) -> None: """批量编码文档并创建 Faiss 索引""" self.doc_ids = [doc.doc_id for doc in docs] self._doc_id_to_pos = {doc_id: i for i, doc_id in enumerate(doc_ids)} embeddings = [] for i in range(0, len(docs), batch_size): batch = [doc.text for doc in docs[i:i+batch_size]] emb = self.model.encode(batch, normalize_embeddings=True) embeddings.append(emb) embeddings = np.vstack(embeddings).astype("float32") # 构建 IVF 索引:这里用 Flat 保证召回质量,后续可切 IVF self.index = faiss.IndexFlatIP(self.dim) self.index.add(embeddings) def retrieve(self, query: str, top_n: int = 50) -> List[tuple[str, float]]: query_emb = self.model.encode([query], normalize_embeddings=True).astype("float32") scores, idxs = self.index.search(query_emb, top_n) return [(self.doc_ids[int(i)], float(s)) for i, s in zip(idxs[0], scores[0])]你可能注意到我在编码时加了normalize_embeddings=True,并用内积(IndexFlatIP)做相似度计算。这是关键细节:bge 系列的相似度计算建议用余弦相似度,而把向量归一化之后,内积和余弦等价,但内积在 Faiss 里性能更好。
建索引时先用IndexFlatIP是为了保证检索质量,它本质上就是暴力扫描,几十万量级以内速度完全够用。如果文档量超过百万,再考虑换IndexIVFFlat,但换来的是近似结果,需要额外做 recall 评测。中小型企业知识库在百万级以下,Flat 索引在 CPU 上也只花几十毫秒,没必要为了炫技引入近似索引。
3.4 RRF 融合实现:合并去重 + 名次打分
两个通道的结果都是(doc_id, score)的列表,其中 score 一个是 BM25 原始分,一个是向量余弦相似度,完全不可比。我们的目标是忽略分数,只取它们在各自列表中的排名位置。RRF 融合实现很简单,但要处理几个边界情况:doc_id 在其中一个通道没出现、两个通道结果有重叠、空查询。
def rrf_fusion(bm25_results: List[tuple[tuple[str, float]]], # 或简化类型 dense_results: List[tuple[tuple[str, float]]], k: int = 60, channel_weight: tuple[float, float] = (1.0, 1.0)) -> List[tuple[str, float]]: """融合两个通道结果,按 RRF 分排序返回。输入: [(doc_id, score)]""" fused_score = {} # 构建 rank 表 for rank, (doc_id, _) in enumerate(bm25_results): fused_score.setdefault(doc_id, 0.0) fused_score[doc_id] += channel_weight[0] / (k + rank + 1) # rank 从0起 for rank, (doc_id, _) in enumerate(dense_results): fused_score.setdefault(doc_id, 0.0) fused_score[doc_id] += channel_weight[1] / (k + rank + 1) # 按融合分降序 ranked = sorted(fused_score.items(), key=lambda x: x[1], reverse=True) return ranked这里注意一个细节,我用的是rank + 1,也就是把列表中的位置从 0 换成论文里的 1-based ranking。公式里的 rank 通常以 1 为起点,第一名是 rank=1,如果代码里 enumerate 默认从 0 开始,懒一点不处理也没问题,因为所有文档都偏移一位,相对顺序不变,但在和其他系统对接、做日志分析时,保持一致更好。
融合完之后,返回的列表里每个元素是(doc_id, rrf_score),这个 rrf_score 已经没有业务含义,它的唯一作用就是排序。我习惯把原始通道的位置信息一起保存在日志里,方便调试时定位某一条结果是从哪边捞出来的。
3.5 串联整个链路:从 query 到结果
把三块串起来只需几行代码。这里我还加了一个可选的精排开关,用一个简单的关键词命中兜底逻辑作演示。真实生产环境里这一步替换成模型 reranker 即可。
def build_hybrid_retriever(docs: List[Document]): bm25 = BM25Retriever() bm25.build(docs) dense = DenseRetriever(device="cuda:0") dense.build(docs) return bm25, dense def hybrid_retrieve(query: str, bm25: BM25Retriever, dense: DenseRetriever, bm25_top_n: int = 50, dense_top_n: int = 50, rrf_k: int = 60) -> List[tuple[str, float]]: bm25_results = bm25.retrieve(query, top_n=bm25_top_n) dense_results = dense.retrieve(query, top_n=dense_top_n) return rrf_fusion(bm25_results, dense_results, k=rrf_k) # 示例 docs = [ Document(doc_id="doc1", text="商品在签收后七天内可以申请无理由退货,请确保吊牌完整。"), Document(doc_id="doc2", text="退款申请流程:进入订单详情页,点击申请售后,上传凭证。"), Document(doc_id="doc3", text="售后赔付政策仅适用于质量问题,人为损坏不在保修范围内。"), ] bm25, dense = build_hybrid_retriever(docs) for doc_id, score in hybrid_retrieve("七天无理由怎么退钱", bm25, dense)[:3]: print(doc_id, score)运行之后,融合列表的第一位大概率是 doc1 或 doc2,而不是 doc3,因为前两篇和查询在关键词和语义上都有更强的关联。这个例子故意选得短,但已经能跑通链路。真实系统里,你只需要把docs替换成从数据库/ES 加载的文档列表就行。
4. 调优、踩坑与 FAQ:从能用到好用
4.1 RRF 的 k 值和两个通道的召回数量怎么配
先回答一个高频问题:k 值到底设多少合适?我的建议是默认从 60 开始,然后用一批线上真实 query 做离线评测。具体做法是准备 200 条有标注的查询,跑一遍混合检索,算每个 k 值下的 Recall@10 和 MRR。在大多数企业知识库场景里,k=60 和 k=30 的指标差距很小,但如果你的查询大多为短查询、实体型查询,可以试着把 k 降低到 30 附近,让头部排名冲刺力更强;如果查询多为长句口语化表达,适度提高到 70-80 反而更稳。
另一个更关键但经常被忽略的比例是两个通道各自的召回数。RRF 融合只能对“已经在列表里”的文档做排名,如果 BM25 只召回 20 条、dense 召回 200 条,那融合结果天然偏向 dense。我在项目里对两者的召回数设定是:先都设成 50,观察融合结果中来自两个通道的比例,再按需调整。如果发现 BM25 通道的文档占比低于 30%,说明 bm25_top_n 设低了或 BM25 质量确实差,需要排查。
4.2 权重偏置:领域知识怎么注入混合检索
RRF 里可以加权重,公式变成score(d) = Σ_i w_i / (k + rank_i)。但我强烈建议:在做任何权重调整之前,先把两路召回的差异可视化出来,看清楚了再加。加权重等于告诉系统“哪个通道更可信”,但这个判断必须是数据驱动的。
我自己的经验框架是这样:先各跑一周日志,把两个通道的独立召回结果分别记录,人工标注 Top 20 结果的好坏。如果 dense 通道的结果明显比 BM25 好,比如标注好结果率相差 15 个百分点,再用 0.6 对 1.0 的权重去偏。反向也一样。直接拍脑袋给通道设权重,往往会把另一个通道的独特价值压制掉,得不偿失。
4.3 常见问题排查实录
以下是混合检索落地中我遇到过的最高频问题,整理成速查表。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 融合结果基本等于 BM25 单通道 | dense 召回数为0或过低 | 检查 dense 索引是否构建成功,模型中 encode 是否报错 |
| 融合结果全是 dense,BM25 被淹没 | bm25_top_n 太小或 BM25 语料没分词 | 调大 bm25 召回数,检查 jieba 分词结果 |
| 文档 ID 类型不一致导致融合为空 | BM25 返回 str,dense 返回 int,dict 匹配不上 | 统一 doc_id 类型,全部转成 str |
| RRF 分数差距过小,排序近似随机 | 两个通道列表长度差异大,大量文档只出现在一个通道 | 限制 top_n 一致,或去掉单通道出现的尾部文档 |
| 线上问答答案质量差,但检索指标正常 | 召回相关但精排环节太弱 | 在融合后接入 reranker,检查候选集里是否正确段落存在 |
| 中英文混排文档,BM25 召回噪声大 | 分词对英文处理不足 | 在 tokenize 里增加英文小写化和词干化 |
这份表格来源于我在真实客服项目里反复遇到的现象,尤其是“融合结果完全被单一通道接管”这个问题,最容易出现在初次搭建混合检索的系统中。原因是两个通道的召回列表长度不一致、doc_id 类型不统一,导致融合阶段大量有效记录被静默丢弃。
4.4 线上调优的关键:留好日志和数据评测
最后分享一套我每次都会做的线上监控方案。日志里至少给每条检索记录打三个标记:query、top 结果排序、每个 top 结果分别来自哪个通道以及它在原通道中的排名。这样出现 badcase 时,我能立刻判断问题出在哪个环节——是 BM25 没召回、dense 没召回,还是 RRF 融合排序不合理。
我见过太多团队上线了一套混合检索,却完全不记录中间结果,线上出问题时只能靠猜。检索链路越长,中间状态越重要。RRF 是一个轻量高效的融合策略,但前提是你得让每个通道的独立结果都可见、可回溯。离线评测建议至少覆盖三个指标:Recall@10 代表召回能力,MRR 代表排序质量,nDCG@10 代表综合体验。没有这些指标,所谓调优就只能是感觉调优。
根据我个人经验,这个内容后续还可以这样扩展:把 BM25 换成一整套倒排索引服务、把 dense 通道调整成多向量检索,甚至引入知识图谱的实体召回作为第三个通道。混合检索框架的最大价值在于,它给你留下一块灵活的拼图空间——RRF 融合本身不会成为瓶颈,真正决定系统上限的,是你愿意为每个通道投入多少工程深度。别急着追求花哨的模型,先把两路召回的质量做扎实,融合结果自然水到渠成。