RAG检索增强策略:混合检索、重排序与Query改写
前面的文章里,我们把数据层、向量层都搭好了,Demo 也跑通了。但到了这一步,你会发现一个新问题:“今天的召回结果怎么跟昨天不一样?”“明明有这篇文档,为什么搜不出来?”
检索是 RAG 系统里调优空间最大的环节。没有好的检索,LLM 就是巧妇难为无米之炊。今天我把最实用的三个检索增强策略掰开来讲。
大家好,我是黒漂技术佬。
一、为什么单纯的向量检索不够?
向量检索的本质是"语义相似"。这个机制在 80% 的场景下工作得很好,但剩下 20% 会出问题:
场景 1:精确匹配失败
用户问"张三的报销单",向量检索可能搜出一堆讲报销流程的文档,但就是搜不出张三那张——因为"张三"这个人名的语义信息非常弱,在向量空间里和其他"人名"混在一起,没有区分度。
场景 2:问法差异
用户嘴上说"这玩意儿咋整",心里想的是"服务重启操作步骤"。口语化提问和文档的书面语之间存在巨大的语义鸿沟,向量很难跨越。
场景 3:查询太短
"年假"两个字,向量化之后信息量太少,和任何文档的相似度都差不多,区分不开。
这三个问题分别对应三个解决方案:混合检索、Query 改写、重排序。
二、混合检索:向量不够,关键词来凑
核心思路
两条路同时走,结果再融合:
用户问题 "张三的报销单" │ ├──→ 向量检索(语义召回)→ Top 20 │ 能搜到:报销流程、财务制度…… │ └──→ 关键词检索(BM25)→ Top 20 能搜到:包含"张三"的文档 │ └──→ 融合算法 → Top 10 → 送入 LLM怎么实现?
如果你用的是Elasticsearch 8.x,一个查询搞定:
response=es.search(index="knowledge_base",body={"query":{"bool":{"should":[# 关键词匹配(BM25算法){"match":{"content":"张三 报销"}},]}},"knn":{"field":"content_vector","query_vector":query_embedding,"k":10,"num_candidates":100},"size":20})如果你用的是Milvus + ES 双库架构,需要自己做结果融合。最常用的融合算法叫RRF(Reciprocal Rank Fusion,倒数排名融合):
defrrf_fusion(vector_results,keyword_results,k=60):""" 倒数排名融合:每个文档的最终分数 = Σ(1 / (排名 + k)) 文档在两个列表中排名都靠前 → 分数高 → 排前面 """scores={}forrank,docinenumerate(vector_results):doc_id=doc["id"]scores[doc_id]=scores.get(doc_id,0)+1.0/(rank+k)forrank,docinenumerate(keyword_results):doc_id=doc["id"]scores[doc_id]=scores.get(doc_id,0)+1.0/(rank+k)# 按分数降序排列returnsorted(scores.items(),key=lambdax:x[1],reverse=True)[:10]k=60 是经验值,这个值越大,排名靠后的文档权重越低,越不容易干扰前面的结果(相当于是个平滑因子)。
三、Query 改写:帮用户把话说明白
用户不是搜索引擎专家,他们的提问常常是模糊的、口语化的甚至语焉不详的。
方案:用 LLM 改写 Query
在检索之前,先让一个小模型把用户的原始问题"翻译"成检索友好的查询:
query_rewrite_prompt=""" 你是一个搜索查询优化助手。请将用户的口语化问题改写成更适合文档检索的关键词串。 规则: 1. 补充缩写词的全称(如 "ES" → "Elasticsearch") 2. 将口语转为书面语(如 "这玩意儿" → "这个功能") 3. 提取核心概念,去掉语气词 4. 生成 2-3 个不同角度的改写 用户问题:{question} 请输出(每行一个改写): """defrewrite_query(question):"""用 LLM 改写用户问题,生成多个搜索变体"""response=llm.invoke(query_rewrite_prompt.format(question=question))rewrites=[line.strip()forlineinresponse.split("\n")ifline.strip()]returnrewrites# 实际使用original="上次说的那个代码检查的是什么东西来着"rewrites=rewrite_query(original)# 输出:# ["代码检查工具 是什么", "Code Review 代码检查 工具 介绍", "代码静态分析 检查 说明"]# 用所有改写分别检索,合并去重all_results=[]forqinrewrites:q_vector=embedding_model.encode(q)results=vectorstore.search(q_vector,k=5)all_results.extend(results)Query 改写要控制成本
每次多调用一次 LLM,虽然可以用便宜的模型(比如 DeepSeek 轻量版或本地小模型),但如果你的 QPS 很高,改写开销不能忽视。建议对改写结果做缓存——同一个问题不需要每次重新改写。
四、重排序(Reranker):最后一公里的精度提升
向量检索返回的 Top-20 只是"大概相关"。真正相关的可能排在第 15 位。这时候需要一个更强的模型重新精排。
Reranker 是什么?
Reranker 是一个专门训练的排序模型,输入是(Query, Document)对,输出是相关性分数。它比 Embedding 模型的"距离计算"更精细——因为它同时看了 Query 和 Document 的完整内容,而不是分别 Embedding 后算距离。
代码实现
fromFlagEmbeddingimportFlagReranker# 加载中文最强的 Rerankerreranker=FlagReranker('BAAI/bge-reranker-v2-m3',use_fp16=True)defrerank(query,candidates,top_k=5):"""对候选文档重新排序,返回最相关的 top_k 个"""pairs=[[query,doc.page_content]fordocincandidates]scores=reranker.compute_score(pairs)# scores[i] 是 query 和 candidates[i] 的匹配分数# 按分数降序排列ranked=sorted(zip(candidates,scores),key=lambdax:x[1],reverse=True)return[docfordoc,_inranked[:top_k]]为什么 Reranker 效果更好?
Embedding 模型做的是"压缩"——把长文本压缩成一个固定长度的向量。这个过程不可避免地会丢失细粒度信息。但 Reranker 不需要压缩——它直接对(Query, Document)全文做 Cross-Attention(交叉注意力),Query 里的每个词都能和 Document 里的每个词做交互。
代价是:Reranker 比单纯的向量距离计算慢 10~50 倍,所以不能对全库做,只能对 Top-K 候选项精排。
五、三个策略怎么组合?我的实战流水线
把混合检索、Query 改写、重排序串联起来,就是一个完整的检索增强流水线:
classAdvancedRetriever:def__init__(self,vectorstore,es_client,reranker,llm):self.vectorstore=vectorstore self.es=es_client self.reranker=reranker self.llm=llmdefretrieve(self,question:str,filters:dict=None,top_k:int=5):# Step 1: Query 改写(生成 3 个变体)rewritten_queries=rewrite_query(question)# Step 2: 多路召回all_candidates=[]seen_ids=set()forqinrewritten_queries+[question]:# 所有改写 + 原文q_vec=self._embed(q)# 向量召回vec_results=self.vectorstore.search(q_vec,k=20,filter=filters)# BM25 关键词召回kw_results=self._keyword_search(q,k=20,filter=filters)# 用 RRF 融合两路结果fused=rrf_fusion(vec_results,kw_results)# 去重(同一个chunk可能被不同改写命中)fordoc_id,_infused:ifdoc_idnotinseen_ids:all_candidates.append(self._get_doc(doc_id))seen_ids.add(doc_id)# Step 3: 重排序(精排)final_docs=rerank(question,all_candidates,top_k=top_k)returnfinal_docs效果对比
我在同一份测试集上对比了四种检索策略:
策略 Recall@5 MRR 延迟(毫秒) ─────────────────────────────────────────────────────────── 纯向量检索 0.86 0.74 80ms 向量 + BM25 混合检索 0.91 0.80 120ms 混合 + Query 改写 0.93 0.84 350ms 混合 + Query 改写 + 重排序 0.96 0.88 480ms每增加一个策略,效果都提升,但延迟也在增加。480ms 对用户体验来说是可以接受的,但需要做好异步和超时控制。
六、调优的实战经验
- 不是所有问题都需要 Query 改写:对短查询(< 10 字)和口语化明显的做改写,长且清晰的问题直接检索,节省延迟。
- Reranker 的 top_k 不是越大越好:向量检索阶段取 20~30 个候选项给 Reranker 就够,取 100 个只会让精排耗时翻倍,但对效果提升很小。
- 缓存是调优的秘密武器:热门问题的检索结果缓存 5 分钟,能挡掉 20%~30% 的重复查询,相当于免费的性能提升。
- 日志比直觉靠谱:把每次检索的 Query、命中结果、用户反馈(点赞/点踩)打全日志,跑一个月的统计分析,比靠直觉调参准十倍。
💬 你有没有遇到过"明明文档里有答案但偏偏搜不出来"的情况?你是怎么调的?评论区交流!