Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
IV 版 · 知识引擎 第 02 章 Retrieval Augmented Generation
IV · 知识引擎 02 / 22 进阶

RAG 的链路与失效模式

Retrieval Augmented Generation
预计阅读 32 分钟
难度 进阶
关键词 RAG · 切分
本机状态 未读

「RAG 就是检索加生成」是这句话最大的问题——它把一个六个环节的链路压成了两个词,于是每个环节的失效模式都被忽略了。这一章把链路拆开,逐段列出失效模式与定位手段,最后给出一份可执行的排查顺序。

编辑部注 · 本章的失效模式表是实操清单,建议直接拿它去排查你手上的检索系统。

先纠正一个说法。「检索 + 生成」描述的是结果,不是过程。真实的链路有六个环节,而每个环节都能独立地把最终结果做错——而且错得毫无痕迹,你只能看到「回答不对」,看不到是哪一环出了问题。

一、六个环节

RAG 链路:六个环节,六个失效点 ① 查询理解 把用户口语转成检索意图: 实体归一、指代消解、改写 ✗ 指代未消解(「它」指谁?) ✗ 别名未归一(华东/东区) ✗ 口语未改写(「卖得好吗」) ② 切分 Chunking 把文档切成可检索的单元 ✗ 语义切断:答案被切在两块 ✗ 块过大:噪音多、匹配模糊 ✗ 块过小:丢上下文与前提 ✗ 表格/图表被切成乱码 ③ 索引 Indexing 向量化 + 建索引 ✗ 嵌入模型换了但没重建 ✗ 元数据没入索引(无法过滤) ✗ 增量更新遗漏,索引与源不一致 ④ 召回 Retrieval 从索引里取回候选 ✗ top-k 太小:漏掉关键块 ✗ 无过滤:取回错误版本的文档 ✗ 纯向量:精确匹配的词反而漏 ✗ 无时间过滤:旧版本被召回 ⑤ 重排 Rerank 对候选做精排 ✗ 跳过了重排,直接按相似度排 ✗ 重排模型与语料语言不匹配 ✗ 截断到 N 条时丢了关键证据 ⑥ 组装与生成 拼上下文 → 生成回答 ✗ 缺来源标注:模型无从判断可信 ✗ 上下文超限被截断:证据丢失 ✗ 没给「无相关资料」的出口 ✗ 引用无法点开核对 排查顺序:从后往前,二分定位 Step 1 把标准答案所在的原文块,手工塞进上下文 → 模型能答对吗?    能 → 问题在 ①-⑤(检索链路);不能 → 问题在 ⑥(组装/生成)或模型能力 Step 2 检查召回结果里有没有那个块(把 top-k 调到 100 看)    有 → 问题在 ⑤(排序)或 top-k 太小;没有 → 问题在 ①②③(建模/切分/索引) Step 3 用原文里的关键词直接做全文检索,能不能找到那个块?    能找到 → 是语义检索的问题(换嵌入模型或加混合检索);找不到 → 切分或索引有问题
图 1 RAG 六环节与各自的失效模式,以及底部那段排查顺序。排查的关键是「从后往前二分」——先用「手工塞原文」把问题区间砍成两半,再逐段收敛。很多团队一上来就换嵌入模型,实际上问题往往出在切分(②)上。

二、切分:最被低估的环节

如果说有一个环节的投入产出比最高,那就是切分。原因很简单:切错了的内容,后面用再好的模型也救不回来。

四种切分策略与适用场景
策略做法适合风险
固定长度每 N 个字符切一刀无结构的长文本必然切断语义,只能作为最后手段
按结构按标题层级 / 段落 / 列表项切有明确结构的文档(规范、报告)依赖文档结构质量;层级过深时块会很小
按语义相邻句子语义相似度骤降处切开叙述性长文计算成本高;结果不稳定,难调试
父子块小块用于检索,命中后返回其所属的大块推荐默认方案实现复杂度略高;需维护父子映射
父子块为什么值得作为默认方案

