RAG检索优化实战:从67%到92%,我做了这4步
上一篇《RAG 核心技术实战:文档解析、分块策略与检索 Pipeline》把文档解析、分块策略、Embedding 选型都做完了,Pipeline 也能跑了。
但跑起来你会发现一个尴尬的事实:检索效果跟随机猜差不多。
用户问"差旅报销标准是多少",你捞回来的 chunk 讲的是"差旅费用管理制度的制定背景",语义相关,但根本不是答案。用户问"ISO-27001认证流程",向量检索把它和"信息安全管理体系概述"拉到一起,反而漏掉了写具体流程的那页。
这不是你代码写得不好,是纯向量检索的天生缺陷。这篇就专门解决这个问题:怎么把检索 Hit Rate 从 67% 拉到 92%。
先建评估体系,别盲调
大部分团队搭好 RAG 后随便问几个问题,觉得"还行"就上线了。线上出问题改改 prompt,再测几个,感觉好一点,收工。
这不叫优化,叫盲调。你连当前系统在什么水平都不知道,怎么判断改动是改进还是退化?
三个核心指标
| 指标 | 含义 | 目标值 |
|---|---|---|
| Hit Rate@5 | Top-5 中命中正确 chunk 的比例 | > 85% |
| MRR | 第一个正确结果排名的倒数均值 | > 0.75 |
| Context Relevance | LLM 评估上下文有用性(可选) | > 0.8 |
Hit Rate@K:top-K 检索结果中是否包含正确答案所在的 chunk。Hit Rate@5 = 0.67 意味着 67% 的 query 在 top-5 里命中了正确 chunk。
MRR(Mean Reciprocal Rank):第一个正确结果排名的倒数的平均。排第1位贡献 1.0,第3位贡献 0.33。比 Hit Rate 更严格,不仅要求召回,还要求排到前面。
Context Relevance:检索结果对回答的有用程度,通常需要 LLM 辅助打分。标注成本高,前两个指标够用就先不上。
构建评估集:50 条标注 query
- 从真实用户日志或业务场景构造 query,确保分布和线上一致
- 每个 query 人工标注正确答案所在的 chunk ID
- 一定要放难召回的、表述模糊的 query,不然评估效果会虚高
50 条够看出趋势。后续要精细 A/B 对比再扩到 100-200 条。
评估代码
python
# evaluate.py - RAG 检索质量评估:Hit Rate@K 和 MRR
import json
from typing import List, Set
from dataclasses import dataclass
@dataclass
class EvalQuery:
query_id: str
query: str
relevant_chunk_ids: Set[str] # 人工标注的正确 chunk ID
def calculate_hit_rate(eval_queries, retrieval_results, k=5):
"""计算 Hit Rate@K:top-K 中是否包含正确 chunk"""
qid_to_relevant = {q.query_id: q.relevant_chunk_ids for q in eval_queries}
hits = 0
for result in retrieval_results:
if result.query_id not in qid_to_relevant:
continue
top_k = set(result.retrieved_chunk_ids[:k])
if top_k & qid_to_relevant[result.query_id]:
hits += 1
return hits / len(eval_queries)
def calculate_mrr(eval_queries, retrieval_results):
"""计算 MRR:第一个正确结果排名倒数的平均"""
qid_to_relevant = {q.query_id: q.relevant_chunk_ids for q in eval_queries}
rr_sum = 0
for result in retrieval_results:
if result.query_id not in qid_to_relevant:
continue
for rank, cid in enumerate(result.retrieved_chunk_ids, 1):
if cid in qid_to_relevant[result.query_id]:
rr_sum += 1.0 / rank
break
return rr_sum / len(eval_queries)
def run_evaluation(eval_queries, retrieval_fn, k=5):
"""完整评估:传入检索函数,返回指标字典"""
results = []
for q in eval_queries:
chunk_ids = retrieval_fn(q.query)
results.append(type('R', (), {'query_id': q.query_id, 'retrieved_chunk_ids': chunk_ids}))
return {
f"hit_rate@{k}": round(calculate_hit_rate(eval_queries, results, k), 4),
"mrr": round(calculate_mrr(eval_queries, results), 4),
}
有了评估体系,每步优化都有量化反馈。
Baseline:纯向量检索能拿多少分
在 50 条评估集上,Baseline 表现:
| 指标 | 纯向量检索 |
|---|---|
| Hit Rate@5 | 67% |
| Hit Rate@10 | 78% |
| MRR | 0.52 |
纯向量检索有几个典型问题:
语义相似不等于相关。 用户问"差旅报销标准",向量检索可能召回"差旅费用管理制度的制定背景",语义接近但没有具体数字。
关键词漏召。 用户搜"ISO-27001认证",如果文档写的是"信息安全管理体系认证",Embedding 可能匹配不上。反过来文档里写了"ISO-27001"这个编号,向量检索也可能漏掉。
排序不准。 top-5 的相似度分数差不到 0.02,但第一个是正确答案,第五个完全不相关。
排查 17 个 miss 的 case:7 个是语义相似但非答案,5 个是关键词漏召,3 个是 chunk 切分问题(归因数据预处理),2 个是 Embedding 编码问题。12 个是检索可以改善的,这就是接下来的发力点。
第一步:混合检索(BM25 + 向量)
BM25 是经典词项检索算法,优势恰好补上向量检索的短板:精确关键词匹配、专有名词处理、短 query 匹配。反过来 BM25 不懂同义词和语义相似。两个方法互补性很强。
两路结果怎么合并?用 RRF(Reciprocal Rank Fusion):
scss
RRF 融合公式:score(d) = Σ 1 / (k + rank_i(d))
d = 候选文档
rank_i(d) = 文档 d 在第 i 个检索系统中的排名
k = 平滑常数,通常取 60
只看排名不看分数,不需要归一化,比加权融合省心得多。加权融合要处理两个不同量纲的分数(BM25 可以到几十,余弦相似度是 -1 到 1),α 的最优值依赖数据和归一化方法,调起来不稳定。RRF 直接绕过了这些问题。
python
# hybrid_retriever.py - 混合检索:BM25 + 向量 + RRF 融合
from rank_bm25 import BM25Okapi
import jieba # 中文分词
class BM25Retriever:
"""BM25 检索器(rank_bm25 版,生产环境建议用 Elasticsearch)"""
def __init__(self, chunks):
self.chunks = chunks
self.chunk_ids = [c["chunk_id"] for c in chunks]
# 中文分词后构建索引
self.bm25 = BM25Okapi([list(jieba.cut(c["text"])) for c in chunks])
def search(self, query, top_k=10):
tokenized = list(jieba.cut(query))
scores = self.bm25.get_scores(tokenized)
top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [{"chunk_id": self.chunk_ids[i], "score": scores[i], "text": self.chunks[i]["text"]}
for i in top_idx]
class HybridRetriever:
"""
混合检索器:BM25 + 向量,RRF 融合
RRF 公式:score(d) = Σ 1/(rank_i(d) + 60)
只看排名不看分数,不需要归一化,省心
"""
def __init__(self, bm25_retriever, vector_retriever, rrf_k=60):
self.bm25 = bm25_retriever
self.vector = vector_retriever
self.rrf_k = rrf_k
def search(self, query, top_k=10):
candidate_k = max(top_k * 3, 20)
bm25_results = self.bm25.search(query, top_k=candidate_k)
vector_results = self.vector.search(query, top_k=candidate_k)
# RRF 融合:按排名算分,合并两路结果
rrf_scores = {}
chunk_map = {}
for rank, r in enumerate(bm25_results, 1):
cid = r["chunk_id"]
rrf_scores[cid] = rrf_scores.get(cid, 0) + 1.0 / (rank + self.rrf_k)
chunk_map[cid] = r
for rank, r in enumerate(vector_results, 1):
cid = r["chunk_id"]
rrf_scores[cid] = rrf_scores.get(cid, 0) + 1.0 / (rank + self.rrf_k)
if cid not in chunk_map:
chunk_map[cid] = r
sorted_ids = sorted(rrf_scores, key=lambda c: rrf_scores[c], reverse=True)[:top_k]
return [{**chunk_map[cid], "score": rrf_scores[cid]} for cid in sorted_ids]
为什么用 RRF?上面解释过了,这里不再重复。
上了混合检索,重新跑评估集:
| 指标 | Baseline | 混合检索 | 提升 |
|---|---|---|---|
| Hit Rate@5 | 67% | 78% | +11pp |
| MRR | 0.52 | 0.63 | +0.11 |
11 个百分点的提升,这是性价比最高的一步。12 个检索 bad case 修了 7 个。
第二步:重排序(Reranking)
混合检索解决了召回,但排序还在。BM25 和向量检索都是 Bi-Encoder,Cross-Encoder 能补上这个缺口:
| 维度 | Bi-Encoder(向量检索) | Cross-Encoder(Reranker) |
|---|---|---|
| 编码方式 | Query 和 Doc 分开编码 | Query 和 Doc 拼接后一起编码 |
| Token 交互 | 无 | Attention 层全交互 |
| 速度 | 快(可离线建索引) | 慢(无法离线,只能重排) |
| 精度 | 一般 | 更高 |
| 适用环节 | 全库召回 | 对候选集精排 |
所以典型的做法是两阶段:Bi-Encoder 从全库召回 top-50,Cross-Encoder 对这 50 个重新排序,取 top-5。
python
# reranker.py - 基于 BGE-Reranker-v2-M3 的重排序
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
class BGEReranker:
"""Cross-Encoder 重排序器"""
def __init__(self, model_name="BAAI/bge-reranker-v2-m3"):
device = "cuda" if torch.cuda.is_available() else "cpu"
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSequenceClassification.from_pretrained(model_name).to(device).eval()
self.device = device
def rerank(self, query, candidates, top_k=5):
"""对候选结果重新排序"""
if not candidates:
return []
# query 和每个 chunk 拼接后送入模型
pairs = [[query, c["text"]] for c in candidates]
with torch.no_grad():
inputs = self.tokenizer(
pairs, padding=True, truncation=True,
max_length=512, return_tensors="pt",
).to(self.device)
scores = self.model(**inputs).logits.squeeze(-1).cpu().numpy()
for i, c in enumerate(candidates):
c["rerank_score"] = float(scores[i])
return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]
# 完整检索流程:混合检索召回 50 条 -> 重排序取 top-5
class RAGRetriever:
def __init__(self, hybrid_retriever, reranker, first_stage_k=50, final_k=5):
self.hybrid = hybrid_retriever
self.reranker = reranker
self.first_stage_k = first_stage_k
self.final_k = final_k
def search(self, query):
candidates = self.hybrid.search(query, top_k=self.first_stage_k)
return self.reranker.rerank(query, candidates, top_k=self.final_k)
重排序模型选型:有 GPU 或 CPU 延延时可接受,用 BGE-Reranker-v2-M3(中英文都好,开源免费)。不想部署就用 Cohere Rerank API。先跑通评估再决定要不要换。
加上重排序后的表现:
| 指标 | 混合检索 | +Reranking | 提升 |
|---|---|---|---|
| Hit Rate@5 | 78% | 88% | +10pp |
| MRR | 0.63 | 0.79 | +0.16 |
MRR 提升(+0.16)比 Hit Rate 提升(+10pp)更显著。重排序的核心价值不是召回更多,而是把正确结果从靠后位置提到前面。
第三步:查询改写(Query Transformation)
前面两步改检索侧,但有时候问题出在 query 本身。用户问"怎么报销差旅费",文档写"员工出差费用结算流程",BM25 匹配不上,向量相似度也不高。
三种策略:
Query Expansion:同义词扩展,"年假"扩展成"年假 年休假 带薪休假"。简单但容易引噪声。
Multi-Query:让 LLM 从多个角度重写 query,每个都去检索,RRF 融合结果。
HyDE:让 LLM 先生成一个假设答案,用假设答案的 Embedding 去检索。把 query"翻译"成 document 的语言,缩小分布差异。
python
# query_transformer.py - 查询改写:Multi-Query + HyDE
import json
class QueryTransformer:
def __init__(self, llm_client, model="gpt-4o-mini"):
self.llm = llm_client
self.model = model
def multi_query(self, query, num_queries=3):
"""Multi-Query:让 LLM 多角度重写 query"""
prompt = f"""将以下查询从不同角度重写 {num_queries} 次,保持语义不变。
原始查询:{query}
只返回 JSON:{{"queries": ["重写1", "重写2", ...]}}"""
resp = self.llm.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
)
return json.loads(resp.choices[0].message.content)["queries"]
def hyde(self, query, doc_length=200):
"""HyDE:生成假设文档,用它的 Embedding 去检索"""
prompt = f"""根据以下问题,写一段 {doc_length} 字的假设性文档。
看起来像知识库里可能有的内容,不需要完全准确。
问题:{query}
直接返回文档内容。"""
resp = self.llm.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.5,
)
return resp.choices[0].message.content.strip()
class MultiQueryRetriever:
"""
Multi-Query 检索器:重写 query -> 多路检索 -> RRF 融合 -> 重排序
"""
def __init__(self, hybrid_retriever, transformer, reranker=None):
self.hybrid = hybrid_retriever
self.transformer = transformer
self.reranker = reranker
def search(self, query, top_k=5, num_rewrites=3, use_hyde=False):
if use_hyde:
# HyDE:向量检索用假设文档,BM25 用原始 query
hyde_doc = self.transformer.hyde(query)
vector_results = self.hybrid.vector.search(hyde_doc, top_k=30)
bm25_results = self.hybrid.bm25.search(query, top_k=30)
# RRF 融合两路结果
all_result_sets = [bm25_results, vector_results]
else:
# Multi-Query:原始 + 重写 query 分别走混合检索
rewritten = self.transformer.multi_query(query, num_rewrites)
all_queries = [query] + rewritten
all_result_sets = [self.hybrid.search(q, top_k=30) for q in all_queries]
# RRF 融合所有检索结果
rrf_scores = {}
chunk_map = {}
for results in all_result_sets:
for rank, r in enumerate(results, 1):
cid = r["chunk_id"]
rrf_scores[cid] = rrf_scores.get(cid, 0) + 1.0 / (rank + 60)
if cid not in chunk_map:
chunk_map[cid] = r
sorted_ids = sorted(rrf_scores, key=lambda c: rrf_scores[c], reverse=True)[:50]
candidates = [{**chunk_map[cid], "score": rrf_scores[cid]} for cid in sorted_ids]
if self.reranker:
return self.reranker.rerank(query, candidates, top_k=top_k)
return candidates[:top_k]
各策略的对比数据:
| 策略 | Hit Rate@5 | MRR | LLM 调用次数 |
|---|---|---|---|
| 混合检索+Reranking | 88% | 0.79 | 0 |
| + Query Expansion | 89% | 0.80 | 1 |
| + Multi-Query | 91% | 0.83 | 1 |
| + HyDE | 90% | 0.82 | 1 |
| + Multi-Query + HyDE | 92% | 0.85 | 2 |
HyDE 的风险要讲透。用户问"公司离职补偿怎么算",HyDE 生成一篇引用《劳动合同法》第四十七条的假设文档,检索效果很好。但如果用户问的是模糊问题"那个赔偿的事情怎么处理",HyDE 可能生成一篇关于"交通事故赔偿"的假设文档,完全跑偏。
结论:用户问题越具体,HyDE 效果越好。模糊问题用 Multi-Query 更稳。
第四步:上下文处理与生成优化
检索到 92% Hit Rate 了,但直接把 chunk 塞给 LLM 还会出问题:不相关 chunk 干扰生成、Lost in the Middle(LLM 对长上下文中间内容注意力低)。
python
# context_processor.py - 上下文处理:去重、重排、Prompt 构造
import numpy as np
class ContextProcessor:
"""检索结果送入 LLM 前的预处理"""
def __init__(self, dedup_threshold=0.90, max_chunks=5, max_length=3000):
self.dedup_threshold = dedup_threshold
self.max_chunks = max_chunks
self.max_length = max_length
def deduplicate(self, chunks, embedding_model):
"""基于 Embedding 相似度去重"""
if len(chunks) <= 1:
return chunks
texts = [c["text"] for c in chunks]
emb = embedding_model.encode(texts)["dense_vecs"]
emb = emb / (np.linalg.norm(emb, axis=1, keepdims=True) + 1e-8)
kept, removed = [], set()
for i in range(len(chunks)):
if i in removed:
continue
kept.append(chunks[i])
for j in range(i + 1, len(chunks)):
if j not in removed and np.dot(emb[i], emb[j]) > self.dedup_threshold:
removed.add(j)
return kept
def reorder_for_attention(self, chunks):
"""重排缓解 Lost in the Middle:最相关的放两端"""
if len(chunks) <= 2:
return chunks
left, right = [], []
for i, c in enumerate(chunks):
(left if i % 2 == 0 else right).append(c)
return left + right[::-1]
def truncate(self, chunks):
"""控制总长度"""
total, result = 0, []
for c in chunks:
if total + len(c["text"]) > self.max_length:
remaining = self.max_length - total
if remaining > 100:
result.append({**c, "text": c["text"][:remaining] + "..."})
break
result.append(c)
total += len(c["text"])
return result
def build_rag_prompt(query, chunks):
"""构造 Grounding prompt:限定基于上下文回答 + 引用来源"""
context = "\n\n".join(
f"[文档{i+1}]\n{c['text'][:500]}" for i, c in enumerate(chunks)
)
return f"""你是知识库问答助手。根据以下文档回答问题。
要求:
1. 只能基于以下文档内容回答
2. 文档不足以回答时,明确说"根据现有资料,我无法回答"
3. 标注信息来源,格式 [文档X]
4. 保持准确、简洁
检索到的文档:
{context}
用户问题:{query}
回答:"""
上下文处理对检索指标没贡献,但答案准确率从约 83% 提升到约 88%。Grounding 指令和引用要求能显著减少幻觉。
优化效果汇总
| 阶段 | Hit Rate@5 | MRR | 答案准确率 | 增量提升 |
|---|---|---|---|---|
| Baseline(纯向量) | 67% | 0.52 | ~60% | - |
| + 混合检索 | 78% | 0.63 | ~70% | +11pp |
| + 重排序 | 88% | 0.79 | ~80% | +10pp |
| + 查询改写 | 92% | 0.85 | ~83% | +4pp |
| + 上下文处理 | 92% | 0.85 | ~88% | +0pp(生成+5pp) |
边际收益递减很清楚。混合检索和重排序是必做项,21 个百分点的提升,实现不复杂。查询改写是选做项,效果好但增加延迟。上下文处理对检索指标无贡献但对生成质量有贡献,成本低建议总是做。
一句话总结:混合检索和重排序是必做项,21 个百分点的提升,实现不复杂。查询改写是选做项,效果好但增加延迟,需评估 tradeoff。
markdown
优化路径选择:
1. 建评估集(50条标注 query)------没有评估就不要开始
2. 加混合检索(BM25+向量+RRF)------预期 +10pp,复杂度低
3. 加重排序(Cross-Encoder)------预期 +10pp,复杂度中
4. 加查询改写(Multi-Query/HyDE)------预期 +3~5pp,需 LLM 调用
5. 上下文处理(去重+重排+Prompt)------答案准确率 +3~5pp,复杂度低
6. 持续迭代:扩大评估集,定期回归测试,A/B 测试上线
生产环境的坑
检索失败兜底:92% Hit Rate 意味着还有 8% 检索不到。最高分低于阈值时直接告诉用户"没找到相关内容",比产生幻觉好。阈值看评估集的分数分布定。
离线评估不等于线上效果:50 条评估集只是信号反馈。上线必须做 A/B 测试,看用户行为指标(点赞/踩、追问、复制行为)。有时候离线指标提升了,在线反而下降。
延迟权衡:Multi-Query + HyDE 两次 LLM 调用就要 1-3 秒,重排序 200-800ms,生成 1-3 秒,总计可能 4-5 秒。优化方向:查询改写用小模型、重排序减少候选数、多路并行检索、流式输出。
持续优化:线上 bad case 加到评估集里,定期回归测试。每次改检索策略都跑完整评估集,确保不退化。
总结
回顾优化路径:建评估体系(量化问题)-> 混合检索(解决召回)-> 重排序(解决排序)-> 查询改写(解决 query-document mismatch)-> 上下文处理(解决生成质量)。每步解决的问题不同,互相补充。
下一篇(第四篇)会聊给传统 SaaS 增加 AI 能力的三个真实落地场景。检索优化讲完了,接下来看 RAG 和 AI 能力怎么真正融入业务系统。
我们下篇见。