Skip to content

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 chunks

overlap(重叠)的作用:确保被切断的语义至少在相邻 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 参数的问题,答案总是不准"。

排查路径:

  1. 打印几条 API 参数问题的召回结果,发现召回的都是概念介绍,没有精确的参数说明。
  2. 看文档结构,发现参数说明集中在表格里,表格在切块时被拆散,每个 chunk 只有半张表,语义不完整。
  3. 改造切块策略:检测 Markdown 表格,把整张表作为一个 chunk,不做跨表格切割。
  4. 重建索引,再测,召回命中率明显提升。
  5. 仍然发现部分精确参数名(比如 max_completion_tokens)有时用向量检索找不到,因为词项太精确,加 BM25 混合检索。
  6. 效果稳定后,对几十条测试问题跑一遍 Recall@5,确认提升是真实的,不只是个例感受。

这是一个很典型的调优路径:从看召回开始,定位是分块问题,修分块,再补充混合检索,最后用指标确认。

常见面试考点

这一页对应的题目通常会追问“怎么把一个能跑的 RAG 系统调好”。准备时重点放在工程判断,而不是背术语:

  1. 查询扩展:它解决的是用户问法和文档写法不一致的问题,要能说清楚多问法检索、结果合并和额外延迟之间的取舍。
  2. 父子块策略:子块负责精准召回,父块负责给模型足够上下文;适合“定位准但回答缺上下文”的场景。
  3. Rerank 使用时机:候选集里有正确内容但排序靠后时再上 Rerank;如果召回本身不对,Rerank 解决不了根因。
  4. 效果评估指标:检索层看 Recall@k、命中率和召回片段质量,生成层再看答案是否忠实于资料和引用是否正确。
  5. 调优顺序:先看召回,再修分块和索引,然后考虑混合检索、查询扩展、Rerank,最后才调 prompt。

如果你已经把基础 RAG 跑通,建议直接结合 RAG 知识库项目做一轮调优实验:先用基础版本跑测试集,记录 Recall@k,然后逐步加入混合检索和 Rerank,对比每步的效果变化。能看到可量化的提升,调优才真正落地。

下一章:Agent 基础原理——RAG 解决的是单次"找资料再回答",而 Agent 要处理的是分步做事、根据中间结果决定下一步。

面向开发者的 AI 实战路线——Vibe Coding 与 AI 应用开发