RAG 检索增强生成:原理不复杂,难的是工程细节

RAG 的五个环节怎么串起来、分块策略怎么选、什么时候该加重排、以及那些「Demo 很好用、上线就翻车」的典型原因。

RAG(Retrieval-Augmented Generation)的原理一句话就能说完:先去知识库里检索相关内容,再让模型基于检索结果回答。

但真正做过的人都知道,Demo 一晚上就能跑通,上线却要调好几周。问题几乎从不出在「原理」上,而是出在分块、召回质量、以及各种意料之外的边角情况上。

为什么需要 RAG

大模型有三个绕不开的限制:

  1. 知识有截止日期,训练之后发生的事它不知道
  2. 不了解你的私有数据,公司内部文档、产品手册都不在训练集里
  3. 会一本正经地编,在没有依据时倾向于生成流畅但错误的答案

微调能缓解第 2 条,但代价高、更新慢。RAG 的思路更直接:不改模型,改给它看的上下文。

知识变了?更新索引就行,不用重新训练。

五个环节

离线(建索引):
  文档 → 切分 → 向量化 → 存入向量库

在线(回答问题):
  问题 → 向量化 → 召回 → (重排)→ 拼进提示词 → 生成

1. 切分(Chunking)

最容易被低估的一步。切得好不好,直接决定召回质量的上限。

为什么必须切? 三个原因:模型上下文有限;整篇文档塞进去噪音太大;向量表征在长文本上会被“平均”掉,检索精度下降。

常见策略:

策略 适用场景 注意
固定长度 + 重叠 通用起点 简单但会切断语义单元
按结构切 Markdown、代码、合同 利用标题层级,效果通常更好
语义切分 长篇文章 按语义相似度找断点,成本较高
小块检索 + 大块生成 高精度要求 用小块做召回,返回时带上它所在的大块

实践建议:从按结构切分开始,chunk 大小在 300 到 800 token 之间试,重叠留 10% 到 15%。

一个常见的坑是切分时丢掉了标题上下文。一个小节的内容如果脱离了它所属的章节标题,向量表征会变得非常模糊。把标题路径拼进每个 chunk 是个几乎没有成本、但收益明显的做法:

def build_chunk_text(title_path: list[str], body: str) -> str:
    """把标题路径拼到正文前面,给 chunk 补上上下文"""
    header = " > ".join(title_path)
    return f"{header}\n\n{body}"

2. 向量化(Embedding)

把文本映射成向量,语义相近的文本在向量空间里距离更近。

选型时关注三点:中文效果、维度与成本、最大输入长度(必须大于你的 chunk 大小,否则会被静默截断)。

注意:检索用的嵌入模型和生成用的 LLM 是两回事,可以独立选择。检索用小模型就够,没必要上大模型。

3. 召回(Retrieval)

最常用的是向量相似度检索(余弦相似度或内积)。但纯向量检索有个明显短板:对精确匹配不敏感。

用户搜「错误码 E1024」,向量检索可能返回一堆语义相关但没提到这个码的文档。这时候需要关键词检索(BM25)来补。

混合检索(Hybrid Search) 就是同时跑向量和 BM25,再用 RRF(Reciprocal Rank Fusion)之类的算法融合两路结果。这是目前性价比最高的一个改进:

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
    """
    把多路检索结果融合成一个排序。
    rankings: 每一路召回返回的 doc_id 有序列表
    """
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

4. 重排(Rerank)

召回阶段追求的是快,要在百万级文档里捞出几十条候选,所以用的是双塔模型(问题和文档分别编码,无法充分交互)。

重排阶段候选只有几十条,可以上交叉编码器(Cross-Encoder)——把问题和文档拼在一起送进模型,让它充分交互后打一个相关性分数。

代价是慢,但只在少量候选上跑,可以接受。召回 50 条 → 重排取前 5 条 是很常见的配置,效果提升通常比换更大的嵌入模型更明显。

5. 生成

把检索结果拼进提示词。关键是约束模型只用给定材料回答:

请仅根据下面的资料回答问题。如果资料中没有相关信息,
请直接回答"资料中未提及",不要自行推测。

<context>
{{RETRIEVED_CHUNKS}}
</context>

问题:{{QUESTION}}

同时建议要求模型标注引用来源(比如让它输出 [1] [2] 对应到具体 chunk)。这既方便用户核实,也让你能发现「模型在编」的情况。

上线后最常见的四类问题

检索不到。 先别怀疑模型,把召回结果打出来看。大多数时候是分块太碎或太大、嵌入模型不适合中文、或者问题里的关键词根本没出现在文档里(术语不一致)。

检索到了但答错。 通常是噪音太多——召回了 10 条,其中 7 条不相关,模型被带偏。解法是加阈值过滤或上重排。

答案是对的但用户不信。 缺引用。让模型输出出处,用户体验会明显改善。

多轮对话里检索失效。 用户的第二句话往往是「那它呢?」这种省略了主语的指代。直接用这句去检索必然失败。常见做法是先用 LLM 把问题改写成独立的完整问题,再拿改写后的问题去检索:

REWRITE_PROMPT = """根据对话历史,把用户的最新问题改写成一个
不依赖上下文、可以独立理解的完整问题。只输出改写后的问题。

对话历史:
{history}

最新问题:{question}
"""

这一步叫 Query Rewriting,在多轮场景里几乎是必需品。

怎么评估

没有评估就没法优化。最低成本的方案是攒一个评测集:

  • 准备 30 到 50 个真实问题
  • 每个标注出「正确的答案应该来自哪几个 chunk」
  • 分两段量:召回率(正确 chunk 有没有被召回)和答案质量(生成的是否正确)

这样当你调整分块大小或换嵌入模型时,能量化地知道是变好了还是变差了,而不是凭感觉。

如果要进一步,可以用 LLM 当裁判给答案打分,但要有心理准备:LLM 裁判本身也有偏差,最好人工抽查一部分校准。

小结

  • RAG = 检索 + 生成,原理简单,工程细节决定成败
  • 分块策略的收益常常大于换模型
  • 混合检索(向量 + BM25)是性价比最高的改进
  • 召回追求快、重排追求准,两阶段分工
  • 多轮对话必须做 Query Rewriting
  • 先建评测集,再谈优化

想把模型本身调得更贴合你的场景,可以看 LoRA 微调实战。