「RAG 就是检索加生成」是这句话最大的问题——它把一个六个环节的链路压成了两个词,于是每个环节的失效模式都被忽略了。这一章把链路拆开,逐段列出失效模式与定位手段,最后给出一份可执行的排查顺序。
编辑部注 · 本章的失效模式表是实操清单,建议直接拿它去排查你手上的检索系统。
先纠正一个说法。「检索 + 生成」描述的是结果,不是过程。真实的链路有六个环节,而每个环节都能独立地把最终结果做错——而且错得毫无痕迹,你只能看到「回答不对」,看不到是哪一环出了问题。
一、六个环节
二、切分:最被低估的环节
如果说有一个环节的投入产出比最高,那就是切分。原因很简单:切错了的内容,后面用再好的模型也救不回来。
| 策略 | 做法 | 适合 | 风险 |
|---|---|---|---|
| 固定长度 | 每 N 个字符切一刀 | 无结构的长文本 | 必然切断语义,只能作为最后手段 |
| 按结构 | 按标题层级 / 段落 / 列表项切 | 有明确结构的文档(规范、报告) | 依赖文档结构质量;层级过深时块会很小 |
| 按语义 | 相邻句子语义相似度骤降处切开 | 叙述性长文 | 计算成本高;结果不稳定,难调试 |
| 父子块 | 小块用于检索,命中后返回其所属的大块 | 推荐默认方案 | 实现复杂度略高;需维护父子映射 |
父子块为什么值得作为默认方案
它同时解决了「块小则上下文不足」与「块大则匹配模糊」这对矛盾:
- 检索阶段用小块——句子级的块语义聚焦,与查询的语义匹配更准,不会被整段里的无关内容稀释;
- 返回阶段给大块——命中后返回它所属的段落或整个小节,保证模型拿到完整的前提与上下文;
- 顺带的收益:可以只对小块做嵌入(数量多但每个都短),索引成本可控。
三个切分的实操细节(都很容易被忽略):
- 重叠(overlap)是必要的。 相邻块之间保留 10%-20% 的重叠,可以显著降低「答案被切断」的概率。代价是索引体积变大。
- 表格必须特殊处理。 表格按字符切会变成乱码。正确做法是把表格转为 Markdown 或自然语言描述(「表头为 地区/转化率,华东行取值为 1.1%」),作为整体一个块。
- 每个块都要带上层级路径。 块的开头附上「文档标题 > 一级标题 > 二级标题」,这既能提升检索准确率(路径参与嵌入),也让模型知道这段内容的语境。
三、动手:可调参的检索链路
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 故障发生在 ②③⑤,而这三步都在模型之外。
① 采集与清洗——把文档、表格、日志接进来,去重、去噪、处理编码与格式。
② 切分(Chunking)——决定检索的最小单位。切得太大召回不准且占 token,切得太小语义不完整。
③ 索引——为每个 chunk 建关键词索引与向量索引,并保留元数据(来源、时间、权限、层级结构)。
④ 召回——根据查询取回候选集(关键词 + 向量混合)。
⑤ 排序(Rerank)——对候选做精排,选出真正该进上下文的那几条。
⑥ 生成——把选中片段与问题一起交给模型,要求带引用作答。
最常见的误区是把 RAG 理解成"④ + ⑥",然后所有问题都往提示词上改。实际上大部分 RAG 故障发生在 ②③⑤,而这三步都在模型之外。
追问链
- 每一环的典型失效表现是什么?
- 怎么定位故障出在哪一环?
RAG 的典型失效模式有哪些?怎么定位?
按链路顺序列四类最常见的:
① 切分破坏语义——把一张表格从中间切断、把一个流程分成两半,导致召回片段残缺。表现为"答案缺一半"或"细节对不上"。定位:直接看召回片段的原文。
② 召回失败——正确片段根本没进候选集。这是最隐蔽的,因为模型会基于错误材料自信作答。定位:把"正确片段是否在候选集里"单独作为指标统计(召回率),而不是只看最终答案正确率。
③ 排序失败——正确片段在候选集里但被挤到后面,最终没进上下文。定位:看 rerank 前后的排名变化。
④ 生成忽略——材料都在上下文里,模型却没用,或者用了却编造了细节。定位:要求输出引用并在评测里检查引用是否真实存在。
关键方法论是把链路分层评测:先保证召回,再保证排序,最后才谈生成——否则你会一直在错误的环节上优化。
加分点:强调"召回率要单独统计",因为最终答案正确率会把召回失败和生成失败混在一起。 ① 切分破坏语义——把一张表格从中间切断、把一个流程分成两半,导致召回片段残缺。表现为"答案缺一半"或"细节对不上"。定位:直接看召回片段的原文。
② 召回失败——正确片段根本没进候选集。这是最隐蔽的,因为模型会基于错误材料自信作答。定位:把"正确片段是否在候选集里"单独作为指标统计(召回率),而不是只看最终答案正确率。
③ 排序失败——正确片段在候选集里但被挤到后面,最终没进上下文。定位:看 rerank 前后的排名变化。
④ 生成忽略——材料都在上下文里,模型却没用,或者用了却编造了细节。定位:要求输出引用并在评测里检查引用是否真实存在。
关键方法论是把链路分层评测:先保证召回,再保证排序,最后才谈生成——否则你会一直在错误的环节上优化。
切分(chunking)你会怎么定?
按内容结构切,不要按固定字数机械切。
原则:① 尊重结构边界——按章节、段落、表格、列表项切,别把标题和它的正文分开;② 保留上下文——每个 chunk 带上标题路径(文档 > 章节 > 小节),让它在脱离原文时仍可被理解;③ 允许重叠——相邻 chunk 之间留 10%–20% 重叠,缓解边界信息丢失;④ 长度适配——大小要兼顾语义完整与检索粒度,通常几百 token;表格和代码这类结构化内容要整体保留,宁可超长。
还有一个常被忽略的点:要为不同类型的资料用不同的切分策略(政策文档按条款、会议记录按发言段落、指标字典按字典项),一刀切是精度损失的常见来源。
判断切分好坏的实用方法:随机抽 20 个 chunk 单独读,能不能看懂它在说什么——看不懂就是切坏了。
原则:① 尊重结构边界——按章节、段落、表格、列表项切,别把标题和它的正文分开;② 保留上下文——每个 chunk 带上标题路径(文档 > 章节 > 小节),让它在脱离原文时仍可被理解;③ 允许重叠——相邻 chunk 之间留 10%–20% 重叠,缓解边界信息丢失;④ 长度适配——大小要兼顾语义完整与检索粒度,通常几百 token;表格和代码这类结构化内容要整体保留,宁可超长。
还有一个常被忽略的点:要为不同类型的资料用不同的切分策略(政策文档按条款、会议记录按发言段落、指标字典按字典项),一刀切是精度损失的常见来源。
判断切分好坏的实用方法:随机抽 20 个 chunk 单独读,能不能看懂它在说什么——看不懂就是切坏了。
答这类题的通用结构
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。