RAG 为什么要混合检索:从关键词、向量到重排与引用溯源
本文定位:RAG 检索工程 / 搜索排序 / 企业知识库
示例环境:PostgreSQL + pgvector、Java 21、BM25 思路、Rerank 服务。指标阈值需要根据业务风险重新设定。
摘要
只使用向量相似度的 RAG,面对错误码、产品型号、合同编号和精确字段时往往不如关键词检索;只使用关键词,又无法很好理解同义表达和自然语言问题。混合检索的价值不是把两套结果简单拼起来,而是利用不同检索器的优势,再用统一的排序和证据治理把结果变成可解释的上下文。
本文从一个“设备故障知识库”出发,比较 BM25、向量检索、混合召回和 Rerank 的职责,给出候选集融合、引用元数据、Java 数据结构和评测方法,并说明为什么“召回更多”不一定代表回答更好。
一、不同检索器擅长什么
| 检索方式 | 擅长 | 不擅长 |
|---|---|---|
| 关键词/BM25 | 错误码、编号、专有名词、精确短语 | 同义改写、口语表达 |
| 向量检索 | 语义相近、自然语言描述 | 精确数字、罕见型号、否定条件 |
| 混合召回 | 兼顾精确与语义 | 参数更多,调试复杂 |
| Rerank | 在候选集中判断问答相关性 | 无法挽回第一阶段完全没召回的证据 |
例如用户问“告警 E102 连续出现三次后,先做什么”,关键词检索容易直接命中E102;用户问“采集链路不稳定时怎样处理”,向量检索更容易找到“检查采集链路”的段落。高质量系统通常让两者并行产生候选。
二、检索链路
注意权限和版本过滤既可以在第一阶段做,也应该在最终上下文组装前再做一次。多层过滤是为了防止缓存、重排服务或异步流程引入过期、越权文档。
三、关键词和向量结果如何融合
最简单的方式是给两类结果设置权重:
score = alpha * normalized_vector_score + (1 - alpha) * normalized_keyword_score但两个检索器的分数分布往往不同,不能直接相加。更稳妥的做法是 Reciprocal Rank Fusion:
RRF(d) = sum(1 / (k + rank_i(d)))其中rank_i(d)是文档在第 i 个检索器中的排名,k是平滑常数。RRF 不依赖原始分数尺度,适合快速建立混合检索基线。
publicList<Candidate>rrfMerge(List<Candidate>keyword,List<Candidate>vector,intk,intlimit){Map<Long,Double>score=newHashMap<>();for(inti=0;i<keyword.size();i++){score.merge(keyword.get(i).chunkId(),1.0/(k+i+1),Double::sum);}for(inti=0;i<vector.size();i++){score.merge(vector.get(i).chunkId(),1.0/(k+i+1),Double::sum);}returnscore.entrySet().stream().sorted(Map.Entry.<Long,Double>comparingByValue().reversed()).limit(limit).map(e->Candidate.withFusionScore(e.getKey(),e.getValue())).toList();}融合前要按 Chunk ID 去重,并保留每个候选来自哪些检索器、原始排名和分数。否则出了问题只能看到最终排序,无法判断是关键词错了、向量错了还是 Rerank 错了。
四、Rerank 应该放在哪里
Rerank 适合对 20~100 条候选做精细相关性判断,不适合直接扫全库。它可以理解问题和候选文本的交互关系,通常比单独的向量相似度更准确,但会增加网络调用、延迟和成本。
publicList<Candidate>retrieve(QueryContextquery){List<Candidate>candidates=merge(keyword.search(query.text(),30),vector.search(query.embedding(),30));List<Candidate>permitted=permissionFilter.filter(candidates,query.auth());List<RerankItem>items=permitted.stream().map(c->newRerankItem(c.chunkId(),c.content())).toList();returnreranker.rank(query.text(),items,8);}Rerank 不能代替权限过滤。先发送越权文本给外部 Rerank 服务,再在返回结果里过滤,已经失去安全意义。更安全的顺序是先过滤租户和权限,再重排。
五、查询规范化要克制
可以让模型把问题拆成错误码、设备型号、时间范围和意图,但查询改写不能改变原始条件。建议同时保留原问题和规范化结果:
{"original":"E102 连续三次后先做什么?","keywords":["E102","连续三次"],"semantic_query":"错误码E102重复出现后的首要处理步骤","must_keep":["E102","三次"]}如果改写模型把否定条件删掉,检索结果可能完全变质。例如“不是电源问题时如何排查”不能改成“电源问题如何排查”。对高风险检索,重要实体应通过规则或实体识别校验。
六、引用溯源:让每条结论都能回到原文
上下文组装时给每个 Chunk 生成稳定引用编号,编号和文档 ID、版本、页码、章节一一对应。模型只输出[C1]、[C2],前端再把编号渲染成可点击来源。
publicrecordEvidence(StringcitationId,longchunkId,Stringtitle,intversion,Integerpage,Stringcontent){}publicStringbuildContext(List<Candidate>candidates){returnIntStream.range(0,candidates.size()).mapToObj(i->{Candidatec=candidates.get(i);Stringid="C"+(i+1);return"["+id+"] 文档="+c.title()+" 版本="+c.version()+" 页码="+c.page()+"\n"+c.content();}).collect(Collectors.joining("\n\n"));}生成后校验引用编号是否存在,且引用内容是否在当前候选集合中。引用了不存在的[C9],或者引用了一个没有支持该结论的 Chunk,都应视为回答质量问题。
七、评测必须拆分检索和生成
如果最终答案错了,不一定是模型生成能力差,也可能是正确证据根本没有被召回。评测分两层:
- 检索评测:Recall@K、MRR、nDCG、过滤正确率。
- 生成评测:引用支持率、答案覆盖率、拒答准确率、格式通过率。
可以把每条错误归因到以下类别:未召回、召回但排序靠后、证据冲突、上下文过长、模型推理错误、引用错误和权限错误。错误归因比只记录“答案不对”更有优化价值。
八、典型失败案例
错误码命中了,但处理步骤不对
可能是多个版本文档都包含同一错误码,旧版本排名更靠前。解决方案是把版本状态作为过滤条件,并让版本信息进入 Rerank 输入。
语义相似度很高,但没有关键数字
用户问“连续三次”,召回的内容只包含“重复告警”。可以对数字、错误码和型号做关键词增强,并要求证据覆盖这些实体。
候选变多,回答反而变差
召回了互相冲突的制度和历史记录。应按版本、发布日期和文档状态做治理,并在冲突时主动提示“存在多个版本,需要确认适用范围”。
引用很多,但不支持结论
模型为了显得可靠而堆引用。可以限制每个结论最多引用 2~3 条,并要求引用内容包含相关实体或条件。
九、性能和成本
建议先测四个阶段:关键词查询、向量查询、Rerank 调用、模型生成。Rerank 候选从 60 条增加到 200 条,准确率可能略有提升,但延迟和成本会显著增加。对高频固定问题可以缓存召回结果,但缓存键必须包含租户、权限、知识库版本和查询归一化结果。
可以采用分层策略:普通问题只做混合召回;高风险问题增加 Rerank 和引用校验;知识库外问题走拒答检测。不同场景使用同一套“最重链路”,通常会造成成本浪费。
十、上线检查清单
- 关键词和向量结果是否都保存原始排名。
- 融合时是否去重、归一化并保留来源。
- 权限过滤是否发生在发送给 Rerank 之前。
- 版本、状态和生效时间是否进入检索条件。
- 生成答案中的每个引用是否可回溯。
- 是否有知识库外问题和冲突文档测试。
- Embedding 或 Rerank 模型更换后是否重新跑评测。
- 缓存是否包含租户、权限和知识库版本。
十一、总结
混合检索不是“多调几个参数”,而是把精确匹配、语义理解、排序决策和证据治理组合起来。关键词擅长命中实体,向量擅长理解表达,Rerank 负责精排,引用溯源负责让答案可复核。
最有效的优化顺序通常是:先确认正确证据能被召回,再确认它排在前面,最后才调整模型回答。这样才能知道问题出在数据、检索还是生成,而不是在 Prompt 上反复试错。
读者讨论
如果你的知识库包含大量错误码、型号或版本号,建议先统计这些实体在查询中的占比,再决定关键词与向量的权重。