它同时解决了「块小则上下文不足」与「块大则匹配模糊」这对矛盾:

  • 检索阶段用小块——句子级的块语义聚焦,与查询的语义匹配更准,不会被整段里的无关内容稀释;
  • 返回阶段给大块——命中后返回它所属的段落或整个小节,保证模型拿到完整的前提与上下文;
  • 顺带的收益:可以只对小块做嵌入(数量多但每个都短),索引成本可控。

三个切分的实操细节(都很容易被忽略):

  1. 重叠(overlap)是必要的。 相邻块之间保留 10%-20% 的重叠,可以显著降低「答案被切断」的概率。代价是索引体积变大。
  2. 表格必须特殊处理。 表格按字符切会变成乱码。正确做法是把表格转为 Markdown 或自然语言描述(「表头为 地区/转化率,华东行取值为 1.1%」),作为整体一个块。
  3. 每个块都要带上层级路径。 块的开头附上「文档标题 > 一级标题 > 二级标题」,这既能提升检索准确率(路径参与嵌入),也让模型知道这段内容的语境。

三、动手:可调参的检索链路

rag_pipeline.pypython
from dataclasses import dataclass, field
from typing import Callable
import re
@dataclass
class Chunk:
    id: str
    text: str
    parent_id: str | None = None     # 父子块:指向所属的大块
    path: str = ""                   # 层级路径:文档 > 章节 > 小节
    meta: dict = field(default_factory=dict)   # source, updated_at, version, lang
    def embed_text(self) -> str:
        """参与嵌入的文本:带上层级路径,提升语义匹配质量"""
        return f"{self.path}\n{self.text}" if self.path else self.text
@dataclass
class RagConfig:
    top_k: int = 20                  # 召回条数(先宽)
    top_n: int = 6                   # 精排后保留条数
    min_score: float = 0.0           # 分数下限,低于此值视为无关
    require_meta: dict | None = None # 必须满足的元数据过滤,如 {"lang": "zh"}
    max_chars_per_chunk: int = 3000  # 单块入上下文的字符上限
def retrieve(query: str, cfg: RagConfig, embed, search) -> list[Chunk]:
    """
    召回阶段。注意三点:
      1. 元数据过滤必须在召回时做,而不是后面过滤 —— 否则 top-k 会被无关内容占满
      2. 先宽召回,再精排(top_k > top_n)
      3. 分数下限很重要:宁可返回空,也不要用低相关内容凑数
    """
    vec = embed(query)
    raw = search(vec, k=cfg.top_k, filters=cfg.require_meta)
    picked = [c for c, score in raw if score >= cfg.min_score]
    return picked
def expand_parents(chunks: list[Chunk], get_parent: Callable[[str], Chunk]) -> list[Chunk]:
    """命中子块后膨胀为父块,但去重 —— 命中同一父块的多个子块只保留一个"""
    seen, out = set(), []
    for c in chunks:
        if c.parent_id and c.parent_id not in seen:
            seen.add(c.parent_id)
            out.append(get_parent(c.parent_id))
        elif not c.parent_id:
            out.append(c)
    return out
