RAG检索不准?混合检索+Rerank是知识库问答落地的关键
1. 先体检一下那些“分数明明很高结果完全不对”的召回先讲一个我自己做企业知识库问答时遇到的典型场景。员工问“报销单提交之后多久能到账”知识库里有这么一段话“差旅费用报销流程员工提交报销单后财务部门将在7个工作日内完成审核与打款。”从人工视角看这段话完全命中问题。但第一次跑纯向量检索这条文档的排序并不在前三甚至前五都进不去。问题不在于向量模型没理解语义而在于向量检索在高频业务场景里默认处理的是“语义相似度”不是“信息相关度”。这两个概念在做搜索召回时会被严重混淆。很多人搭 RAG 应用时先拿中文 Embedding 模型生成向量然后往 Milvus、Qdrant 或者 FAISS 里一塞用余弦相似度把 TopK 取回来就觉得链路通了。等到真有人问“报销多久到账”系统返回的却是“报销单填写要求”“报销审批权限说明”这时候才会发现向量检索不准不是换个 Embedding 模型就能解决的。要解决这个问题需要把召回链路拆开来看向量检索负责哪一段它擅长什么不擅长什么然后在它外面补上混合检索和 Rerank 重排序。这篇内容就把我实际落地时的处理思路、代码和参数取舍完整梳理一遍。1.1 实际场景答案在库偏偏没被捞出来做知识库问答最常见的失败模式不是“库里没有答案”而是“答案在里面检索排不出来”。我遇到过几种类似情况用户问“出差打车发票能用吗”库里写的是“市内交通费报销标准包括出租车发票”。这个靠向量检索勉强能命中因为出租车、交通费、报销这些词有语义关联。用户问“预算不够了能不能加”库里写的是“项目预算调整需提交变更申请”。这个就麻烦“加预算”和“预算调整”在表面词上差得远语义上却是一回事单靠向量模型不见得抓得住。用户问“报销到账需要几个工作日”库里写的是“财务审核通过后7个工作日内打款”。这个更典型——问题和答案之间只有“几天”“到账”这类零散线索真实有效的匹配来自财务流程的常识而不是纯粹的文本相似度。第一种纯靠向量还有机会第二、第三种就开始漏了。原因是向量模型把整句语义压成一个高维向量这个过程会丢失很多细粒度线索。尤其是数字、专有名词、操作条件这些关键信息在向量空间里很容易被周围那些更“泛”的词稀释掉。1.2 向量检索失效的三种常见类型结合多个项目的排查经验我通常会把“向量检索不准”归成三类。第一类是“关键词粒度的信息丢失”。Embedding 模型把文本转成稠密向量时会把“7个工作日”“打款”“审核通过”这种信息编码成语义向量里的某些方向。可一旦句子长一点、里面同时有多个关键信息最终向量往往是它们的状态叠加。真正和用户问题强相关的那部分只占很小比重导致检索时分数被其他不相关句子带跑。第二类是“意图粒度错配”。用户的提问天然是短而口语化的“报销多久到账”“这个能报吗”“标准是多少”但库里的文档是长而正式的。问句向量和文档向量在空间中的距离往往会被语气、句式和背景描述干扰。一个四五句话组成的文档段落算出来的向量会把每句话的含义“平均”掉结果最容易匹配的是某个泛泛的主题描述而不是精确到操作步骤的那一句。第三类是“语料之间本身高度相似”。企业内部知识库最容易出现这种情况几十个项目都在讲“申请-审批-执行-归档”表面套路一致只有细节不同。向量空间里这些段落挤在一起余弦相似度普遍很高你很难靠分数把真正对应某一条制度的那份文档区分出来。1.3 判断任务的性质你要的是“相似”还是“相关”做优化之前必须先想明白一个业务问题你的问题本质上是一个“语义联想题”还是一个“信息检索题”。“还有别的办法解决吗”这种问题是语义联想适合向量召回。“报销到底几天到账”这种问题核心是信息检索本质要求字面和逻辑条件都精确匹配。语义相似度高不等于答案正确。这也是为什么业界在 RAG 场景里普遍转向混合检索而不是继续盲目堆向量模型效果字面匹配负责兜底精确条件语义匹配负责扩展表达方式重排模型负责在两者结果之上做最终判断。理解到这个层面才算搞清楚“向量检索不准”的真正含义不是单个环节出错而是整套召回策略缺少立体结构。后面要做的混合检索和 Rerank就是把这个立体结构搭起来。2. 混合检索字面匹配和语义召回一条都不能少2.1 先承认一个事实BM25没有退环境很多人在接触 RAG 时容易被一种叙事带偏“用向量检索干掉传统关键词搜索”。这个观点在开放域问答里可能有点道理但放到企业知识库、专业文档、工具手册这类场景很快会被现实教育。传统 BM25 关键词检索的核心是对词频和逆文档频率做加权。它的本质是“词命中”好处是精确、可控、对专有名词和数字符号极友好。你搜“EPC项目验收流程”只要文档里出现 EPC、验收、流程它大概率能捞回来拼写和语义模型的联想反而没那么重要。问题在于它对同义词和无词形变化很无力比如问“怎么把钱拿回来”它不会联想到“报销到账”。向量检索擅长解决这一层。所以 BM25 不是要淘汰而是要和向量检索并肩作战。这就像查资料时你既需要一个记住原话在哪本书里的图书管理员也需要一个能听懂你口音、帮你找相近内容的顾问。两个人一起干活比自己单打独斗靠谱得多。2.2 最简单的混合方案双路召回先别谈复杂模型混合检索的入门版方案很简单同一份文档分好块建立两套索引。一套用 BM25按词命中排序取 TopN另一套用向量 Embedding按余弦相似度排序也取 TopN。两边做并集去重再统一进入下游。第一版不需要做任何机器学习上的融合策略。只要保证块切分粒度相同否则两边检索的对象根本对不上TopN 不要取太少建议 50~200因为后面还有 Rerank 兜底两路结果合并后记录每条结果来自哪一路方便后面排查。很多团队在第一步就会翻车因为把大量精力放在“怎么让两路分数可比”上。其实不需要。你先让两路结果都进到候选集里真正的精准排序交给 Rerank 来做。如果候选集这一步就把真正的答案漏掉了后面模型再强都救不回来。2.3 分数归一化与融合为什么我把RRF放在第一版如果不做 Rerank只做“混合”那就面临一个问题BM25 分数和向量余弦相似度根本不是一个量纲。直接把分数相加等于让某一方主导没有任何意义。业界常用的三种融合方法第一种是归一化加权。对两路分数分别做 min-max 或 Z-score 归一化再按权重相加。这种方案直观但权重需要人工调而且对分数分布异常敏感。如果某个 query 的 BM25 分数整体偏高或偏低权重就失效。第二种是 RRF也就是 Reciprocal Rank Fusion。它不看原始分数只看排名。每条候选文档的综合分等于它所在路排名的倒数之和。公式为score Σ 1 / (k rank_i)对于出现在两路或多路的文档每多一路命中就能叠加积分。k 通常取 60 左右目的是削弱排名第一和第二之间过大的差异避免某一路独裁。第三种是基于学习的融合方式比如训练一个小模型把多路分数作为特征去拟合业务标注数据。效果好但维护成本高小团队不建议第一版就上。我把 RRF 放在第一版的原因很简单它几乎不需要调参对分数尺度不敏感而且能真实反映“多路同时命中”的协同价值。在向量召回那段不太准的初期RRF 能让 BM25 精确命中的结果不至于被淹没。提示如果你的两路结果都做了很高水平的调优再考虑更复杂的归一化加权初版就用 RRF避免在归一化问题里浪费过多时间。2.4 混合检索代码示意这里我给一个接近生产使用的伪代码结构方便照搬改造。假设文档已经完成切块分别调用 es_client 做 BM25 检索、调用向量库做 ANN 检索。# Python Elasticsearch FAISS 风格示例 import numpy as np def hybrid_search(query, index_name, vector_index, embed_model, top_k100, rrf_k60): # 1. BM25 召回 bm25_body { query: { multi_match: { query: query, fields: [title, content] } }, size: top_k } bm25_resp es_client.search(indexindex_name, bodybm25_body) bm25_ids [hit[_id] for hit in bm25_resp[hits][hits]] bm25_rank {doc_id: idx 1 for idx, doc_id in enumerate(bm25_ids)} # 2. 向量召回 query_vec embed_model.encode(query, normalize_embeddingsTrue) vec_resp vector_index.search(query_vec, top_ktop_k) vec_ids [doc[id] for doc in vec_resp] vec_rank {doc_id: idx 1 for idx, doc_id in enumerate(vec_ids)} # 3. RRF 融合 all_ids set(bm25_ids) | set(vec_ids) fused_scores {} for doc_id in all_ids: score 0.0 if doc_id in bm25_rank: score 1.0 / (rrf_k bm25_rank[doc_id]) if doc_id in vec_rank: score 1.0 / (rrf_k vec_rank[doc_id]) fused_scores[doc_id] score # 4. 按融合分排序返回候选 sorted_ids sorted(fused_scores, keylambda x: fused_scores[x], reverseTrue) return sorted_ids[:top_k]补充一句这里刻意不对索引、Embedding 模型选择展开因为不同团队的基础设施差异太大。重要的是把握“双路取回、按排名融合”的思路。2.5 常见误区混合索引不是把 TopN 直接拼接有个非常容易踩的坑以为混合检索就是把 BM25 返回的 TopN 和向量返回的 TopN 简单拼在一起前 10 条取 BM25后 10 条取向量然后直接送给大模型。这种做法看起来混合了实际效果往往还不如单路。原因是两条检索通道的排序逻辑完全不同拼出来的顺序没有可比性。而且 BM25 和向量返回的头部结果有大量重复直接拼接会浪费后面的输出窗口配额。另有团队喜欢只保留两路都命中的交集。这也不一定好因为很多问题本身就是某一方才擅长。比如用户输入的是全文抄录的长段落向量检索可能极准用户输入的是几个零散但关键的词BM25 可能更准。如果把交集作为唯一候选等于又回去了。所以混合检索的正确姿势是“召回层做并集精排层做判断”。保证不漏但不保证准。“准”这件事交给 Rerank。3. Rerank精排CrossEncoder如何把“清单”变成“答案”3.1 BiEncoder负责捞CrossEncoder负责判澄清一个概念我们平时说的“向量检索模型”大多是 BiEncoder 结构。它把 query 和 document 各自编码成一个向量然后在向量空间里算距离。这样做的好处非常明显——文档可以提前离线建索引在线检索很快。代价是 query 和 document 之间没有真正发生过“交互”它们在模型中只是被分别编码后比较大小。CrossEncoder 不一样。它把 query 和 document 拼成一句话输入模型让模型内部的注意力机制在 query 和文档的每个 token 之间充分交互。这样模型能够捕捉到细粒度的匹配信号例如“到账”“几个工作日”“7个工作日”之间的精确对齐。如果用一句话类比BiEncoder 像是看简历筛人候选人的学历、技能、经历都被压缩成摘要筛选速度快但只能看大概。CrossEncoder 像是面试让面试官和候选人真正对话能判断细节是否匹配但是每个候选人都要单独面一次贵且慢。所以在完整链路里CrossEncoder 不适合拿来做全量检索它的位置是在召回之后负责把几十到几百个候选文档精排一轮选出真正能回答问题的 TopK。3.2 为什么重排能纠正召回阶段的错误排序看一个例子。假设候选集里有两段文本文档 A“员工提交报销单后财务部门将在7个工作日内完成审核。”文档 B“公司报销制度包括差旅补贴、餐补和交通补贴标准。”用户问“报销打款要多久”向量检索会认为 A 和 B 都跟“报销”相关但 B 的整体向量中心可能更接近“报销”这个短语实际却没有回答打款时间。A 虽然包含“报销”和具体天数但在向量空间中的位置反而略偏。Rerank 用 CrossEncoder 计算时query 是“报销打款要多久”候选 A 会被拼成“[CLS] 报销打款要多久[SEP] 员工提交报销单后财务部门将在7个工作日内完成审核。”模型能看到“多久”“打款”“7个工作日”之间的明显对应关系打分会显著高于只是泛泛提及报销制度的 B。这个过程是“从词到句的细粒度匹配”不是“从意思到意思的粗粒度联想”。对 RAG 场景来说重排往往是提升最终问答准确率最立竿见影的一个环节。3.3 轻量重排的选型与运行细节Rerank 模型选型既要看效果也要看部署成本。中文场景下比较常见的选择是 BAAI 开源的 bge-reranker 系列。bge-reranker-base 适合普通业务量和小规模 GPU 推理显存占用低CPU 上也能勉强跑只是慢bge-reranker-large 在准确率上更强但推理延迟和显存占用都明显上升部分云平台提供可用的 rerank API省去部署环境问题如果数据不能出内网还是本地模型更稳。我个人的习惯是先用 base 版本跑通链路等效果验证完毕再用 large 版本做 A/B 对比。不是所有人都有 GPU 集群能跑起来、能看效果、能量化收益比一开始追求极限精度更重要。使用 CrossEncoder 做重排时有一个细节必须注意。query 和 document 拼接后输入长度是有限的。如果 document 很长比如切块超过 512 token模型就只能截断末尾可能会丢掉关键信息。所以重排之前最好确保候选文档的切块大小与模型最大长度匹配或者在分段检索阶段就做好基础切块。3.4 Rerank阶段的实际调用代码用 sentence-transformers 库调用 bge-reranker 的代码非常简单重排的核心逻辑就是把候选文档成对送入。from sentence_transformers import CrossEncoder # 加载本地模型max_length 控制拼接后最大 token 数 rerank_model CrossEncoder(BAAI/bge-reranker-base, max_length512) def rerank_candidates(query, candidates, top_n10, batch_size32): # 构造(query, doc)输入对 pairs [(query, doc[content]) for doc in candidates] scores rerank_model.predict(pairs, batch_sizebatch_size) # 按分数降序排序 scored list(zip(candidates, scores)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_n]注意这里面上层已经拿到了混合检索融合后的候选集。如果候选数控制在 100 条以内模型推理量并不大如果到 500 条甚至更多你会明显感受到延迟压力。对于线上低延迟场景一个合理配合是向量检索 Top100 - 混合排序后选 Top50 - Rerank 只对 Top50 做精排 - 取 Top5 给大模型。这也是很多生产系统默认的配合比例。4. 落进生产一个RAG问答链路的完整参数建议4.1 用场景串起完整的检索链路从工程角度看混合检索加 Rerank最终要组成一套这样的完整链路。第一步用户输入问题先做 Query 预处理。这里的预处理不是简单的分词而是判断是否有需要精确匹配的实体比如部门名称、文档编号、金额数字、流程状态。这些约束条件对向量检索很致命但对 BM25 很友好。第二步并行执行两路召回。BM25 跑关键词匹配向量模型做语义扩展两边各自取回 Top100。第三步用 RRF 做融合得到一篇相对粗糙但有高召回率的候选序列。第四步CrossEncoder Rerank 对前 50~100 条候选精排取 Top5。第五步把精排后的结果组合进 Prompt交给大模型生成答案同时附上来源引用方便人工核验。这套链路的价值在于每个环节都有明确分工。你会发现向量检索不准的问题不是某个环节单独修复的而是整条漏斗结构共同修正的。4.2 从嵌入到检索一份可直接跑的简化实现下面这份代码是我在本地验证“混合检索 Rerank”时常用的最小实现完整跑通只需要一个小的 Python 脚本# 简化验证版bm25 faiss bge-reranker import numpy as np from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer, CrossEncoder # 文档切块后的语料 docs [ 员工提交报销单后财务部门将在7个工作日内完成审核与打款, 差旅费报销需提供发票填写报销单并提交直属上级审批, 市内交通费报销标准包括出租车发票和地铁票, 项目预算调整需提交变更申请由项目负责人审批, # ... 实际场景中应该有几百上千条 ] # 1. 准备BM25 tokenized_docs [list(doc) for doc in docs] # 真实场景用jieba分词 bm25 BM25Okapi(tokenized_docs) # 2. 准备向量索引这里直接用余弦矩阵模拟 embed_model SentenceTransformer(BAAI/bge-large-zh-v1.5) doc_vectors embed_model.encode(docs, normalize_embeddingsTrue) def retrieve(query, top_k100): # 向量召回 q_vec embed_model.encode(query, normalize_embeddingsTrue) vec_scores doc_vectors q_vec vec_top_ids np.argsort(vec_scores)[::-1][:top_k] # BM25召回 bm25_scores bm25.get_scores(list(query)) bm25_top_ids np.argsort(bm25_scores)[::-1][:top_k] # RRF融合 rrf_k 60 fused_scores {} for rank, idx in enumerate(vec_top_ids): fused_scores[idx] fused_scores.get(idx, 0) 1 / (rrf_k rank 1) for rank, idx in enumerate(bm25_top_ids): fused_scores[idx] fused_scores.get(idx, 0) 1 / (rrf_k rank 1) return sorted(fused_scores.items(), keylambda x: x[1], reverseTrue)[:top_k] # 3. Rerank精排 rerank_model CrossEncoder(BAAI/bge-reranker-base, max_length512) def final_rank(query, candidates, top_n5): pairs [(query, docs[idx]) for idx, _ in candidates] scores rerank_model.predict(pairs) result sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [(idx, docs[idx]) for (idx, _), score in result[:top_n]]这段代码的最大意义不是性能而是让你直观看到全流程的数据流。实际生产时替换向量库、检索服务和并发框架即可。4.3 参数建议与性能取舍做产品化配置时下面几个参数值得认真对待。我按自己常用的推荐起始值列一个表具体场景还需调优参数推荐值说明文档切块 chunk_size300~500 字太短丢失上下文太长稀释精确匹配chunk_overlap30~50 字保证跨块信息不中断BM25 召回 TopK100尽量别少于 50否则融合后候选不足向量召回 TopK100与 BM25 对齐方便观察两路单独覆盖率RRF 参数 k60一般 40~80 之间效果稳定Rerank 候选数50超过 100 延迟收益比下降明显最终输出 TopN5给大模型的答案片段太多影响信噪比这些参数不是拍脑袋定的。先说切块一般在 300~500 字之间足够表达一段完整操作流程或标准条款又不至于让同一段文本承载过多主题。然后是重排候选数CrossEncoder 是逐对计算候选从 50 增到 500CPU 推理时间可能上升 10 倍而最后一轮收益未必成比例提升。注意Rerank 并不需要处理全部召回结果。它解决的是“最后 50 条怎么排”的问题不是“全体排序”的问题。候选规模控制好后用 base 模型也够快。4.4 评测与回归验证优化不是靠“感觉”参数难调是因为缺少评价指标。做检索优化最忌讳只看两三个样例就下结论。我建议上线前至少准备三样东西第一构造一批带标准答案的评测集。例如 100~200 个真实用户问题每个问题标注 1~3 条正确答案来源。不一定要很多但要覆盖常见问法、长尾问法和同义改写问法。第二定义指标。检索链路常用的是 RecallK、Hit RateK、MRR。Recall 看答案在不在候选集里MRR 看答案排名靠不靠前。如果召回 Top100 里面答案压根没有优先调切块和混合策略如果答案在候选里但没进 Top5优先调 Rerank。第三留出基线版本。每次改动线上参数前先把原版本召回结果存下来之后再跑同样的评测集对比。做过几次实验后你就会发现很多调整在单独样例上“看起来更聪明了”但整体 MRR 不升反降。以我过往的经验加入混合检索这一步大多数知识库项目的 Recall10 就能有不少提升因为解决了纯字面和纯语义各自丢失的部分。再叠加 RerankMRR 会有新一轮明显提高最终给到大模型的 Top5 上下文更集中生成的答案也就更少出现张冠李戴。具体能提升多少取决于语料质量、切块策略和模型选择不要迷信任何公开基准数据。5. 避坑清单与最终调参心得从纯向量检索改成“混合检索 Rerank”之后大概率还会遇到新问题。我总结几个项目里最容易被忽略、且直接影响线上效果的坑。第一个坑文档切块切得太碎导致 Rerank 拿不到完整上下文。比如把一整段 500 字的报销流程切成 4 条 100 字的碎片每一条都能命中几个关键词但 CrossEncoder 看哪条都像“部分相关”分数拉不开。这时候把切块调大或者允许按语义段落切分往往比换模型更有效。第二个坑Rerank 模型的打分风格和业务数据不一致。开源模型经过通用语料训练知道哪些文本“看起来更像正确答案”但不一定理解你们公司的报销叫“EMS”、项目代号叫“A3P”。如果业务专有词比例高建议在你们自己的历史 query-文档 对上面做少量 fine-tune。没有 fine-tune 条件时至少要坚持人工评测避免被高分误导。第三个坑query 里的冗余表述影响检索。真实用户提问往往是“帮我查一下报销几天能到账”而不是干净的“报销几天到账”。在向量检索场景“帮我查一下”这几个字会把 query 向量往非业务方向带一点点但在混合检索里影响会被 BM25 和 Rerank 分担所以危害不那么大。不过在指令微调类的模型场景里这种前缀会造成更大偏差。可以在预处理阶段做轻量改写去掉寒暄词和指令词保留核心业务语义。第四个坑重排分数跨场景不可比。同一个 Rerank 模型对“报销打款”类问题的打分普遍偏高对“备份策略”类偏冷门问题的打分可能普遍偏低。如果你跨多个业务域共用同一个阈值去决定要不要把内容给大模型很容易出现热门域漏出垃圾、冷门域全被过滤的情况。更稳妥的做法是只做排序不做全局截断让大模型来判断最终答案。第五个坑重排后的 Top1 不一定是答案生成的最佳输入。Retrieval 的 Top1 通常是“最可能包含答案的段落”但大模型回答“报销需要多久到账”时可能需要把“报销流程”和“财务审核周期”两段内容拼在一起。所以最终进 Prompt 的材料建议保留 Top3~5而不是只塞一条最高分。候选太少会加大模型盲猜的概率。调参顺序上我的建议是先把“混合检索”这层跑稳确认答案的召回率提上来了再调 Rerank。不要一上来就换大模型、换 Embedding、加 Rerank 同时做。一次只动一个变量你才能知道每个模块各自贡献了多少收益。如果评测后发现问题集中在召回的某一类例如用户经常用口语化表达查询专业术语那就去调 Embedding 模型或增加同义词扩展如果发现答案能召回但排在后面优先调重排模型和候选数量如果发现大模型输出时引用了不相关的段落优先检查最终进 Prompt 的 TopN 里是否混进了低质量片段必要时可以加一条“若段落与问题无关则明确回答未知”的约束指令。把混合检索和 Rerank 当成一个分层漏斗理解之后你会发现“向量检索不准”这个问题的答案不再是某一个模型能解决的而是要用召回宽度加精排深度共同去补足。这也正是 RAG 这类系统最核心的工程能力所在。