3907 字
约 13 分钟
12
7.2 切片、嵌入、召回、重排四步
2026-10-25
无标签

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 的四个阶段,优先级的顺序是:重排 > 混合检索 > 归因 > 切片调参。

7.2 切片、嵌入、召回、重排四步
https://clxhxhhr.top/posts/4898/
作者
clxstart
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。