「RAG 就是检索加生成」是这句话最大的问题——它把一个六个环节的链路压成了两个词,于是每个环节的失效模式都被忽略了。这一章把链路拆开,逐段列出失效模式与定位手段,最后给出一份可执行的排查顺序。
编辑部注 · 本章的失效模式表是实操清单,建议直接拿它去排查你手上的检索系统。
先纠正一个说法。「检索 + 生成」描述的是结果,不是过程。真实的链路有六个环节,而每个环节都能独立地把最终结果做错——而且错得毫无痕迹,你只能看到「回答不对」,看不到是哪一环出了问题。
一、六个环节
「检索 + 生成」压成两个词,漏掉了中间四个环节。真实的链路(Retrieval-Augmented Generation)有六段——查询理解(Query Understanding)、切分(Chunking)、索引(Indexing)、召回(Retrieval)、重排(Rerank)、组装——每段都能独立把结果做错,且错得毫无痕迹。
二、切分:最被低估的环节
如果说有一个环节的投入产出比最高,那就是切分——尤其是父子块(Parent-Child Chunking)这种策略。原因很简单:切错了的内容,后面用再好的模型也救不回来。
| 策略 | 做法 | 适合 | 风险 |
|---|---|---|---|
| 固定长度 | 每 N 个字符切一刀 | 无结构的长文本 | 必然切断语义,只能作为最后手段 |
| 按结构 | 按标题层级 / 段落 / 列表项切 | 有明确结构的文档(规范、报告) | 依赖文档结构质量;层级过深时块会很小 |
| 按语义 | 相邻句子语义相似度骤降处切开 | 叙述性长文 | 计算成本高;结果不稳定,难调试 |
| 父子块 | 小块用于检索,命中后返回其所属的大块 | 推荐默认方案 | 实现复杂度略高;需维护父子映射 |
它同时解决了「块小则上下文不足」与「块大则匹配模糊」这对矛盾:
- 检索阶段用小块——句子级的块语义聚焦,与查询的语义匹配更准,不会被整段里的无关内容稀释;
- 返回阶段给大块——命中后返回它所属的段落或整个小节,保证模型拿到完整的前提与上下文;
- 顺带的收益:可以只对小块做嵌入(数量多但每个都短),索引成本可控。
三个切分的实操细节(都很容易被忽略):
- 重叠(Overlap)是必要的。 相邻块之间保留 10%-20% 的重叠,可以显著降低「答案被切断」的概率。代价是索引体积变大。
- 表格必须特殊处理。 表格按字符切会变成乱码。正确做法是把表格转为 Markdown 或自然语言描述(「表头为 地区/转化率,华东行取值为 1.1%」),作为整体一个块。
- 每个块都要带上层级路径。 块的开头附上「文档标题 > 一级标题 > 二级标题」,这既能提升检索准确率(路径参与嵌入),也让模型知道这段内容的语境。
三、动手:可调参的检索链路
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的父块用于返回,对比上下文长度与答案准确率的变化。
四、常见误区与追问
这一章的六环节表看起来像常识,但下面五条是「知道结论却仍然会做错」的地方。
4.1 误区:检索不到答案,就换一个更强的嵌入模型
错在哪:把「检索失败」默认归因到模型能力。为什么自然:换模型是唯一看起来「一次性解决」的动作,而且动作清晰、不用排查。但六环节里只有 ③④ 与嵌入模型相关,而 ② 切分与过滤错误会让目标块根本不在候选里。 判据:先做两步二分——把标准答案所在的原文块手工塞进上下文,模型能答对吗(不能则问题在生成侧);再用原文里的关键词做一次全文检索,能找到那个块吗(能找到则问题在语义召回,找不到就是切分或索引)。量级上,换嵌入模型通常带来「召回率几个百分点」的改善,而切分切错带来的是「这条直接得 0 分」,两者不是一个量级。
4.2 误区:元数据过滤可以等拿到结果后再做
错在哪:把过滤当成一段后期清洗代码。为什么自然:向量库的检索 API 用起来简单,先取一批再在本地挑,写起来更快。但 top-k 是额度:被过滤掉的每一条都占掉了一个名额。 判据:统计过滤后被丢掉的比例——如果超过召回条数的一半(例如 top-20 里 17 条来自旧版本,只剩 3 条可用),就必须把 元数据过滤(Metadata Filtering) 下推到检索层,让向量库只在符合条件的子集里做近邻搜索。这也解释了为什么选向量库时要先确认它支持带过滤的近邻查询,而不是事后补。
4.3 误区:top-k 越大越好,干脆取 100 条
错在哪:用「召回宽度」冒充「答案质量」。为什么自然:多取一些总不会漏,直觉上更安全。但 top-k 决定的是候选池大小,不是进上下文的内容;池子越大,低相关内容越多,精排与生成都会被稀释。 判据:把两头分开设——召回取 20~50 条(宽),重排后截到 4~8 条(窄),并且给召回设分数下限,低于阈值宁可返回空。一个可操作的检查:把 top-k 从 20 提到 100,如果最终进上下文的条数也跟着涨到 100,那你优化的是噪声占比,不是召回率。
4.4 误区:块要么小、要么大,必须选一个
错在哪:把两个粒度的问题问成了一个。为什么自然:「块小匹配准、块大上下文全」这个矛盾太显眼,于是默认要做取舍。实际做法是把它们解耦:检索块 200~400 字符,返回块 800~1500 字符,用父子映射连起来。 判据:如果你现在的做法是「把块调到 500 字左右,两个问题各让一步」,那答案准确率通常会卡在一个不上不下的水平;换成父子块后,同一份语料可以做到检索更准、上下文更全。顺带一条纪律:单块进上下文超过 3000 字符必须截断,并在块里显式写出「已截断多少字符」。
4.5 误区:RAG 的上限由生成模型决定
错在哪:把「答得不好」归因到生成侧。为什么自然:回答是生成模型写的,看起来它才是那个「做判断的人」。但生成侧只能使用检索给它的材料——材料里没有答案时,模型无论多强都只能编或者拒答。 判据:手工把正确原文塞进上下文,如果模型能给出正确答案,那么这次失败与生成模型无关,瓶颈在检索;如果塞了原文仍然答错,才轮到提示词与生成模型。这条判据只需几分钟,却能把排查方向一次砍成两半——而多数团队是先换模型、再回头查检索,把最难的一步留到最后。
五、自测
六、小结
| 环节 | 关键动作 | 最易犯的错 |
|---|---|---|
| ① 查询理解 | 别名归一、指代消解、口语改写 | 没做归一,检索直接漏召回 |
| ② 切分 | 结构化切分 + 父子块 + 重叠 + 表格特殊处理 | 按字符切、切坏表格、丢层级路径 |
| ③ 索引 | 嵌入模型变更后必须重建 | 换了模型没重建,检索结果完全错乱 |
| ④ 召回 | 先宽召回、元数据过滤下推、设分数下限 | 后过滤导致可用结果被挤空 |
| ⑤ 重排 | 精排 + 截断到 N | 跳过重排,直接用相似度排序 |
| ⑥ 组装 | 带来源/时间/层级 + 超限显式标注 + 给「无资料」出口 | 没有来源标注,模型无从判断可信度 |
检索系统的调试只有一条有效路径:先二分定位环节,再动手改。跳过分步排查直接换模型,是耗时最长的一条路。本刊编辑部
下一章单独讲检索的物理基础:向量到底是什么,为什么「语义相近」和「对回答有用」是两件事。
七、参考与延伸
这一章的每个环节都有各自的深挖材料,下面按「先看实证、再看代码、最后读论文」排好了。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看实证(量化「每个环节值多少钱」)
- Anthropic · Introducing Contextual Retrieval —— 用数据回答「切分加语境」值多少:top-20 检索失败率从 5.7% 降到 3.7%(只加语境),叠加关键词召回降到 2.9%,再加精排降到 1.9%。想说服同事「先修切分和融合,别急着换嵌入模型」,这篇的数字最好用。
再看代码(动手实现召回与精排)
- Sentence-Transformers · Retrieve & Re-Rank —— 官方实现的双阶段检索范例:先双塔召回 top-100,再交叉编码器精排。本章第二节的三段式分工,这份代码里就是可运行的最小版本。
最后读论文(对齐一手定义)
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(arXiv:2005.11401) —— RAG 的一手来源(Lewis 等,NeurIPS 2020)。注意读它的术语:这里的「检索」是把文档喂给生成器,今天工程上的六环节都是它的实现细节——先读第 2 节,别从头啃。
◇ 面试官会怎么问
共 6 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
RAG 的完整链路有哪几步? 高频
① 采集与清洗——把文档、表格、日志接进来,去重、去噪、处理编码与格式。
② 切分(Chunking)——决定检索的最小单位。切得太大召回不准且占 token,切得太小语义不完整。
③ 索引——为每个 chunk 建关键词索引与向量索引,并保留元数据(来源、时间、权限、层级结构)。
④ 召回——根据查询取回候选集(关键词 + 向量混合)。
⑤ 排序(Rerank)——对候选做精排,选出真正该进上下文的那几条。
⑥ 生成——把选中片段与问题一起交给模型,要求带引用作答。
最常见的误区是把 RAG 理解成"④ + ⑥",然后所有问题都往提示词上改。实际上大部分 RAG 故障发生在 ②③⑤,而这三步都在模型之外。
追问链
- 每一环的典型失效表现是什么?
- 怎么定位故障出在哪一环?
RAG 的典型失效模式有哪些?怎么定位?
① 切分破坏语义——把一张表格从中间切断、把一个流程分成两半,导致召回片段残缺。表现为"答案缺一半"或"细节对不上"。定位:直接看召回片段的原文。
② 召回失败——正确片段根本没进候选集。这是最隐蔽的,因为模型会基于错误材料自信作答。定位:把"正确片段是否在候选集里"单独作为指标统计(召回率),而不是只看最终答案正确率。
③ 排序失败——正确片段在候选集里但被挤到后面,最终没进上下文。定位:看 rerank 前后的排名变化。
④ 生成忽略——材料都在上下文里,模型却没用,或者用了却编造了细节。定位:要求输出引用并在评测里检查引用是否真实存在。
关键方法论是把链路分层评测:先保证召回,再保证排序,最后才谈生成——否则你会一直在错误的环节上优化。
切分(chunking)你会怎么定?
原则:① 尊重结构边界——按章节、段落、表格、列表项切,别把标题和它的正文分开;② 保留上下文——每个 chunk 带上标题路径(文档 > 章节 > 小节),让它在脱离原文时仍可被理解;③ 允许重叠——相邻 chunk 之间留 10%–20% 重叠,缓解边界信息丢失;④ 长度适配——大小要兼顾语义完整与检索粒度,通常几百 token;表格和代码这类结构化内容要整体保留,宁可超长。
还有一个常被忽略的点:要为不同类型的资料用不同的切分策略(政策文档按条款、会议记录按发言段落、指标字典按字典项),一刀切是精度损失的常见来源。
判断切分好坏的实用方法:随机抽 20 个 chunk 单独读,能不能看懂它在说什么——看不懂就是切坏了。
你的 RAG 系统在 10% 的查询上答错。给出一个能在十分钟内把问题区间砍成两半的排查方案。 高频
能答对,问题就在检索链路(查询理解、切分、索引、召回、重排);不能答对,问题在组装与生成,或者模型能力本身。
第二步:检查召回结果里有没有那个块(把 top-k 临时调到 100 再看)。有,是排序问题或 top-k 太小;没有,是切分或索引问题。
第三步:用原文里的关键词做一次全文检索。能找到,是语义召回的问题;找不到,是切分把答案切断了。
纪律:先定位、再动手。换嵌入模型通常只动几个百分点,而切分错误带来的是「这条直接得 0 分」,两者不是一个量级。
追问链
- 为什么这一步要「从后往前」做,而不是从查询理解开始?
- 统计完 10 个失败样本之后,你会优先改哪个环节,依据是什么?
top-20 的召回里 17 条来自旧版本文档,过滤之后只剩 3 条可用。问题出在哪,怎么改?
top-k 是额度:被过滤掉的 17 条占掉了名额,而真正相关的第 21 到 25 名从来没有进入候选池。
正确做法是把过滤条件下推到检索层,让向量库只在符合条件的子集里做近邻搜索——同样的查询、同样的 top-k,可用结果从 3 条变成 20 条。
判据:过滤后被丢掉的比例超过召回条数的一半,就必须下推。选型阶段先确认向量库支持带过滤的近邻查询。
追问链
- 两路混合检索时,为什么两路必须用同一套过滤条件?
- 如果向量库不支持带过滤的近邻搜索,有哪些替代方案?
同一份语料:把块调到 200 字,检索更准但答案变差;调到 1200 字,答案更全但召回变差。怎么解?
检索粒度(块越小语义越聚焦、匹配越准)与返回粒度(块越大上下文越全)本来就该分别取值。
做法是父子块:切成 200 到 400 字的子块用于嵌入与检索,命中后按 parent_id 返回其所属的 800 到 1500 字的父块,命中同一父块的多个子块只返回一次。
代价是要维护父子映射,并且膨胀之后总字符变多,必须按预算截断、并显式标注截断了多少。
另外相邻块保留 10% 到 20% 的重叠,降低答案被切在边界上的概率。
追问链
- 表格为什么不能按字符长度切?你会怎么处理表格?
- 每个块为什么要带上「文档 > 章节 > 小节」的层级路径?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。