博客.
博客.
(联系方式)

保持联系

如果你想联系我,无论是讨论合作机会、分享想法,还是只是打个招呼,都可以通过以下方式找到我。

(最后更新)

版权所有 / 2026

(快捷键)C
返回学习路线
(02 · Agent 开发)2026年8月

RAG 实战

RAG 的精髓不是「检索」,而是「证据链」——答案的每句话都能指回原文片段。从解析清洗到混合检索、Rerank、引用校验与评估,一篇讲透整条流水线的工程细节。

RAG 实战

开场:把闭卷考试改成开卷考试

LLM 直接回答问题是「闭卷考试」:全靠训练时记住的东西,记不清就现场编——这就是幻觉。你问它公司今年的差旅报销标准,它能一本正经地编出「住宿每晚不超过 800 元」,语气笃定,内容虚构。

RAG(Retrieval-Augmented Generation)的思路朴素得近乎无聊:答题前先去资料库里翻一翻,把翻到的几页摊在桌上,照着写答案。但很多人对 RAG 的理解止步于「向量数据库 + Embedding」,做出来的东西检索倒是能跑,答案照样胡说。问题出在哪?出在只学了「翻资料」,没学 RAG 真正的精髓——证据链(Citation):答案里的每个结论,都必须能溯源到检索回来的原文片段。

记住这个判断标准:不能溯源的答案,和幻觉没有区别。 这一章我们按一条真实流水线的顺序,把每个环节的工程要点过一遍,最后回到证据链、评估和前沿演进。

RAG 流水线全景:离线侧的解析、清洗、Chunking、建索引,在线侧的混合检索、Rerank、带引用生成与评估


文档解析与清洗:垃圾进,垃圾出

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 的主场。

混合检索与 RRF 融合:向量检索负责语义相近,BM25 负责字面与专名命中,RRF 按排名把两路结果合并成一份排序

BM25 的原理直觉

BM25 不用懂公式也能建立直觉,它就三条常识:

  1. 词频饱和:一个词在文档里出现 3 次和 30 次,相关性差别不大(TF 有饱和曲线,不是线性加分)
  2. 稀有词更值钱:「退款」在几千篇文档里都有,「ERR_CONNECTION_RESET」只有三篇有——命中稀有词的文档更相关(IDF)
  3. 长度归一化:同样命中两次,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 调优等于蒙眼调参。构造方法按性价比排序:

  1. 真实日志采样:上线灰度后从用户 query 里采,分布最真实,但要脱敏
  2. LLM 合成:让模型基于文档自动生成(问题, 答案, 来源 chunk)三元组,再人工抽检 20% 过滤坏样本——冷启动阶段的主力
  3. 题型要覆盖:事实型(直接能答)、多跳型(要组合两个 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 RAGAgent 循环中按需多次检索模型自己决定查几次、怎么查不是单次流水线,成本和延迟要另算

参考材料


小结

RAG 流水线七步:解析清洗决定上限,Chunking 决定粒度,混合检索保底召回,Rerank 精选上下文,Citation 建立信任,评估驱动迭代。 GraphRAG 和 Agentic RAG 是特定问题类的补丁,长上下文吃掉小语料场景但给不了证据链。最该记住的只有一句话:RAG 的价值不在于模型能翻到资料,而在于答案的每句话都能指回原文——证据链才是对抗幻觉的工程答案。