RAG 检索优化实战:混合检索 + RRF + Rerank 三件套(附代码与参数表)
前言
先说个我自己踩过的事:几百页文档灌进知识库,问它「XX 设备的故障码 E-123 怎么处理」,AI 张嘴就编了个像模像样的答案。我翻回文档一看,正确答案就躺在第 47 页。
后来排查明白了,这事儿八成不怪大模型,怪检索–第 47 页压根没被捞回来。做 RAG 这段时间有个体感:效果差的问题,七成出在检索上。
先打个比方帮你看懂全貌:把这套系统想成一家档案馆,你是馆长,手底下俩员工各有一手绝活。员工 A 照意思找,用户问"退货",他能给你翻出《退换货流程》;员工 B 照字找,你报故障码 E-123,他就一个字一个字地死磕。用户来提问,俩人各搬回来一堆资料;你从两堆里各挑出几十本,把两边都排得上号的合到一起,再细筛一遍,把最靠谱的几份摆到大模型面前–找得到就答,找不到就老实说找不到。(档案本身怎么整理归档,是"切块"的活,下一篇专门讲。)
映射回术语就一句话:员工 A 是向量检索,员工 B 是关键词检索,馆长干的活就是下面要讲的融合加重排。
这篇把我真实项目里正在跑的三件套(混合检索 + RRF 融合 + Rerank 重排)完整拆开:代码骨架可以直接抄,参数表给起步值,最后是上线第一周踩的 3 个坑,第 3 个最贵。
一、为什么单路检索必然不准
纯向量检索的盲区:对精确 token 不敏感。故障码、合同编号、型号、专有缩写,靠语义相似度经常匹配不上–你问 E-123,它召回 E-124,唯独没有 E-123。
纯关键词检索的盲区:BM25 就是搜索引擎按字面匹配打分的那套老算法。文档写"退换货流程",用户问"怎么退货",一个字都对不上,直接漏召回。
单路检索,各有各的瞎法。
二、三件套原理速览
两路并行召回 topN -> RRF 融合(按排名)-> Rerank 精排 -> 取 finalK 条给大模型- 混合检索:向量管语义、BM25 管关键词,两路并行,互不挡道
- RRF 融合:两路各出一张榜单,怎么合成一张?麻烦在于分数没法比——向量路打出来 0.83,关键词路打出来 12.7,压根不是一个量纲。RRF(倒数排名融合)的思路笨但好用:不看分数,只看名次。两路都排前面的,自动冲到最前面。公式就一行:
score = Σ 1/(k + rank),k 通常取 60,不用调参 - Rerank 重排:重排模型把"问题 + 候选文档"拼在一起读一遍、挨个打相关分,准但慢,所以只对融合后 top50 精排,最后留 3~5 条给大模型
三、代码骨架
骨架是 Java 写的,但别急着划走:这套东西本质是流程编排,两路并发、按名次融合、带降级的重排,换 Python 或 Go 写出来长得也差不多–到了这个年代,这类代码语言真的不重要。(我为什么选 Java、整套技术选型的账,后面单独写一篇。)
// 检索配置:全部可配,千万别写死(不同知识库最优点不一样)recordRetrievalConfig(inttopN,// 每路召回条数intrrfK,// RRF 平滑常数intrerankCandidates,// 进重排的候选数intfinalK,// 最终给大模型的条数booleanrerankEnabled// 重排开关){}// 1. 两路并行召回(延迟取 max 而不是相加)Future<List<Chunk>>vecF=pool.submit(()->vectorStore.search(embed(query),cfg.topN()));Future<List<Chunk>>kwF=pool.submit(()->bm25Index.search(query,cfg.topN()));// 2. RRF 融合:只看排名,不看分值staticdoublerrfScore(List<SearchResult>all,StringdocId,intk){doubles=0;for(SearchResultr:all)if(r.docId().equals(docId))s+=1.0/(k+r.rank());returns;}// 3. Rerank 漏斗:带独立超时 + 降级(血泪教训,见下文坑 3)List<Chunk>fused=rrfFuse(vecF.get(),kwF.get(),cfg.rrfK()).limit(cfg.rerankCandidates());List<Chunk>top;try{top=cfg.rerankEnabled()?reranker.rerankWithTimeout(query,fused,Duration.ofMillis(800)).limit(cfg.finalK()):fused.limit(cfg.finalK());}catch(TimeoutException|RerankExceptione){log.warn("rerank 降级,退回 RRF 融合序: {}",e.getMessage());top=fused.limit(cfg.finalK());// 降级用融合序,不能让整条检索挂掉}向量库选型说明:我用的是pgvector(我的业务库本来就是 PostgreSQL,备份/权限/运维全套现成,百万级以下数据量够用),换成 ES 或专用向量库,管线结构不变。
四、参数配置表(起步值与调优方向)
| 参数 | 含义 | 起步值 | 调优方向 |
|---|---|---|---|
topN | 每路召回条数 | 30~50 | 宁多勿少,粗召回是漏斗入口 |
rrfK | RRF 平滑常数 | 60 | 经典值,动它收益很小,别纠结 |
rerankCandidates | 进重排候选数 | 50 | 与重排模型 QPS/预算匹配 |
finalK | 最终给大模型条数 | 3~5 | 太多引噪声,太少丢上下文 |
rerankEnabled | 重排开关 | true | 小库可关(差别小),大库必开(差别大) |
五、上线踩过的 3 个坑
坑 1:候选池开太小,重排无米下锅。一开始我两路各只召回 10 条,重排模型再强也没用–正确文档压根没进候选池。topN提到 30~50 之后,命中率立刻回来了。粗召回这步别抠门,反正后面有漏斗帮你筛。
坑 2:两路串行,首字耗时翻倍。我一开始图省事,写的是先查向量、拿到结果再查关键词,两个延迟加在一起。后来把两个任务同时发出去,总延迟就等于慢的那一路,流式对话的首字时间肉眼可见地快了。
坑 3(最贵,命中率"腰斩"的元凶):重排服务没有降级。有天重排服务抖了,请求全卡在超时上,把整条检索链路拖垮,前端只好退回单路检索–命中率直接腰斩。最气人的是没有告警,两天后才从用户反馈里知道。修复三件事:重排调用单独加 800ms 超时、失败就退回 RRF 融合序、命中率监控告警。教训就一句:降级路径不是可选项,是检索管线的标配。
六、上线后的两个救命配套
- 检索测试面板:同一问题的两路命中、融合排名、重排排名、最终给模型的片段全链路可视,效果差一眼看出是哪段的锅
- 检索日志:线上每次检索全量落库,定期分析"高频问题 × 命中质量",反哺切块与参数。没有度量,调参全靠玄学
七、预判抬杠:大厂不是十几路召回吗?
写这种文章,评论区可能会出现这句:”大厂都是多路召回,你就俩路?”先把话说在前面:两路还是十几路,不是技术差距,是问题域不一样。
- 企业知识库,大厂的收敛形态就是”两路混合 + 融合 + 重排”:Azure AI Search(向量 + 全文 + 语义重排)、AWS Bedrock 知识库(混合检索 + 可选重排)、Elastic(BM25 + 向量 + 学习型稀疏检索)、国内百炼 / Coze / 千帆的知识库产品(语义 + 关键词混合 + 查询改写 + 重排)。不管面向 ToB 还是 ToC 客户,扒掉外壳全是这个骨架。
- 十几路召回是网页搜索、推荐系统的事:那边是亿级文档加流量分发的问题,向量、关键词、学习型稀疏(用模型学出来的关键词权重,比 BM25 聪明)、多查询改写、缓存热点、个性化、知识图谱,十几路一起上。企业知识库一般就几千到几万份文档,用不着,也养不起。
- 前沿方向知道有就行:GraphRAG(把文档抽成知识图谱,按社区摘要做检索)、上下文检索增强(Anthropic 的 Contextual Retrieval,入库时给每个块补一句全文语境)、Self-RAG / CRAG(模型自己判断检索结果够不够好,不够就再来一轮)。这些还在论文和头部场景里卷,远没到企业标配。
总结
- 单路检索必有盲区:向量丢精确词,关键词丢同义改写
- 三件套漏斗:混合兜宽度、RRF 管融合、Rerank 管排序
- 三个坑的本质:入口要宽(topN)、两路要并行、出口要有降级
觉得有用点个赞收个藏。你要也卡在哪一段过,评论区聊聊,我看到都会回。下一篇拆大厂知识库的四段式完整流水线(切块 -> 混合检索 -> 重排 -> 引用溯源),关注不迷路。