Appearance
RAG 进阶优化
在基础 RAG 管道之上,通过混合检索、Rerank、查询扩展和分块策略优化检索质量,是工程落地的关键。本页系统梳理各类优化手段的原理与适用场景。
这章接着 RAG 原理讲什么
RAG 原理把完整链路讲清楚了:建库、检索、上下文拼装、生成、引用。但"链路能跑起来"和"链路真的好用"之间通常有一段距离。
我一开始做 RAG 系统的时候发现一件事:用几十条文档测试时效果还不错,但资料一多、问题一复杂,检索质量就开始明显下滑。原因五花八门:有的是 chunk 切断了句子,语义分散了;有的是用户问法和文档写法差太多,向量相似度上不去;有的是召回了三段,最关键那段恰好排在 top-3 之外。
这些问题不是换一个更好的模型能解决的,也不是改改 prompt 能救回来。它们几乎都发生在检索层。
这章的目标就是系统讲清楚:RAG 效果不好的时候,该从哪几个方向入手。
先确认问题在哪一层
RAG 系统调优有一个常见的反模式:效果不好,先去改 prompt。
这通常是在浪费时间。RAG 的问题大多出在检索层,生成层的 prompt 再强,也救不了"召回内容就错了"这个根本问题。
排查时先把召回的 chunk 打印出来看一眼:
- 问题的答案在不在召回的片段里?
- 如果在,是不是被切断了、不完整?
- 如果不在,是文档没收录,还是切块方式把相关内容打散了?
大多数时候,这一步就能定位问题。答案在召回里但生成跑偏了,才是 prompt 层的问题。
分块策略
分块(Chunking)是建库阶段最容易被低估的环节。切不好,后面所有优化都是在补漏洞。
固定大小切块是最简单的方式,按字数或 token 数切,相邻块之间有一定重叠。实现简单,但容易切断句子和段落,导致某个 chunk 的开头或结尾缺少上下文。
python
def chunk_text(text: str, chunk_size: int = 300, overlap: int = 50) -> list[str]:
tokens = text.split() # 简化示例,实际应按 token 切
chunks = []
i = 0
while i < len(tokens):
chunk = tokens[i:i + chunk_size]
chunks.append(' '.join(chunk))
i += chunk_size - overlap
return chunksoverlap(重叠)的作用:确保被切断的语义至少在相邻 chunk 里各出现一次,减少"重要信息刚好在边界上"导致的召回漏失。一般设为 chunk_size 的 10-20%。
语义切块是更精细的方式——按段落、标题层级或语义边界切,不强求每块大小一样。好处是每个 chunk 内部语义更完整,坏处是实现复杂,不同文档格式差异很大。
**父子块(Parent-Child Chunks)**是一种折中方案,在工程实践里非常实用:
把文档切成两个粒度:细粒度的"子块"用来做向量检索(更精准地找到相关位置),粗粒度的"父块"用来传给模型(包含更多上下文)。
python
# 建库时存两层
def build_parent_child_index(text: str):
# 父块:较大粒度(如整段)
parent_chunks = split_by_paragraph(text)
for i, parent in enumerate(parent_chunks):
# 子块:较小粒度(如每段里的若干句)
child_chunks = split_into_sentences(parent)
for j, child in enumerate(child_chunks):
store(
id=f"child_{i}_{j}",
text=child,
embedding=embed(child),
parent_id=f"parent_{i}"
)
store_parent(id=f"parent_{i}", text=parent)
# 查询时:用子块检索,返回父块
def query(question: str, top_k: int = 5) -> list[str]:
child_results = vector_search(embed(question), top_k=top_k)
parent_ids = {r.parent_id for r in child_results}
return [get_parent(pid) for pid in parent_ids]这样做的好处:向量检索在语义精准的小块上找位置,但最终送给模型的是含有足够上下文的大块,两个目标都能兼顾。
选哪种策略的判断思路:
- 如果文档结构清晰(有标题层级、段落分明),优先语义切块
- 如果文档结构混乱(PDF 扫描、表格、嵌入代码),固定大小 + 合理 overlap 是更稳的保底
- 如果召回精准但模型生成时上下文不够,父子块是值得考虑的方向
混合检索
纯向量检索有一个弱点:它靠"语义相似度",对精确词项不太敏感。你问"gpt-4o-mini 的最大 token 限制是多少",向量检索可能返回一堆泛泛讨论 token 限制的内容,而不是直接命中带数字的那一段。
关键词检索(BM25)相反,擅长精确词项匹配,但对"用户问法和文档写法不一样"的情况很弱。
混合检索把两者结合:分别用向量检索和 BM25 检索,再把结果合并排序。常用的合并方式是 RRF(Reciprocal Rank Fusion,倒数排名融合):
python
def reciprocal_rank_fusion(results_list: list[list], k: int = 60) -> list:
"""
results_list: 多个检索结果列表,每个列表按相关性排序
k: RRF 参数,通常取 60
"""
scores = {}
for results in results_list:
for rank, doc_id in enumerate(results):
if doc_id not in scores:
scores[doc_id] = 0
scores[doc_id] += 1 / (k + rank + 1)
return sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
# 使用
vector_results = vector_search(embed(query), top_k=10)
bm25_results = bm25_search(query, top_k=10)
# 提取 id 列表
merged = reciprocal_rank_fusion([
[r.id for r in vector_results],
[r.id for r in bm25_results]
])
final_docs = [get_doc(id) for id in merged[:5]]RRF 的好处是不需要对两个检索结果的分数做归一化(分数量纲不同,直接合并没意义),只用排名做加权,简单可靠。
什么时候考虑混合检索:
- 文档里有大量精确术语(产品名、API 名、版本号)
- 用户问题经常包含精确匹配词
- 纯向量检索的召回结果里,能看出语义相关但关键词对不上的情况
如果你的知识库是一份技术文档,混合检索通常比纯向量检索有明显提升。
查询扩展与 HyDE
用户输入的问题和文档的写法往往差很多。用户问"这个功能怎么打开",文档里可能写的是"启用 X 功能的配置步骤"。这种问法差异会让向量相似度下降,召回质量随之下降。
查询扩展的思路:把一个问题扩展成多个角度的问题,分别检索,结果合并。
python
async def expand_query(question: str) -> list[str]:
response = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""把下面这个问题改写成 3 个不同说法,
保持原意,用不同措辞表达,每行一个:
{question}
只输出改写结果,不要解释:"""
}]
)
variations = response.choices[0].message.content.strip().split('\n')
return [question] + [v.strip() for v in variations if v.strip()]
# 多问法检索,合并去重
async def expanded_search(question: str, top_k: int = 5) -> list:
queries = await expand_query(question)
all_results = []
for q in queries:
results = vector_search(embed(q), top_k=top_k)
all_results.extend(results)
# 按 id 去重,保留得分最高的
seen = {}
for r in all_results:
if r.id not in seen or r.score > seen[r.id].score:
seen[r.id] = r
return sorted(seen.values(), key=lambda x: x.score, reverse=True)[:top_k]**HyDE(Hypothetical Document Embeddings)**是一种更进一步的思路:先让模型根据问题生成一段"假想的答案文档",再用这段假想文档去做向量检索。逻辑是:假想文档的向量比问题本身的向量更接近真实文档的分布。
python
async def hyde_search(question: str, top_k: int = 5) -> list:
# 生成假想文档
response = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"假设你需要写一段文档来回答这个问题,写出核心内容(100 字左右):\n\n{question}"
}]
)
hypothetical_doc = response.choices[0].message.content
# 用假想文档做检索
return vector_search(embed(hypothetical_doc), top_k=top_k)HyDE 在问题和文档写法差异很大时效果比较明显,代价是多一次模型调用,延迟增加。如果你的检索场景对延迟敏感,先试查询扩展,HyDE 作为进一步的选项。
Rerank
向量检索返回 top-k 的结果,这 k 个结果按向量相似度排序。但向量相似度是一种近似度量,不能完全代表"这段内容对当前问题有多有用"。
Rerank(重排序)在向量检索之后做第二次更精细的排序:用一个 Cross-Encoder 模型,对每个候选 chunk 和原始问题重新做相关性打分,再按新分数排序。
Cross-Encoder 和 Embedding 的区别:Embedding 把问题和文档各自独立编码(效率高,适合大规模检索),Cross-Encoder 把问题和文档一起编码(更慢,但相关性判断更准确)。Rerank 阶段候选集已经很小(通常只有 10-20 条),Cross-Encoder 的精度优势能发挥出来,额外延迟也可接受。
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('BAAI/bge-reranker-v2-m3')
def rerank_results(question: str, candidates: list[dict], top_n: int = 3) -> list[dict]:
"""
candidates: [{"id": ..., "text": ...}, ...]
"""
pairs = [(question, c["text"]) for c in candidates]
scores = reranker.predict(pairs)
scored = list(zip(candidates, scores))
scored.sort(key=lambda x: x[1], reverse=True)
return [item for item, _ in scored[:top_n]]
# 完整流程:向量检索 top-15,Rerank 选 top-3
candidates = vector_search(embed(question), top_k=15)
final_chunks = rerank_results(question, candidates, top_n=3)常用的开源 Reranker 是 BGE 系列(BAAI/bge-reranker-*),中英文效果都不错。如果不想本地部署模型,Cohere 的 Rerank API 也是常见选择。
什么时候上 Rerank:
- 向量检索 top-k 里能看到正确答案,但排名靠后,没进入最终发给模型的 top-3
- 对精确度要求高,宁可慢一点也要排序更准
- 文档数量较大,向量检索的召回质量已经稳定,瓶颈转移到精排上
如果向量检索召回的内容本身就不对,Rerank 解决不了根本问题——先修检索,再加 Rerank。
提示压缩
当召回的内容太多,或者原始 chunk 很长,全部塞进 prompt 会增加 token 成本,也可能稀释模型注意力。提示压缩是一种中间处理步骤:在检索后、生成前,把召回内容压缩成更精简的形式。
最简单的提示压缩:让模型对每个 chunk 提取关键信息:
python
async def compress_chunk(question: str, chunk: str) -> str:
response = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""从下面这段资料中,提取和问题 "{question}" 相关的关键信息。
如果没有相关信息,回复"无相关信息"。
只输出提取结果,不要解释:
{chunk}"""
}]
)
return response.choices[0].message.content专门做提示压缩的工具有 LLMLingua 等,对召回结果做 token 级别的压缩,保留高相关性 token,去掉低相关性 token,压缩率可以到 4-6 倍。
注意:压缩有信息损失的风险。如果原始 chunk 本身已经很精准,压缩反而可能丢失细节。提示压缩更适合"召回内容质量参差、总量很大"的场景,而不是精准检索后的精炼 top-3。
调优顺序建议
面对一个 RAG 系统效果不理想,按这个顺序排查和优化,别一开始就堆所有技术:
第一步:先看召回
把每次查询的 top-k 打印出来,手动判断答案在不在里面。不在,分清楚是文档没有还是切块有问题。
第二步:优化分块
如果答案在文档里但没被召回,通常是 chunk 切断了相关语义。调整 chunk_size、overlap,或者改用语义切块。父子块也是这一阶段可以尝试的方向。
第三步:加混合检索
如果向量召回不稳定,有精确词项匹配的需求,加 BM25 做混合检索。
第四步:加查询扩展或 HyDE
如果用户问法和文档写法差异大,加查询扩展或 HyDE。
第五步:加 Rerank
答案在召回里但排名靠后时,加 Rerank 做精排。
最后:才调生成层 prompt
到这一步说明检索层已经稳了,剩下的问题是生成层的约束不够强。加强"不要在资料之外发挥"的 prompt 约束,或者收紧引用格式要求。
这个顺序不是绝对的,但它避免了最常见的错误:检索层还有明显问题,却一直在改 prompt。
效果评测
调优不能光靠主观感受。每个优化步骤做完,要有可比较的指标:
检索层评测:准备一批带标准答案的测试问题,对每个问题检查"正确答案的 chunk 是否出现在 top-k 里",统计召回率(Recall@k)。这个指标反映检索层有没有漏掉关键信息。
生成层评测:检查生成回答和参考答案的一致性,或者用另一个模型对生成结果打分(如相关性、准确性、引用是否使用正确)。
AI 应用评测章节里有更系统的评测方法。RAG 评测的重点是把两层分开评——召回好不等于生成好,把两个维度混在一起评,出了问题根本不知道该修哪里。
一个调优场景串起来
假设你在做一个技术文档问答系统,用户频繁反映"问 API 参数的问题,答案总是不准"。
排查路径:
- 打印几条 API 参数问题的召回结果,发现召回的都是概念介绍,没有精确的参数说明。
- 看文档结构,发现参数说明集中在表格里,表格在切块时被拆散,每个 chunk 只有半张表,语义不完整。
- 改造切块策略:检测 Markdown 表格,把整张表作为一个 chunk,不做跨表格切割。
- 重建索引,再测,召回命中率明显提升。
- 仍然发现部分精确参数名(比如
max_completion_tokens)有时用向量检索找不到,因为词项太精确,加 BM25 混合检索。 - 效果稳定后,对几十条测试问题跑一遍 Recall@5,确认提升是真实的,不只是个例感受。
这是一个很典型的调优路径:从看召回开始,定位是分块问题,修分块,再补充混合检索,最后用指标确认。
常见面试考点
这一页对应的题目通常会追问“怎么把一个能跑的 RAG 系统调好”。准备时重点放在工程判断,而不是背术语:
- 查询扩展:它解决的是用户问法和文档写法不一致的问题,要能说清楚多问法检索、结果合并和额外延迟之间的取舍。
- 父子块策略:子块负责精准召回,父块负责给模型足够上下文;适合“定位准但回答缺上下文”的场景。
- Rerank 使用时机:候选集里有正确内容但排序靠后时再上 Rerank;如果召回本身不对,Rerank 解决不了根因。
- 效果评估指标:检索层看 Recall@k、命中率和召回片段质量,生成层再看答案是否忠实于资料和引用是否正确。
- 调优顺序:先看召回,再修分块和索引,然后考虑混合检索、查询扩展、Rerank,最后才调 prompt。
如果你已经把基础 RAG 跑通,建议直接结合 RAG 知识库项目做一轮调优实验:先用基础版本跑测试集,记录 Recall@k,然后逐步加入混合检索和 Rerank,对比每步的效果变化。能看到可量化的提升,调优才真正落地。
下一章:Agent 基础原理——RAG 解决的是单次"找资料再回答",而 Agent 要处理的是分步做事、根据中间结果决定下一步。