RAG 检索增强生成:原理不复杂,难的是工程细节
RAG 的五个环节怎么串起来、分块策略怎么选、什么时候该加重排、以及那些「Demo 很好用、上线就翻车」的典型原因。
RAG(Retrieval-Augmented Generation)的原理一句话就能说完:先去知识库里检索相关内容,再让模型基于检索结果回答。
但真正做过的人都知道,Demo 一晚上就能跑通,上线却要调好几周。问题几乎从不出在「原理」上,而是出在分块、召回质量、以及各种意料之外的边角情况上。
为什么需要 RAG
大模型有三个绕不开的限制:
- 知识有截止日期,训练之后发生的事它不知道
- 不了解你的私有数据,公司内部文档、产品手册都不在训练集里
- 会一本正经地编,在没有依据时倾向于生成流畅但错误的答案
微调能缓解第 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 微调实战。