def build_rag_context(query: str, cfg: RagConfig, embed, search,
                      rerank, get_parent) -> tuple[str, dict]:
    """
    完整链路 + 诊断信息。
    诊断信息是必须的 —— 没有它,出了问题只能靠猜。
    """
    diag = {}
    # ④ 召回
    cand = retrieve(query, cfg, embed, search)
    diag["召回条数"] = len(cand)
    diag["召回来源分布"] = {}
    for c in cand:
        s = c.meta.get("source", "unknown")
        diag["召回来源分布"][s] = diag["召回来源分布"].get(s, 0) + 1
    if not cand:
        return ("(未检索到相关资料。请明确说明信息不足,不要凭推测作答。)", diag)
    # ⑤ 重排
    ranked = rerank(query, cand)[: cfg.top_n]
    diag["重排后条数"] = len(ranked)
    diag["最高分来源"] = ranked[0].meta.get("source") if ranked else None
    # 父子块膨胀
    expanded = expand_parents(ranked, get_parent)
    diag["膨胀后条数"] = len(expanded)
    # ⑥ 组装:必须带来源、时间、层级;超限要明确标注截断
    lines, used, dropped = [], 0, 0
    for c in expanded:
        body = c.text
        if len(body) > cfg.max_chars_per_chunk:
            body = body[: cfg.max_chars_per_chunk] + "……[内容过长已截断]"
        block = (f"【来源 {c.meta.get('source','未知')}|"
                 f"更新 {c.meta.get('updated_at','未知')}|"
                 f"层级 {c.path or '—'}\n{body}")
        lines.append(block)
        used += len(block)
    if dropped:
        lines.append(f"(另有 {dropped} 条因长度限制未纳入)")
    head = ("以下为检索到的材料。请严格基于这些材料回答,"
            "并在结论处标注依据的来源。若材料不足以回答,请直接说明不足,不要推测。\n\n")
    diag["上下文总字符"] = used
    return head + "\n\n".join(lines), diag
def split_by_structure(doc: str, doc_title: str, target: int = 600,
                       overlap: int = 100) -> list[Chunk]:
    """
    结构化切分(默认推荐):按标题切,超长再按段落切,段落仍超长才按长度切。
    每块都带上层级路径。
    """
    chunks: list[Chunk] = []
    # 简易实现:以 Markdown 标题为界
    sections = re.split(r"\n(?=#{1,3} )", doc)
    idx = 0
    for sec in sections:
        title = ""
        m = re.match(r"(#{1,3}) (.+)", sec)
        if m:
            title = m.group(2).strip()
        path = f"{doc_title} > {title}" if title else doc_title
        # 超长段落再按长度切,保留重叠
        if len(sec) <= target:
            chunks.append(Chunk(id=f"c{idx}", text=sec.strip(), path=path)); idx += 1
        else:
            start = 0
            while start < len(sec):
                piece = sec[start: start + target]
                chunks.append(Chunk(id=f"c{idx}", text=piece.strip(), path=path))
                idx += 1
                start += target - overlap
    return chunks
def table_to_text(rows: list[list[str]]) -> str:
    """
    表格处理:不要按字符切表格。
    转成「每行一句」的自然语言,作为一个整体块。
    """
    if not rows:
        return ""
    header = rows[0]
    lines = []
    for r in rows[1:]:
        pairs = ", ".join(f"{h}{v}" for h, v in zip(header, r))
        lines.append(f"该表一行记录:{pairs}")
    return f"表头:{', '.join(header)}\n" + "\n".join(lines)
实操任务
  • 挑 10 个「检索失败」的查询,按图 1 底部的三步排查顺序定位,统计各环节的失败占比;
  • top_k 从 20 调到 100,看那个「本应命中却没命中」的块是否出现——如果出现,说明问题是排序而不是召回;
  • 实现父子块:target=300 的小块用于检索、800 的父块用于返回,对比上下文长度与答案准确率的变化。

四、自测

本章自测第 2 题为高频考点
Q1检索不到答案时,正确的第一步排查是什么?
B。这是「二分定位」的第一步:如果给了正确原文模型也答不对,那问题就不在检索上,改嵌入模型纯属浪费——把问题区间先砍一半,后面的排查才有方向。C 是合理的第二步(用于区分召回与排序问题),但作为第一步会引入新的混淆因素。
Q2为什么元数据过滤(如只搜某个版本、某种语言)必须在召回阶段做,而不是拿到结果后再过滤?
C。这是个很实际的问题:你取 top-20,其中 17 条来自旧版本文档,后过滤之后只剩 3 条,而真正相关的第 21-25 名根本没被召回进来。正确做法是把过滤条件下推到检索层,让向量库只在符合条件的子集里做近邻搜索。
Q3表格内容为什么不能按字符长度切分?
D。表格的信息在于「表头与单元格的对应关系」,切分一旦破坏这个关系,剩下的数字就毫无意义。正确做法:把表格转为自然语言或 Markdown 后作为整体一个块,例如「表头为 地区/转化率,华东行取值为 1.1%」——这样既能被检索到,也能被模型正确理解。

