7.2 切片、嵌入、召回、重排四步
一句话结论:RAG 的四个阶段里,前两个在离线时就定死了质量上限。切片做错,在线怎么调提示词都救不回来——因为你要找的信息,已经不在任何一块里了。
读这篇之前
需要先读 7.1(RAG 的定位)、2.2(上下文是瓶颈)。这一篇是 RAG 的工程主体。
一、阶段一:切片(Chunking)
为什么必须切
文档不能整篇丢给模型——窗口有限,检索精度也不够。
核心参数与取舍
| 参数 | 含义 | 太大 | 太小 |
|---|---|---|---|
| chunk_size | 每块最大长度 | 检索不精确、浪费 Token | 上下文断裂、语义不完整 |
| chunk_overlap | 块间重叠 | 冗余变多 | 跨块信息断裂 |
经验值:chunk_size 512~1024 字符,overlap 为 10%~15%。[原文 723]
递归字符分割:为什么按这个顺序
一个标准的做法是按分隔符优先级递归切分:
分隔符优先级:段落 → 换行 → 句号 → 感叹号 → 问号 → 逗号 → 空格 → 字符
含义:先尝试在"段落"处切;如果一段还是太长,再退到"换行";再长就退到"句号"……
为什么要这个顺序? 因为它尽量在语义边界上切开。在句子中间切断,会让两块都语义不完整。
切片的三个更高阶做法
| 做法 | 思路 | 适用 |
|---|---|---|
| 父子分块 | 检索用小块(准),送给模型用大块(完整) | 精确性 + 完整性都要(7.4) |
| 语义分块 | 按语义相似度变化处切,而不是按字符数 | 结构松散的文档 |
| 结构分块 | 按 Markdown 标题 / HTML 标签切 | 结构清晰的文档 |
结构分块最被低估。如果你的文档有清晰的标题层级,按标题切几乎总是优于按字符数切——因为标题本身就是作者给的语义边界。
二、阶段二:向量化(Embedding)
把每块文本转成一个向量,存进向量库。
关键认知:向量"距离"代表"语义相近"
模型把语义相近的文本映射到向量空间里彼此靠近的位置;于是"找相关内容"就变成了"找最近的向量"——一个可以被高效计算的问题。【推导】
四个必须注意的点
| # | 注意 | 后果 |
|---|---|---|
| 1 | 查询和文档要用同一个模型 | 用不同模型 → 向量空间不一致 → 结果全错 |
| 2 | 维度越高不等于越好 | 高维更精细但更贵更慢 |
| 3 | 多语言要选多语言模型 | 中文用英文模型会显著掉点 |
| 4 | 模型升级要重建整个索引 | 混用不同模型的向量不可比 |
第 1 条是最常见的致命错误。很多人用 A 模型建索引、用 B 模型做查询,然后困惑为什么结果完全不相关。
三、阶段三:召回(Retrieval)
用问题向量去库里找最近的 N 个块。
单一向量检索的三个弱点
| 弱点 | 表现 | 解法 |
|---|---|---|
| 不擅长精确匹配 | 搜"ERR_4032"这类错误码效果差 | 混合关键词检索 |
| 不理解否定 | "不是 Java" 可能召回 Java 文档 | 重排 / 查询改写 |
| 容易召回"语义相近但无用" | 泛泛相关但不解决问题 | 重排(阶段四) |
混合检索:向量 + 关键词
两条路各有所长:
| 向量检索 | 关键词检索(BM25) | |
|---|---|---|
| 优势 | 同义、改写、跨语言 | 精确术语、错误码、人名 |
| 弱项 | 精确匹配差 | 同义改写的找不到 |
做法:两路各召回一批,合并后重排。
这就是生产级 RAG 的标配——只用向量,等于放弃了精确匹配能力。
四、阶段四:重排(Reranker)
召回拿回 20 条,但真正相关的可能只有 3 条。重排的作用是在这 20 条里精确排序。
为什么召回后还要重排
| 召回(Retrieval) | 重排(Reranking) | |
|---|---|---|
| 目标 | 快,从百万条里捞出 20 条 | 准,在这 20 条里排好序 |
| 机制 | 向量相似度(廉价、近似) | 交叉编码器(昂贵、精确) |
| 数量 | 全库 | 只有候选集 |
核心逻辑:先用便宜的方法缩小范围,再用贵的方法精确排序。 这是典型的"漏斗"设计。
重排的收益
原文把它列为"进阶"手段,但实际效果往往很显著:因为向量相似度高不等于"能回答问题"。重排器会把问题与每段文本一起过一遍模型,判断"这段能不能回答这个问题"。
五、四步的完整链路与失败排查
离线:文档 → 切片 → 向量化 → 入库
在线:问题 → 向量化 → 召回 → 重排 → 拼装 → 生成
当 RAG 答错时,按这个顺序排查:
| 步骤 | 检查 | 如果错了 |
|---|---|---|
| ① 原文里有答案吗? | 人工确认 | 没有 → 不是 RAG 问题,是知识缺失 |
| ② 答案所在的那一块被切出来了吗? | 看切片结果 | 没有 → 改切片策略 |
| ③ 那一块被召回了吗? | 看召回列表 | 没有 → 改检索方式(混合/改查询) |
| ④ 召回后被重排到前面了吗? | 看重排结果 | 没有 → 加重排或调参 |
| ⑤ 模型读到后答对了吗? | 只给那一块,看它能否答对 | 不能 → 提示词/模型问题 |
这个顺序不能跳。 跳过 ② 直接去调提示词,是最常见的浪费。
六、常见误区
误区一:chunk 越大越好,信息更完整。
太大导致检索不精确、Token 浪费。采用父子分块可以两头兼顾。
误区二:切片按固定字符数就行。
有结构的文档应按标题切。语义边界 > 字符数。
误区三:只有向量检索就够了。
放弃了精确匹配能力(错误码、专有名词)。
误区四:索引和查询可以用不同模型。
向量空间不一致,结果全错。必须同一个模型。
误区五:重排是可选优化。
在生产环境里往往是把"能用"变成"好用"的关键一步。
误区六:RAG 答错先改提示词。
先查检索链路(第五节的五步排查)。多数错误在 ②③ 步,不在 ⑤ 步。
七、动手:一次完整的四阶段实现
7.2 讲了四个阶段。这里给出每一步可跑的代码。
阶段一:切片
def split_by_heading(text, max_len=800):
"""按 Markdown 标题切,超长的再按段落切"""
sections = re.split(r'^(#{2,3})\s+(.+)$', text, flags=re.M)
out, cur_title, cur_body = [], "前言", sections[0]
for i in range(1, len(sections), 3):
yield_chunks("", sections[i-1], cur_title, max_len, out)
cur_title, cur_body = sections[i+1], sections[i+2]
yield_chunks("", cur_body, cur_title, max_len, out)
return out
def yield_chunks(_, body, title, max_len, out):
for para in body.split("\n\n"):
for piece in [para[i:i+max_len] for i in range(0, len(para), max_len)]:
if piece.strip():
out.append({"title": title, "text": piece.strip()})
关键:每块都带 title —— 这会在检索时拼进文本,让块"自解释"(7.3 的上下文头)。
阶段二:向量化
def index(chunks):
for c in chunks:
# 注意:把标题拼进去再嵌入,效果明显更好
c["vec"] = embed(f"{c['title']}\n{c['text']}")
store.upsert(chunks)
这一行 f"{title}\n{text}" 是被低估的技巧 —— 它让"这段属于哪一节"的信息进入向量。
阶段三:召回(混合)
def retrieve(q, k=20):
vec_hits = store.search(embed(q), top_k=k) # 向量
kw_hits = store.bm25(q, top_k=k) # 关键词
return dedupe(vec_hits + kw_hits) # 合并去重
阶段四:重排
def rerank(q, candidates, top_n=5):
scored = [(c, cross_encoder_score(q, c["text"])) for c in candidates]
return [c for c, _ in sorted(scored, key=lambda x: -x[1])[:top_n]]
四个阶段的时间分配(经验值)
| 阶段 | 耗时占比 | 能否优化 |
|---|---|---|
| 切片 | 离线,一次 | 不占在线时间 |
| 向量化 | 离线 | 不占在线时间 |
| 召回 | 5-10% | 加索引 |
| 重排 | 60-80% | 减 top_n |
重排是线上延迟的主要来源 —— 所以它只在"候选集"上做,不在全库做。
八、动手:增量索引——别每次都全量重建
7.2 讲的四阶段里,"向量化"是离线的一次性成本。但文档会变。
三种更新策略
| 策略 | 做法 | 成本 |
|---|---|---|
| 全量重建 | 删库重算 | 高(与库规模成正比) |
| 按文档增量 | 只重算变化的文档 | 中 |
| 按块增量 | 只重算变化的块 | 低(但需块级 diff) |
按文档增量的实现
def sync(docs):
existing = {d["id"]: d for d in store.all_docs()}
for d in docs:
old = existing.get(d["id"])
if old is None:
index_doc(d) # 新增
elif old["hash"] != d["hash"]:
store.delete_by_doc(d["id"]) # 删旧块
index_doc(d) # 重算
existing.pop(d["id"])
# 处理删除
for gone in set(existing) - {d["id"] for d in docs}:
store.delete_by_doc(gone)
三个必须处理的细节
| 细节 | 为什么 |
|---|---|
| 先删旧块再插新的 | 否则同一文档有新旧两份,检索会串 |
| 用内容 hash 判断变化 | 比 mtime 可靠(改时间不改内容不算变) |
| 处理"文档被删除" | 常被漏,导致检索到已下线的文档 |
一个真实事故
事故:文档更新后,检索结果里同时出现新旧两个版本
用户困惑:"到底哪个是对的?"
根因:只做了 insert,没做 delete
这是增量索引最常见的 bug —— 而它不会报错,只是悄悄多出一份。
九、动手:一个 RAG 的验收清单
7.2 的四阶段做完,用这张表验收。
| # | 检查 | 不合格的后果 |
|---|---|---|
| 1 | 查询与文档用同一个 embedding 模型吗? | 向量空间不一致,全错 |
| 2 | 切片有重叠吗(10-15%)? | 跨界信息丢失 |
| 3 | 切片保留标题吗? | 块不自解释 |
| 4 | 有混合检索(向量+关键词)吗? | 精确匹配失效 |
| 5 | 有重排吗? | "相关但无用"排前面 |
| 6 | 返回前做了去重吗? | 同一内容重复占位 |
| 7 | 索引支持增量更新吗? | 每次重建 |
| 8 | 有归因(引用)吗? | 无法核查 |
第 1 条是最致命的 —— 而且它不会报错,只是"结果都不太相关"。
一个真实的排查故事
现象:RAG 上线后,检索结果都"沾边但不准"
排查顺序:
① 切片 → 看着正常
② 关键词检索 → 能搜到
③ 向量检索 → 搜不到
④ 检查 embedding → 建索引用 A 模型,查询用 B 模型
修复:统一模型,重建成索引
这类问题很典型:症状模糊("不太准"),根因是配置不一致。
十、动手:一个"检索质量"的回归测试集
7.2 的四阶段要能持续验证。这里给出测试集格式。
# tests/retrieval.yaml
- query: "getUserById 在哪里定义"
expect_titles: ["auth 模块 API"]
expect_contains: "getUserById"
kind: 精确匹配
- query: "怎么配置数据库连接池"
expect_titles: ["数据库配置指南", "性能调优"]
kind: 语义检索
- query: "缓存和数据库一致性问题"
expect_titles: ["缓存设计", "分布式一致性"]
kind: 跨文档
- query: "退款要几天"
expect_titles: ["售后政策"]
expect_contains: "7 个工作日"
kind: 事实查询
四个字段的作用
| 字段 | 用途 |
|---|---|
query |
真实用户问法 |
expect_titles |
应该出现在 top-5 里的文档 |
expect_contains |
应当召回的具体内容 |
kind |
便于分类统计 |
判定标准
通过条件:expect_titles 里至少 1 个出现在 top-5
且 expect_contains(若有)出现在 top-5 的文本中
三种必测的查询
| 类型 | 为什么必测 |
|---|---|
| 精确匹配 | 检验关键词检索是否生效 |
| 语义检索 | 检验向量检索是否生效 |
| 事实查询 | 检验能否召回具体数值 |
三类都过,检索链路才算基本可用。
一个实战价值
有了这个测试集,你才敢改切片参数 —— 否则每次调参都是"感觉好像变好了",无法验证。
十一、动手:一个 RAG 系统的上线前压测
7.2 的四阶段上线前要压测。三个必测项。
压测一:并发下的延迟
import concurrent.futures as cf
def load_test(queries, concurrency=10):
with cf.ThreadPoolExecutor(concurrency) as ex:
t0 = time.time()
results = list(ex.map(answer, queries * 10))
return (time.time() - t0) / len(results)
关注 P95 而不是平均 —— 平均好看但 P95 崩了,用户体验一样差。
压测二:超大文档
测试:一个 5 万字的文档
期望:能切、能索引、检索时不超时
风险:单文档块数过多,检索变慢
压测三:空库与超大库
| 场景 | 期望行为 |
|---|---|
| 库为空 | 明确返回"资料库为空",不是崩溃 |
| 库有 100 万条 | 检索延迟不显著上升(靠 ANN 索引) |
三个压测的价值
| 压测 | 抓什么问题 |
|---|---|
| 并发 | 连接池、限流、锁 |
| 超大文档 | 切片、单点超时 |
| 边界库 | 空指针、索引退化 |
这三项在新系统上线前各跑一次,能挡掉大部分"上线才发现"的问题。
十二、下一篇
切片这件事值得单独一篇——因为它对结果的影响最大,而且有多个微妙权衡。
下一篇专讲切片策略:粒度、重叠、边界、父子分块,以及中文文档的特殊处理。
一个上线顺序建议
① 先上"能跑"的最小版本(30 行,7.1)
② 加混合检索 + 重排(收益最大的一步)
③ 加归因(引用来源)
④ 最后才优化切片参数
很多人反过来——先纠结切片参数,而重排还没做。 按这个顺序,每一步都能立刻看到提升。
这个顺序的价值:先做收益最大的,而不是先做最"技术"的。
"先做收益最大的",是工程判断力,不是技术深度。
这也是一个团队协作的判据:谁在做"收益最大"的事,谁在纠结细节。
RAG 的四个阶段,优先级的顺序是:重排 > 混合检索 > 归因 > 切片调参。