
在企业级智能问答系统的落地过程中有一个几乎绕不过去的坎检索效果差。你可能遇到过这样的场景——用户问“我的订单为啥没发货”关键词检索BM25只能匹配“订单”“发货”把一篇讲“售后流程”的知识库文章排到后面而向量检索dense明明召回了一堆语义相近的段落却在“精确匹配”的合同编号、型号规格上全军覆没。把 BM25 和 dense 两个通道的结果用 RRFReciprocal 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 公式详解为什么 k60 是经验值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 通道排第 30k60 时融合分 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_factorydict) 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, k1self.k1, bself.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)), keylambda i: scores[i], reverseTrue)[:top_n] return [(self.doc_ids[i], float(scores[i])) for i in ranked_idx]这里有两个细节值得说明。第一k11.5, b0.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, devicedevice) 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:ibatch_size]] emb self.model.encode(batch, normalize_embeddingsTrue) 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_embeddingsTrue).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_embeddingsTrue并用内积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(), keylambda x: x[1], reverseTrue) return ranked这里注意一个细节我用的是rank 1也就是把列表中的位置从 0 换成论文里的 1-based ranking。公式里的 rank 通常以 1 为起点第一名是 rank1如果代码里 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(devicecuda: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_nbm25_top_n) dense_results dense.retrieve(query, top_ndense_top_n) return rrf_fusion(bm25_results, dense_results, krrf_k) # 示例 docs [ Document(doc_iddoc1, text商品在签收后七天内可以申请无理由退货请确保吊牌完整。), Document(doc_iddoc2, text退款申请流程进入订单详情页点击申请售后上传凭证。), Document(doc_iddoc3, 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 值下的 Recall10 和 MRR。在大多数企业知识库场景里k60 和 k30 的指标差距很小但如果你的查询大多为短查询、实体型查询可以试着把 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 是否报错融合结果全是 denseBM25 被淹没bm25_top_n 太小或 BM25 语料没分词调大 bm25 召回数检查 jieba 分词结果文档 ID 类型不一致导致融合为空BM25 返回 strdense 返回 intdict 匹配不上统一 doc_id 类型全部转成 strRRF 分数差距过小排序近似随机两个通道列表长度差异大大量文档只出现在一个通道限制 top_n 一致或去掉单通道出现的尾部文档线上问答答案质量差但检索指标正常召回相关但精排环节太弱在融合后接入 reranker检查候选集里是否正确段落存在中英文混排文档BM25 召回噪声大分词对英文处理不足在 tokenize 里增加英文小写化和词干化这份表格来源于我在真实客服项目里反复遇到的现象尤其是“融合结果完全被单一通道接管”这个问题最容易出现在初次搭建混合检索的系统中。原因是两个通道的召回列表长度不一致、doc_id 类型不统一导致融合阶段大量有效记录被静默丢弃。4.4 线上调优的关键留好日志和数据评测最后分享一套我每次都会做的线上监控方案。日志里至少给每条检索记录打三个标记query、top 结果排序、每个 top 结果分别来自哪个通道以及它在原通道中的排名。这样出现 badcase 时我能立刻判断问题出在哪个环节——是 BM25 没召回、dense 没召回还是 RRF 融合排序不合理。我见过太多团队上线了一套混合检索却完全不记录中间结果线上出问题时只能靠猜。检索链路越长中间状态越重要。RRF 是一个轻量高效的融合策略但前提是你得让每个通道的独立结果都可见、可回溯。离线评测建议至少覆盖三个指标Recall10 代表召回能力MRR 代表排序质量nDCG10 代表综合体验。没有这些指标所谓调优就只能是感觉调优。根据我个人经验这个内容后续还可以这样扩展把 BM25 换成一整套倒排索引服务、把 dense 通道调整成多向量检索甚至引入知识图谱的实体召回作为第三个通道。混合检索框架的最大价值在于它给你留下一块灵活的拼图空间——RRF 融合本身不会成为瓶颈真正决定系统上限的是你愿意为每个通道投入多少工程深度。别急着追求花哨的模型先把两路召回的质量做扎实融合结果自然水到渠成。