五、小结

环节关键动作最易犯的错
① 查询理解别名归一、指代消解、口语改写没做归一,检索直接漏召回
② 切分结构化切分 + 父子块 + 重叠 + 表格特殊处理按字符切、切坏表格、丢层级路径
③ 索引嵌入模型变更后必须重建换了模型没重建,检索结果完全错乱
④ 召回先宽召回、元数据过滤下推、设分数下限后过滤导致可用结果被挤空
⑤ 重排精排 + 截断到 N跳过重排,直接用相似度排序
⑥ 组装带来源/时间/层级 + 超限显式标注 + 给「无资料」出口没有来源标注,模型无从判断可信度

检索系统的调试只有一条有效路径:先二分定位环节,再动手改。跳过分步排查直接换模型,是耗时最长的一条路。本刊编辑部

下一章单独讲检索的物理基础:向量到底是什么,为什么「语义相近」和「对回答有用」是两件事。

面试官会怎么问

共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照

RAG 的完整链路有哪几步? 高频
六个环节,每一步都能单独把结果做错:
① 采集与清洗——把文档、表格、日志接进来,去重、去噪、处理编码与格式。
② 切分(Chunking)——决定检索的最小单位。切得太大召回不准且占 token,切得太小语义不完整。
③ 索引——为每个 chunk 建关键词索引与向量索引,并保留元数据(来源、时间、权限、层级结构)。
④ 召回——根据查询取回候选集(关键词 + 向量混合)。
⑤ 排序(Rerank)——对候选做精排,选出真正该进上下文的那几条。
⑥ 生成——把选中片段与问题一起交给模型,要求带引用作答。
最常见的误区是把 RAG 理解成"④ + ⑥",然后所有问题都往提示词上改。实际上大部分 RAG 故障发生在 ②③⑤,而这三步都在模型之外。

追问链

  1. 每一环的典型失效表现是什么?
  2. 怎么定位故障出在哪一环?
RAG 的典型失效模式有哪些?怎么定位?
按链路顺序列四类最常见的:
① 切分破坏语义——把一张表格从中间切断、把一个流程分成两半,导致召回片段残缺。表现为"答案缺一半"或"细节对不上"。定位:直接看召回片段的原文。
② 召回失败——正确片段根本没进候选集。这是最隐蔽的,因为模型会基于错误材料自信作答。定位:把"正确片段是否在候选集里"单独作为指标统计(召回率),而不是只看最终答案正确率。
③ 排序失败——正确片段在候选集里但被挤到后面,最终没进上下文。定位:看 rerank 前后的排名变化。
④ 生成忽略——材料都在上下文里,模型却没用,或者用了却编造了细节。定位:要求输出引用并在评测里检查引用是否真实存在。
关键方法论是把链路分层评测:先保证召回,再保证排序,最后才谈生成——否则你会一直在错误的环节上优化。
加分点:强调"召回率要单独统计",因为最终答案正确率会把召回失败和生成失败混在一起。
切分(chunking)你会怎么定?
按内容结构切,不要按固定字数机械切。
原则:① 尊重结构边界——按章节、段落、表格、列表项切,别把标题和它的正文分开;② 保留上下文——每个 chunk 带上标题路径(文档 > 章节 > 小节),让它在脱离原文时仍可被理解;③ 允许重叠——相邻 chunk 之间留 10%–20% 重叠,缓解边界信息丢失;④ 长度适配——大小要兼顾语义完整与检索粒度,通常几百 token;表格和代码这类结构化内容要整体保留,宁可超长。
还有一个常被忽略的点:要为不同类型的资料用不同的切分策略(政策文档按条款、会议记录按发言段落、指标字典按字典项),一刀切是精度损失的常见来源。
判断切分好坏的实用方法:随机抽 20 个 chunk 单独读,能不能看懂它在说什么——看不懂就是切坏了。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes