RAG 实战
RAG 的精髓不是「检索」,而是「证据链」——答案的每句话都能指回原文片段。从解析清洗到混合检索、Rerank、引用校验与评估,一篇讲透整条流水线的工程细节。
开场:把闭卷考试改成开卷考试
LLM 直接回答问题是「闭卷考试」:全靠训练时记住的东西,记不清就现场编——这就是幻觉。你问它公司今年的差旅报销标准,它能一本正经地编出「住宿每晚不超过 800 元」,语气笃定,内容虚构。
RAG(Retrieval-Augmented Generation)的思路朴素得近乎无聊:答题前先去资料库里翻一翻,把翻到的几页摊在桌上,照着写答案。但很多人对 RAG 的理解止步于「向量数据库 + Embedding」,做出来的东西检索倒是能跑,答案照样胡说。问题出在哪?出在只学了「翻资料」,没学 RAG 真正的精髓——证据链(Citation):答案里的每个结论,都必须能溯源到检索回来的原文片段。
记住这个判断标准:不能溯源的答案,和幻觉没有区别。 这一章我们按一条真实流水线的顺序,把每个环节的工程要点过一遍,最后回到证据链、评估和前沿演进。
文档解析与清洗:垃圾进,垃圾出
RAG 系统的质量上限,在第一步就决定了。真实业务里的文档是 PDF、扫描件、Word、网页,解析的坑远比想象多:
- PDF 的坑:文字按绘制指令存储,多栏排版会被读成错乱的交错行;表格经常被拆成一堆失去结构的散字。严肃场景用 Unstructured、Docling 这类工具,保留标题层级和表格结构
- 扫描件的坑:本质是图片,必须走 OCR,OCR 错一个字,后面向量检索就可能错一片
- 清洗清单:去掉页眉页脚、页码、版权声明(它们在每页重复出现,会污染 embedding);统一全半角和空白字符;修断裂的换行(PDF 里一句话常被硬换行切成三行)
经验法则:清洗投入 1 小时,胜过后面调参 1 天。 每次检索效果差,先抽查 10 条入库的 chunk 看看长什么样,八成问题在这里。
还有一件解析期就要做的事:抽取元数据。每篇文档的来源系统、作者、更新时间、部门权限标签,都要结构化地存下来。它们当时看着没用,后面全是刚需——证据链要靠元数据显示出处,权限过滤要靠标签做访问控制,「这份制度是不是最新版」要靠时间戳判断。入库时再补,成本是解析时的十倍。
Chunking:切多大,是个权衡
文档不能整本塞进索引,要切成 chunk。切太小,上下文不完整,「这个问题的答案」和「问题本身」被切到两个块里;切太大,embedding 被稀释,检索不精确,还浪费上下文窗口。三种主流策略:
1. 定长切分(Fixed-size):按固定 token 数切,块之间留重叠(overlap)防止答案正好跨边界。简单可靠,是 baseline:
function fixedChunk(text: string, size = 400, overlap = 80): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size - overlap) {
chunks.push(text.slice(i, i + size));
}
return chunks;
}
2. 语义切分(Semantic):按句子/段落边界切,或用相邻句 embedding 的相似度突变来找「话题切换点」。块的内容更完整,但实现和调参成本高。
3. 层级切分(Hierarchical):父子两级——用小块(子块,如单个段落)做检索保证精度,命中后把它所属的大块(父块,如整个小节)喂给模型保证上下文完整。LangChain 的 ParentDocumentRetriever、LlamaIndex 的 HierarchicalNodeParser 都是这个思路。
chunk 大小与重叠率:别拍脑袋,扫一遍参数
chunk 大小对召回的影响是实测问题,不是理论问题。经验起点:
- 问答/知识库场景:200~500 token 是甜区。小于 200,块里常常缺主语和背景(「该政策自 3 月起生效」——哪个政策?);大于 800,一个块混进多个主题,向量变成「什么都像一点」,命中精度下滑
- 重叠率:10%
20% 是常用区间(400 token 配 4080 重叠)。低于 10% 拦不住跨边界的句子;高于 20% 收益迅速递减,还白白放大索引体积和冗余召回
正确的姿势是拿评测集扫参:在 {200, 400, 800} × {0%, 15%, 30%} 的网格上跑命中率,看曲线再定值。不同语料的甜点位置差异很大,别人博客里的数字只能当起点。
表格、代码、图片:多模态文档怎么切
- 表格:切忌按 token 拦腰切断。整表作为独立块序列化成 Markdown 或 HTML 保留结构;表超大时按行组切,但每个块都要重复表头,否则「3, 5000, 华东」这行数字毫无语义
- 代码:按语法边界切(函数/类是天然原子),不要按字符硬切。Tree-sitter 可以低成本拿到 AST 边界
- 图片:两条路。一是用 VLM 生成文字描述(caption)入库,按文本检索;二是直接用多模态 embedding(如 CLIP 系)做图搜图。工程上第一条路更可控:描述文本还能和上下文段落合并成块
混合检索:别只迷信向量,BM25 依然能打
向量检索擅长「意思相近但用词不同」的查询,比如问「怎么退款」能命中写着「退货流程」的文档。但它有经典盲区:专有名词、型号、错误码。用户搜 ERR_CONNECTION_RESET 或「RTX 4090」,字面匹配才是正解——这正是关键词检索 BM25 的主场。
BM25 的原理直觉
BM25 不用懂公式也能建立直觉,它就三条常识:
- 词频饱和:一个词在文档里出现 3 次和 30 次,相关性差别不大(TF 有饱和曲线,不是线性加分)
- 稀有词更值钱:「退款」在几千篇文档里都有,「ERR_CONNECTION_RESET」只有三篇有——命中稀有词的文档更相关(IDF)
- 长度归一化:同样命中两次,200 字的短文档比 2 万字的长文档更相关
一句话:「稀有词命中越多、文档越短,越相关」。它不看语义,所以和向量检索正好互补。
RRF 融合:为什么看排名不看分数
混合检索两路各召回一批,怎么合并?最常用的 RRF(Reciprocal Rank Fusion)公式极简:
score(d) = Σ 1 / (k + rank_i(d)) k 通常取 60
文档在两路结果里的排名各贡献一个倒数分,相加排序。它的高明之处在于完全不看原始分数:向量的余弦相似度(01)和 BM25 分数(可能 030)量纲完全不同,直接加权会翻车;排名是无量纲的,天然免疫。
def rrf_merge(vector_hits: list[str], bm25_hits: list[str], k: int = 60) -> list[str]:
scores: dict[str, float] = {}
for rank, doc_id in enumerate(vector_hits):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
for rank, doc_id in enumerate(bm25_hits):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
对比方案是加权融合:先做 min-max 归一化,再算 α·向量分 + (1-α)·BM25分。它能表达「这个场景语义更重要」的业务偏好,但归一化对分数分布很敏感——语料一变、模型一换,调好的 α 就失效。RRF 鲁棒但无法表达偏好。实践顺序:先 RRF 打底,确有明确业务偏好再上加权。
顺带一提检索前的查询侧优化:多轮对话里用户的指代(「那这个怎么退?」)直接拿去检索效果很差,先用 LLM 把 query 改写成自包含的完整问题再检索,是成本最低、收益最稳的一步。
Rerank:粗排之后,再精排一遍
为什么双塔快而不准
向量检索用双编码器(bi-encoder):查询和文档分别编码成向量,检索时算个点积。快是因为文档向量可以预先算好建索引,查询只做一次编码加一次近邻搜索。代价是:查询和文档从头到尾没有真正「见面」,所有交互被压缩进两个向量的一个点积里,细粒度的语义对应关系丢了。
Rerank 模型用交叉编码器(cross-encoder,如 bge-reranker、Cohere Rerank):把「查询 + 候选文档」拼在一起过一遍 Transformer,token 级充分交互后输出相关性分数。准得多,但每个(查询, 文档)对都要单独跑一次前向——对 50 个候选打分通常增加 50~300ms 延迟,所以它只能用于精排,绝不可能扫全库。
混合检索召回 top-50 → Rerank 精排 → top-5 进上下文
工程上,「召回多少个进 Rerank」就是延迟与效果的旋钮:候选越多越不容易漏,但延迟线性上涨。反过来说,如果你的语料很干净、查询很规范(比如内部 FAQ 机器人),混合检索的 top-5 直接进上下文往往已经够用——Rerank 是效果保险,不是必装件,先在评测集上量出它带来的提升再决定买不买这份延迟。
中间路线:ColBERT 的晚交互
双塔太粗、cross-encoder 太慢,有没有折中?ColBERT 的晚交互(late interaction):查询和文档仍各自独立编码,但保留 token 级向量组(而不是压成一个向量);打分时用 MaxSim——查询的每个 token 找文档里最像它的 token,相似度求和。精度逼近 cross-encoder,又因为文档侧可预建索引,能支撑大规模召回。代价是索引体积大一个数量级。Vespa、RAGatouille 等已支持,重度检索场景值得评估。
加 Rerank 这一步为什么值?因为 LLM 的上下文又贵又挑:塞 50 个 chunk 既烧 token,还会撞上「Lost in the Middle」——模型对长上下文中间位置的信息利用率显著低于开头结尾。Rerank 把最相关的 5 个挑出来放前面,比把 50 个全塞进去效果好得多。
Citation:证据链,RAG 的灵魂
终于到核心了。没有 Citation 的 RAG 只是把「模型瞎编」换成「模型照着不一定对的资料编」。证据链要回答的问题是:用户凭什么信这个答案?
工程上三件事:
1. 检索时带上身份证。 每个 chunk 入库时存元数据:文档 ID、标题、页码/小节、原文偏移。检索回来什么,生成时就知道出处是什么。
2. 生成时强制标注。 在 prompt 里给 chunk 编号,要求模型逐句引用:
[1] 退款需在签收后 7 天内发起……(来源:售后政策.pdf 第3页)
[2] 定制商品不支持无理由退款……(来源:售后政策.pdf 第4页)
指令:仅根据上述资料回答,每个事实性结论后面必须标注 [编号]。
资料里没有的信息,直接说"未找到相关规定",禁止补充自己的知识。
3. 生成后校验:引用不是装饰品。 模型标了 [1],不代表那句话真是 [1] 说的。校验层做三步:
- 引用存在性:答案里的每个 [n] 是否真的在本次候选集里(模型会编造不存在的编号)
- span 对齐:按引用标记把答案切成 claim 段,每个 claim span 绑定它声明引用的 chunk,形成(claim, chunk)对
- 支持度判定:逐对判断 chunk 是否支撑 claim——用 NLI 蕴含模型,或让小 LLM 当 judge 输出 supported / partial / unsupported。unsupported 的结论:删除、标灰、或打回重生成
切 claim 这一步本身很朴素,正则就够用:
import re
def split_claims(answer: str) -> list[tuple[str, list[int]]]:
"""把带 [n] 引用的答案切成 (claim, 引用编号列表) 对。"""
parts = re.split(r"(\[[\d, ]+\])", answer)
claims: list[tuple[str, list[int]]] = []
for i in range(0, len(parts) - 1, 2):
text = parts[i].strip()
refs = [int(n) for n in re.findall(r"\d+", parts[i + 1])]
if text:
claims.append((text, refs))
return claims
拿到 (claim, refs) 后,把 claim 和对应 chunk 送给 judge 判定支持度即可。这一层校验才是「工程手段」四个字的分量所在——不信任模型的自觉性,用流程兜底。
两个落地细节值得说透。一是 partial(部分支撑)怎么处理:直接删太粗暴——claim 前半句有依据、后半句是发挥,实践中通常降级为「标灰 + 提示用户核对原文」,只有完全 unsupported 才删除或打回重生成。二是前端体验要跟上:引用编号做成可点击,点击跳转并高亮原文对应段落(入库时存的原文偏移在这里派上用场)。证据链的收益不只是「可信」:用户能点进原文核对,badcase 能精确定位是「没检到」还是「检到了但用错了」,迭代效率完全不同。
评估:命中率与忠实度,两把尺子
RAG 是「检索 + 生成」两段,要分开量,不然出了问题不知道怪谁:
- 检索侧——命中率/上下文召回(Hit Rate / Context Recall):对标注好「正确文档」的问题,看正确文档是否进了召回 top-k。命中率低,说明解析、chunking 或检索有问题,后面生成再好也白搭
- 生成侧——忠实度(Faithfulness):答案中有多少比例的结论能被检索到的上下文支撑。忠实度低,是 prompt、引用约束或校验层的问题
RAGAS 的四个核心指标
RAGAS 框架用 LLM 当裁判把评估自动化,四个指标正好覆盖两端:
| 指标 | 量什么 | 直觉 |
|---|---|---|
| Faithfulness | 答案事实被上下文支撑的比例 | 有没有添油加醋 |
| Answer Relevancy | 答案与问题的相关度 | 有没有答非所问 |
| Context Precision | 相关 chunk 是否排在前面 | 检索排序质量 |
| Context Recall | 标准答案所需信息被召回覆盖的比例 | 检索有没有漏 |
评测集怎么造
没有评测集的 RAG 调优等于蒙眼调参。构造方法按性价比排序:
- 真实日志采样:上线灰度后从用户 query 里采,分布最真实,但要脱敏
- LLM 合成:让模型基于文档自动生成(问题, 答案, 来源 chunk)三元组,再人工抽检 20% 过滤坏样本——冷启动阶段的主力
- 题型要覆盖:事实型(直接能答)、多跳型(要组合两个 chunk)、拒答型(答案不在库里,专测忠实度底线——好系统应该答「未找到」而不是硬编)
规模 50~200 条起步就够,关键是每次改动都跑一遍回归,让优化有据可依。
最后提醒一句:LLM 当裁判本身有偏差——它倾向于给流畅、自信的答案打高分,对「答得对但引用错」的情况常常放水。所以 RAGAS 分数用来做相对回归(这次改动比上次好还是差)很可靠,当成绝对质量证书就危险了。关键版本上线前,核心指标还是要人工复核一批。
演进:GraphRAG 与 Agentic RAG
经典 RAG 解决不了的问题,催生了两个方向:
- GraphRAG(微软,2024):先从语料抽实体和关系建知识图谱,再做社区聚类摘要。它解决的是「全局性问题」——「这批文档整体在讨论什么」「A 和 B 之间有哪些间接关联」,这类问题需要跨文档聚合,向量检索天生做不到。代价是建图成本高、更新慢
- Agentic RAG:检索不再是固定流水线,而是 Agent 循环里的一个工具。模型自己决定查不查、查几次、怎么改写 query,查完发现不够就换关键词再查。多跳问题(「A 公司的供应商的总部在哪」)收益明显。代价是延迟和 token 成本翻倍,行为可控性下降
两者都不是「下一代替代」,而是特定问题类的补丁:全局聚合上图谱,多跳推理上 Agent。
落地顺序上有个务实建议:先把经典流水线做到评测集合格,再考虑演进方案。我见过不止一个团队,基础 chunking 还没扫参、混合检索还没上,就直奔 GraphRAG——结果建图成本花了一大笔,badcase 一分析,八成还是「该捞的没捞到」这种基本功问题。演进方案解决的是经典流水线的天花板,不是它的地基。
争议:RAG 会被长上下文取代吗?
百万 token 上下文时代,这个问题绕不开。正反方都有硬论据:
正方(会取代):整库塞进上下文,省掉解析、切分、索引、检索整套工程;模型对全文有全局视野,没有 chunking 造成的信息割裂;多跳推理不用依赖召回运气。
反方(不会):成本和延迟随上下文长度上涨,高频查询场景每次都塞全库在经济上不成立;Lost in the Middle 实证表明长上下文中段利用率显著下降;企业语料是 TB 级且持续更新的,塞不下也塞不起;权限敏感的语料需要检索层做访问控制——「这个用户能看哪些文档」没法靠塞上下文解决。
我的判断:长上下文吃掉「小语料、一次性分析」的场景(丢一份 200 页合同让它总结),RAG 守住「大语料、高频查询、权限敏感、需要溯源」的场景。而且别忘了本章的主题——长上下文给不了证据链:模型读完全库给出答案,你依然需要它指出「这句话出自哪份文件第几页」,这恰恰还是 RAG 的活。未来更可能的形态是融合:先检索圈定范围,再把相关文档整篇(而非切碎的 chunk)交给长上下文模型阅读——检索负责「找到」,长上下文负责「读全」。
常见误区
- 只上向量检索:专有名词和错误码场景必挂,混合检索是底线不是加分项
- chunk 一刀切:技术文档、合同、FAQ 的结构完全不同,切分策略要按文档类型定
- 抄别人的 chunk 参数:甜点位置依语料而定,不跑扫参等于掷骰子
- 召回直接进上下文:不做 Rerank 靠多塞 chunk 弥补,又贵又撞 Lost in the Middle
- 引用只是 UI 装饰:前端显示个「来源」链接,但那句话根本不是来源里说的——引用必须逐句校验,否则证据链就是摆设
- 拿准确率一把尺子量所有问题:检索烂和生成烂要分开诊断,先修检索——检索侧命中率不达标时,生成侧做任何优化都是在流沙上盖楼
术语表
| 名词 | 定义 | 一句话直觉 | 常见混淆 |
|---|---|---|---|
| Chunking | 把文档切成可索引小块的策略 | 把书撕成便签再归档 | 不是越小越好——块太小会丢上下文 |
| Embedding | 把文本映射为稠密向量的模型 | 给每段话发一个语义坐标 | 向量相似 ≠ 字面相同,恰恰互补 |
| BM25 | 经典关键词排序算法 | 稀有词命中越多、文档越短越相关 | 是统计算法,不含任何语义理解 |
| 混合检索 | 向量 + BM25 两路召回再融合 | 语义警察和字面警察联合办案 | 不是二选一,是两路同时跑 |
| RRF | 按排名倒数求和的融合算法 | 两路排名取倒数相加,不看原始分 | 与加权融合不同——不需要归一化分数 |
| Rerank | 召回后用重排模型精排候选 | 海选之后的复试 | 不是「再检索一次」,时机在召回后、生成前 |
| Cross-encoder | 把查询和文档拼接输入打分的模型 | 让 query 和 doc 当面深聊 | 与双塔(bi-encoder)相反——快和准的取舍 |
| ColBERT / 晚交互 | 保留 token 级向量、打分时细粒度交互 | 各自预习,见面再逐句对 | 介于双塔与 cross-encoder 之间,不是同一物 |
| 证据链 Citation | 结论可溯源到原文片段的机制 | 每句话都要挂脚注 | 不是贴个来源链接,需逐句支持度校验 |
| 忠实度 Faithfulness | 答案被检索上下文支撑的比例 | 有没有照资料添油加醋 | ≠ 事实正确性——答对了但没依据上下文,照样不忠实 |
| 命中率 Hit Rate | 正确文档是否进入召回 top-k | 该捞的捞没捞到 | 与精确率相反:召回看「漏没漏」,精确看「杂不杂」 |
| Lost in the Middle | 模型对长上下文中段信息利用率低 | 读书只记得开头和结尾 | 不是记不住,是用不好——所以需要 Rerank 精选 |
| GraphRAG | 基于知识图谱聚合的检索增强 | 先画人物关系图再答全局题 | 不替代向量检索,只补跨文档聚合 |
| Agentic RAG | Agent 循环中按需多次检索 | 模型自己决定查几次、怎么查 | 不是单次流水线,成本和延迟要另算 |
参考材料
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — RAG 原始论文(Meta,2020)
- Lost in the Middle: How Language Models Use Long Contexts — 为什么 Rerank + 少而精的上下文优于堆量
- ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction — 晚交互检索的原始论文
- RAGAS: Automated Evaluation of Retrieval Augmented Generation — RAG 评估框架的原始论文,四大指标怎么自动算
- Retrieval-Augmented Generation for Large Language Models: A Survey — RAG 技术全景综述,各环节的方案地图
- From Local to Global: A Graph RAG Approach to Query-Focused Summarization — 微软 GraphRAG 论文
- Extrinsic Hallucinations in LLMs — Lilian Weng 关于幻觉成因与对策的长文,理解为什么 Citation 有效
小结
RAG 流水线七步:解析清洗决定上限,Chunking 决定粒度,混合检索保底召回,Rerank 精选上下文,Citation 建立信任,评估驱动迭代。 GraphRAG 和 Agentic RAG 是特定问题类的补丁,长上下文吃掉小语料场景但给不了证据链。最该记住的只有一句话:RAG 的价值不在于模型能翻到资料,而在于答案的每句话都能指回原文——证据链才是对抗幻觉的工程答案。