RAG 工程与检索系统设计

第6章 Chunking:固定分块、结构化分块与语义分块

作者 杨艺韬 · 12,153 字 · 发布于 · 更新于

"A chunk is not a span of characters. It is a unit of retrievable meaning." — Chunking 里的第一原理

本章要点

  • Chunk 不是字符切片,是可检索的语义单元——分块策略决定了下游能不能把某段知识作为整体召回
  • 三大策略各有边界:固定长度(最简单、覆盖场景最广)、结构化(保留文档层级、适合带结构的源)、语义(按内容相似度切分、对话/叙事类最佳)
  • 三条工程参数贯穿所有策略:chunk_size(token 上限)overlap(重叠 token 数)split_boundary(优先切分点)
  • 2024 年出现的 Contextual Chunk(Anthropic)把每个 chunk 前加一段 LLM 生成的文档级 context——显著抬升了检索准确率
  • Chunk 质量不能靠感觉评估——必须用端到端召回率(是否命中 gold chunk)量化

6.1 为什么 chunk 不是字符切片

第 5 章把原始文档变成了 blocks + metadata 的 IR。本章要回答的问题:每个 block 应该作为一个整体送给下游 Embedding,还是该继续切细?

朴素答案是"按固定 token 数切"。但这会破坏语义。看一个真实例子:

「新版企业版套餐中,私有化部署是包含的增值服务之一,但 SSO 功能需要额外购买企业增强包。企业增强包的定价为每年 20,000 元,包含以下能力:SAML 2.0 集成、OIDC 支持、LDAP 同步、审计日志。」

这段 150 字的文本按"每 80 字一 chunk、无重叠"切会变成两个 chunk:

  • chunk 1:「新版企业版套餐中,私有化部署是包含的增值服务之一,但 SSO 功能需要额外购买企业增强包。企业增强包」
  • chunk 2:「的定价为每年 20,000 元,包含以下能力:SAML 2.0 集成、OIDC 支持、LDAP 同步、审计日志。」

用户问"企业增强包包含什么能力"——chunk 2 是匹配的,但 chunk 2 本身没提"企业增强包"四个字(被切到 chunk 1 末尾了)。Embedding 模型拿 chunk 2 的文本算向量,query "企业增强包 能力" 的相似度可能很低。这就是语义完整性被分块切断的典型失败,对应第 3 章 §3.3 子模式 2。

chunk 的好坏,本质是回答一个问题:当这个 chunk 作为唯一上下文交给 LLM 时,LLM 能不能独立理解它、并从中得到该有的信息?——如果不能,chunk 就有缺陷,需要更好的切法、或更大的 overlap、或额外 context 注入。

6.2 三大策略全景

flowchart TB
    classDef fixed fill:#fed,stroke:#c60
    classDef struct fill:#def,stroke:#06c
    classDef semantic fill:#efe,stroke:#080
    IR[IR blocks] --> Q{分块策略}
    Q --> S1[固定长度]:::fixed
    Q --> S2[结构化]:::struct
    Q --> S3[语义]:::semantic
    S1 --> C1["按 token/字符切<br/>+ overlap + 优先切点"]
    S2 --> C2["按 heading / list / 代码块<br/>边界切"]
    S3 --> C3["Embedding 相邻句距离<br/>超阈值处切"]
    C1 & C2 & C3 --> CHUNK[(chunks)]

三种策略并非互斥——生产系统通常混用:结构化为主、固定长度作 fallback、语义分块用在叙事/对话类。选型参考:

策略 适用源 优点 缺点
固定长度 非结构化文本、任意 简单、可控、延迟低 容易切断语义
结构化 Markdown、HTML、代码、技术文档 保留文档层级 依赖解析质量、长节需再切
语义 对话记录、叙事文本、客服工单 切点和意义对齐 需要 embedding 推理、慢

6.3 固定长度分块:三个关键参数

最朴素也最实用。三个参数决定效果:

chunk_size

单 chunk 的 token 数上限。典型取值 200-1000 token,与 Embedding 模型的训练语料长度对齐:

  • bge-small / text-embedding-3-small: 训练时多数样本 < 512 token,chunk 200-400 最佳
  • bge-m3 / text-embedding-3-large: 支持 8192 token 输入,chunk 可放大到 800-1500
  • Contextual / ColBERT / E5: 类似 512 token

太小:单 chunk 信息不足,检索时很多相关 chunk 但都不完整。太大:单 chunk 主题发散,Embedding 向量"均值化"——每个方向的相似度都平淡,难以精准命中。

实操起点:先选 400-600 token 作为默认,然后按本章 §6.8 的评估方法用 gold set 调优。

overlap

相邻 chunk 的重叠 token 数。典型 10%-20% 的 chunk_size(即 50-150 token)。作用是弥补固定切断造成的边界损失——关键句子被切到下一 chunk 开头时,前一 chunk 的结尾有相同文本,保证至少一个 chunk 语义完整。

overlap 的代价:索引膨胀 —— 100 万字原文用 500 token chunk + 100 overlap,总 chunk 数约是无 overlap 的 1.25 倍。Embedding、存储、检索成本都相应增加。生产实操取 10-15% 是甜点区。

split_boundary

"优先切分点"——切到 chunk_size 上限时,往前/往后找最近的自然边界再切,而不是从字符中间硬切。优先级从高到低:

  1. 段落分隔(\n\n
  2. 句号( . ! ?
  3. 分号、逗号(次优,中文里逗号很弱)
  4. 空格(不得已时)
  5. 字符边界(终极兜底)

Python 生态的 RecursiveCharacterTextSplitter(LangChain)和 SentenceSplitter(LlamaIndex)都实现了这套优先级。自己写也不难——20 行代码。

一份配置样例

# LangChain 风格
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=80,
    separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
    length_function=count_tokens,  # 按 tokenizer 计,不是字符数
)

注意 length_function——必须用下游 Embedding 模型的 tokenizer 计算长度,不能按字符数。中英混文本里同样 500 字符,token 数可能从 300(纯中文)到 800(纯英文)差很多。按字符数配 chunk_size 会导致英文 chunk 远超 embedding 限长。

6.4 结构化分块:让文档的层级说话

Markdown、HTML、代码等带结构的源,最好按结构切而不是按字符切。好处:

  • 切点天然语义完整(小节、列表、代码块是完整单元)
  • chunk metadata 可以继承结构(section_path="产品规格 > SSO 配置")
  • 下游引用时能精准定位到"哪一节的哪一段"

按 heading 分块

Markdown / HTML 的 H1-H6 标题构成层级树。结构化分块的基本策略:

  • 叶子节点(最深层 heading 下的内容)作为独立 chunk
  • 若某个叶子内容 < chunk_size,合并到上一级 heading 下
  • 若某个叶子内容 > chunk_size,用固定长度再切(fallback)
flowchart TB
    H1[H1 产品规格] --> H2A[H2 功能列表]
    H1 --> H2B[H2 定价]
    H2A --> H3A[H3 身份认证]
    H2A --> H3B[H3 数据集成]
    H2B --> CHUNK_B[Chunk B<br/>直接作为一个 chunk]
    H3A --> CHUNK_A1[Chunk A1<br/>身份认证正文]
    H3B --> CHUNK_A2[Chunk A2<br/>数据集成正文]
    style CHUNK_A1 fill:#efe
    style CHUNK_A2 fill:#efe
    style CHUNK_B fill:#efe

每个 chunk 带 section_path=["产品规格", "功能列表", "身份认证"] 作为 metadata。检索时可以按 section_path 前缀过滤("只看功能列表相关的 chunk")、也可以让 LLM 在 prompt 里显式引用。

表格和代码块的特殊处理

表格和代码块不能按行切——破坏语义的典型场景。策略:

  • 小表格(< chunk_size):整个表格 + caption + 相邻段落作为一个 chunk
  • 大表格(> chunk_size):保留表头 + 部分行作为一个 chunk,下一个 chunk 复制表头 + 接下来的行。表头复制不是 overlap,是为了让每个 chunk 独立可读
  • 代码块:优先作为一个整体 chunk。超长代码用 AST(第 5 章 §5.6)按函数/类切,不按行切

一个常见反模式:表格被硬切成两段后,第二段失去表头"产品 | 价格 | 版本"的上下文,embedding 看到的就是一堆数字。用户问"企业版价格"——第二段命中但模型看不懂、还得靠第一段补。

列表的处理

长列表(20+ 项)切分时保留列表标识:

原文:
- SAML 2.0 集成
- OIDC 支持
- LDAP 同步
- ... (20 项)
- 审计日志导出

chunk 1:
以下是企业增强包包含的能力(第 1-10 项):
- SAML 2.0 集成
- OIDC 支持
...

chunk 2:
企业增强包能力(接上,第 11-20 项):
- ... 
- 审计日志导出

每个 chunk 都自解释——告诉读者"这是某个列表的一部分"。这样检索到 chunk 2 时模型知道这是完整列表的后半段、而不是孤立信息。

6.5 语义分块:用相似度找切点

结构化分块依赖文档有明确结构。对话记录、会议纪要、访谈实录这类长段连续叙事没有 heading,固定长度又可能切断话题切换——需要语义分块

基本思路:把相邻句子的 Embedding 距离作为"话题相似度"信号,距离突然变大的地方就是切点。LlamaIndex 的 SemanticSplitter 实现了这套逻辑:

# 伪代码
sentences = split_to_sentences(text)
embeddings = [embed(s) for s in sentences]
distances = [1 - cosine(embeddings[i], embeddings[i+1]) for i in range(len(sentences)-1)]
threshold = percentile(distances, 95)  # 取分布的 95 分位作为切点阈值
boundaries = [i for i, d in enumerate(distances) if d > threshold]
chunks = split_by_boundaries(sentences, boundaries)

语义分块的实操问题

  • 延迟高:每句都要 embedding,10 万字文档要几千次推理。离线解析时可接受,实时索引不行
  • 阈值漂移:不同 domain 的"话题切换"幅度不同,固定阈值不通用。需要按 domain 校准或用自适应阈值
  • 过度敏感:短文本、口语化对话可能每句话都话题漂移,切出一堆碎片 chunk

实操建议:语义分块只用在值得的场景——访谈、会议纪要、用户与 Agent 的长对话。普通文档用结构化 + 固定长度组合更划算。

LLM 分块(2024 年以后的新路径)

用 LLM 直接判断分块点是近年新兴方案。Prompt 例如:

以下是一段文档。请在语义转折处插入 <SPLIT> 标记,让每段成为独立可检索的单元。每段 200-400 字。

优点:切点质量极高、对长文档和专业领域有效。缺点:成本(每段文档一次 LLM call)、延迟(几秒到几十秒)、一致性(同一段文档跑两次结果可能不同)。

常见妥协:用小模型(Llama 3.1 8B、Qwen 2.5 7B)本地推理做 LLM 分块,成本可接受。SOTA 的 RAG 系统在值得精加工的核心知识库上会用这种策略。

6.6 Contextual Chunk:2024 年的关键进展

Anthropic 在 Contextual Retrieval 中提出的思路:每个 chunk 在 embedding 前,先用 LLM 生成一段文档级 context 加到 chunk 前面

例子:

  • 原 chunk:「企业增强包的定价为每年 20,000 元...」
  • Contextual chunk:「这段文字来自《企业版产品规格 V3》的"定价"章节,描述增强包定价。具体内容:企业增强包的定价为每年 20,000 元...」

再做 embedding。效果(Anthropic 公开数据):

  • 纯向量 retrieval,top-20 错误率 5.7%
  • 加 Contextual Chunk 后 top-20 错误率 3.7%(下降 35%)
  • 再加 BM25 混合 + Rerank,综合错误率 1.9%

Context 由 LLM 基于"完整文档 + 当前 chunk"生成。Anthropic 把这一步集成进 prompt cache——整份文档只读一次进 cache,每个 chunk 查询时只算 delta,显著降本。

实操要点

Contextual Chunk 的三个工程要点:

  • 模型选型:Claude Haiku / GPT-4o-mini 这类小模型足够——context 生成是低复杂度任务
  • prompt cache:完整文档作为 cacheable prefix,每个 chunk 用这个 cache。成本可降 80%+
  • 增量更新:文档变了,所有 chunk 的 context 理论上要重生成。实际上如果变动集中在局部,只重建被影响的 chunk 更划算

第 14 章 Rerank 会讨论 Contextual Chunk + Rerank 的组合效果。

为什么 Contextual Chunk 管用

从第一性原理看,Contextual Chunk 解决的是一个信息损失补偿问题。原 chunk 在分块时丢失了两类信息:

  • 结构信息:这是哪份文档、哪一节、上下文是什么
  • 指代信息:chunk 里的"它""这个""上述"指的是什么

向量检索阶段,模型只看 chunk 文本。丢失的结构和指代信息让 embedding 表达不准确——"它"在不同文档里意思完全不同,但 embedding 分不清。加上文档级 context 后,embedding 能把"这段 chunk 讨论的是 X 产品的 Y 功能"这一锚定信息编码进去。

这也解释了为什么 BM25(只看字面)加 Contextual Chunk 效果同样好——context 里显式出现"企业版""SSO"等关键词时,BM25 能通过这些词命中更相关的 chunk。

Contextual Chunk 的失败场景

不是所有场景都适合:

  • context 生成耗时:实时索引场景(流式处理用户上传的文档)不能等 LLM 生成 context
  • 长文档成本高:几百页的书,每个 chunk 都要看全文生成 context,即使有 cache 也有成本
  • 语言覆盖:多数 LLM 对小语种(阿拉伯语、泰语)的指代解析还不稳定

对这些场景可以用轻量 context替代——用规则抽文档 title + section path 拼到 chunk 前,不走 LLM。效果不如 full Contextual Chunk,但接近 60-70% 的收益、0 成本。

6.7 常见分块反模式

在给具体策略打磨参数之前,先列清楚哪些做法直接扣分——它们不是"可以调参数解决的次优方案",而是"无论怎么调都不如别的"。

反模式 1:按字符数切中文。中文的 token 数和字符数不是 1:1 关系,不同 tokenizer 差异也大。按字符数切可能把 chunk_size 定到远高于 embedding 模型限长。正确做法:先 tokenize,按 token 切。

反模式 2:切完不再验证。跑完 chunker 直接送下游,没人检查有没有 chunk 只有一个单词、没有 chunk 是纯 metadata、没有 chunk 被切成 2 个字符。生产 chunker 的输出必须过 validator:chunk 长度必须在 [min_len, max_len] 区间、文本必须非空、token 数必须合规。

反模式 3:全局一套参数。知识库里 50% 是技术文档、30% 是客服 FAQ、20% 是合同——用同一个 chunk_size=500 处理所有文档肯定不是最优。按 doc_type 配置不同分块策略。

反模式 4:分块后丢弃结构。解析阶段辛苦抽出的 section_path、table_caption、list_context,分块后全成了纯文本 chunk 的一部分,metadata 里没保留。结构化信息在分块环节必须传递下去——它是 retrieval 和引用的重要输入。

反模式 5:sentence-level 分块。有人把每句话作为 chunk,embed 后检索。句子太短——上下文缺失导致 embedding 语义漂移。除非语料极特殊(格言、FAQ 问答 Q),否则单句 chunk 效果远不如段落 chunk。

反模式 6:overlap 过大。有人把 overlap 设到 50% 甚至更多,以为"重叠越多越保险"。代价是索引膨胀 2 倍、检索时一个问题召回同内容的多个 chunk、上下文冗余。10-20% 是经验值,超过 30% 一定错。

6.8 重叠、去重和 metadata 继承

Overlap 的两种形态

固定长度分块的 overlap 是"尾 - 头复制"。结构化分块有两种 overlap:

  • sliding overlap:相邻叶子 chunk 各自复制对方一部分——成本高但语义连续性最好
  • header overlap:每个 chunk 前面加上文档 title + 上级 section path——模拟"你现在在读哪一节"。对检索帮助大、成本低

推荐默认策略:结构化分块 + header overlap + 语义边界偏好。

去重:防止同内容 chunk

知识库里常有同一段文字出现在多份文档里(模板条款、公司介绍段落、重复的 FAQ)。索引时若不去重,检索会返回多份相同内容的 chunk,上下文浪费。

去重策略:

  • 内容 hash 去重sha256(chunk.text) 相同的只保留一份、其他用 reference 指回
  • 近似去重:simhash 或 minhash,相似度 > 0.95 的 chunk 合并
  • 保留来源链接:去重后保留被合并 chunk 的 source_ids 列表——检索时能还原所有来源

metadata 继承

每个 chunk 从 IR metadata 继承基础字段(doc_id、publish_date、access_level、language)、再加上自己的结构字段(chunk_id、section_path、start_token、end_token、overlap_prev、overlap_next)。完整 chunk schema:

{
  "chunk_id": "prod-spec-v3#7#a1b2",
  "doc_id": "prod-spec-v3",
  "text": "企业增强包的定价...",
  "context": "这段文字来自《...",
  "metadata": {
    "section_path": ["产品规格", "定价", "增强包"],
    "publish_date": "2026-03-15",
    "access_level": "internal",
    "language": "zh",
    "start_char": 4200,
    "end_char": 4750,
    "overlap_prev": "prod-spec-v3#6#c3d4",
    "overlap_next": "prod-spec-v3#8#e5f6",
    "token_count": 480,
    "source_ids": ["prod-spec-v3", "prod-spec-v2"]
  }
}

6.9 怎么评估分块策略

chunk_size、overlap、strategy 的选择不能靠感觉——需要端到端指标。两条路线:

Retrieval-only 评估

构造一批 QA pair(约 50-200 条),每条标注应该命中哪个 chunk(gold chunk)。不同分块策略跑一遍,对比:

  • recall@k:gold chunk 是否在 top-k 召回
  • MRR:gold chunk 的倒数排名平均
  • gold coverage:gold chunk 是否至少完整(没被切断)

注意 gold chunk 会随分块策略变化——不同 chunk_size 下 gold chunk 的边界不同。评估时要动态确定 gold chunk:找到包含答案文本的最小 chunk 集合。

End-to-end 评估

跑完整 RAG 链路,看最终答案的 faithfulness、准确率。用 ragas / trulens 等自动评估框架。这条路线更真实,但慢且受生成模型质量影响。

实操顺序:先 retrieval-only 快速筛掉明显差的策略,再对 top-3 候选做 end-to-end 验证。每次策略变更都跑一次——chunking 是 RAG 质量的强杠杆,任何改动都要 A/B 验证。

实测参数扫描的典型结果

下面是一个真实企业 FAQ 知识库上做参数扫描的典型数据(约 5000 篇 FAQ,gold set 200 条 QA):

chunk_size overlap strategy recall@10 MRR 索引 chunk 数
200 0 fixed 0.68 0.42 18k
400 60 fixed 0.78 0.55 12k
600 100 fixed 0.81 0.58 8k
1000 150 fixed 0.75 0.51 5k
400 60 structured 0.85 0.62 10k
400 60 structured + Contextual 0.91 0.71 10k

三个观察:

  1. chunk_size=600 是 fixed 策略的峰值——小于它上下文不够、大于它主题发散
  2. 结构化分块一步跳升 5-7 个点——保留 section 结构就是便宜的收益
  3. Contextual Chunk 在 structured 基础上再加 6 个点——Anthropic 的数据在企业场景可复现

数据因 domain 而异——法律、代码、新闻的甜点区不同。但跑一次参数扫描的成本远低于猜一个参数踩半年坑的成本。每次接一个新知识库都做这件事。

Gold set 怎么构造

Gold set 的质量决定评估的可信度。构造要点:

  • 多样性:覆盖短问题、长问题、简单事实、复合推理、否定问、冷僻查询。30% 短 + 50% 中 + 20% 长 是经验分布
  • 真问题:从真实用户日志采样 >> 人工编造。后者会带编写者的偏见
  • 标注 gold chunk 而非 gold answer:问题是"正确答案在哪段知识库"而不是"正确答案是什么"。前者可用于 retrieval 评估、后者只能做 end-to-end
  • 定期刷新:业务和知识库都在变,gold set 每季度 review 一次、补 50 条新样本、去掉过时的

一个 200 条的 gold set 是 MVP、500-1000 条是生产标配。超过 1000 条后边际收益递减,把精力放在分层(按场景分桶)更有价值。

6.10 真实参数的经验区间

在若干公开和实际项目里观察到的典型参数:

场景 chunk_size overlap 主策略
通用企业知识库 400-600 token 15% 结构化 + 固定 fallback
代码检索 按函数/类 0(AST 边界) AST 结构化
法律/合同 800-1200 token 10% 结构化按条款
学术论文 按 section 0-10% 结构化按章节
对话/工单 300-500 token 20% 语义分块
新闻/博客 400-800 token 15% 固定长度

这些是起点,不是终点。每个系统都要用 gold set 微调。

多 embedding 模型的分块差异

同一份文档、同样的分块策略,送给不同 embedding 模型,效果可能差异很大。原因是每个模型对 chunk 长度的敏感度不同:

  • bge-small-zh-v1.5:最佳 chunk_size 在 200-400 token。> 500 显著下降
  • bge-m3:长文本友好,500-1500 token 都表现稳定
  • text-embedding-3-small:8192 限长但 sweet spot 在 300-600
  • voyage-2:超长文本(> 2000 token)上表现比多数模型好

实操:选定 embedding 模型后再调 chunk_size,不要先定 chunk_size 再选模型。大多数项目倒过来做了——先按"感觉"切了 500 token、再强行适配某个模型,结果事倍功半。

Hybrid Chunking:多粒度索引

有些生产系统采取多粒度并行索引——同一份文档以多种 chunk_size 同时切分、建多个索引。查询时按问题类型路由:

  • 细粒度问题(问具体参数、条款)→ 查 small chunk 索引(200 token)
  • 综合性问题(问概念、流程)→ 查 large chunk 索引(1000 token)

成本是 2-3 倍索引空间。收益是 recall 峰值提升 5-10%。大型系统(像 Perplexity、You.com)据信在用类似多索引策略。第 11 章讨论向量数据库时会回到多索引的工程成本。

flowchart LR
    Q[用户问题] --> CLF[问题分类]
    CLF -->|细粒度| IDX_S[(small chunks<br/>200 token)]
    CLF -->|综合| IDX_L[(large chunks<br/>1000 token)]
    IDX_S & IDX_L --> FUSE[融合排序]
    FUSE --> OUT[top-k]

6.11 代码、表格与对话:特殊内容的分块

前 10 节默认文档是散文性质——段落连续、层级清晰、按语义或结构切即可。但企业知识库里有大量非散文内容:代码仓库、产品规格表、客户访谈记录、会议纪要。这些内容按通用分块策略切、recall 会断崖式下降。每种格式需要专门的分块方法。

flowchart TB
    classDef prose fill:#def,stroke:#06c
    classDef special fill:#fed,stroke:#c60
    P[散文文档<br/>§6.3-§6.6 通用策略]:::prose
    S[特殊内容]:::special
    S --> S1[代码<br/>AST-based]:::special
    S --> S2[表格<br/>行列单位]:::special
    S --> S3[对话<br/>turn-based]:::special

代码的 AST-based 分块

代码是最需要专门分块的格式——按字符或行切必然破坏语义。正确做法是按 AST(抽象语法树)的自然边界切:

  • 函数级:每个函数 / 方法一个 chunk
  • 类级:对于紧凑小类、整个类一个 chunk;大类按方法切
  • 模块级:小模块或单文件的 import + 顶层声明作独立 chunk

工具链:tree-sitter(第 5 章讨论过)——多语言 AST 解析库、GitHub Copilot / Cursor 都在用。伪代码:

def chunk_code(source, language):
    tree = tree_sitter.parse(source, language)
    chunks = []
    for node in tree.walk():
        if node.type in ["function_definition", "method_definition", "class_definition"]:
            chunks.append({
                "text": node.source,
                "symbol_name": extract_name(node),
                "type": node.type,
                "lines": (node.start_line, node.end_line),
            })
    return chunks

代码 chunk 的 metadata

代码 chunk 的 metadata 比散文丰富:

字段 用途
symbol_name 函数 / 类名、精确匹配的主 key
symbol_type function / class / method
language Rust / Python / Go、filter
file_path 展示引用时显示
line_range 跳转到源码的精确位置
imports 该 chunk 依赖哪些外部符号
docstring 提取的文档注释、独立索引
signatures 函数签名、作为"精确召回锚点"

有了这些 metadata、代码 RAG 就能:

  • language=rust filter
  • symbol_name 精确查找(复用 ch12 BM25)
  • imports 做依赖图查询

跨符号引用的处理

代码的特殊挑战:一个 chunk 可能引用另一个 chunk 里定义的符号。比如 UserService.login 的函数 chunk 里调用了 validatePassword——但 validatePassword 定义在另一个 chunk。

两种应对:

  • 懒加载:召回 UserService.login 后、解析出它调用的符号、按需补充调用目标的 chunk
  • 预关联:入库时建立符号调用图、chunk 的 metadata 里标 calls: ["validatePassword", "getUserById"]、检索时 LLM 可显式请求展开

Copilot Chat 用的是懒加载 + 符号图混合。对大型代码库 RAG 是必要的。

表格的分块

表格用散文分块必然坏。正确做法:

  • 每行一个 chunk:chunk 内容 = 列头 + 当前行数据(如 | 产品 | 价格 | 功能 | 企业版 | 20000/年 | SSO、LDAP、审计 |
  • 每 N 行分组:长表按 10-20 行分组、每组带列头
  • 小表整块:< 20 行的表作为单 chunk、保留完整结构

关键:每 chunk 都要带列头(第 16 章 §16.15 呼应)——否则下游看到一堆数字没意义。

对话 / 访谈文档的分块

会议纪要、客户访谈、对话日志的特点:按 turn 组织、有 speaker、有时间戳。分块策略:

  • 每轮发言一个 chunk:适合简短 turn
  • 按话题切:用语义分块检测话题转换、把连续几 turns 合并(长访谈推荐)
  • 完整回合合并:Q+A 作为一个 chunk(问答对话最自然)

chunk metadata 必备:

{
  "text": "...",
  "speakers": ["张三", "李四"],
  "timestamp_start": "2026-04-20 14:23",
  "timestamp_end": "2026-04-20 14:27",
  "turn_indices": [15, 16, 17]
}

Metadata 让检索能做"张三说过什么 SSO"这类精确查询——纯文本 chunk 做不到。

混合内容 chunk

很多真实文档混合多种格式:产品手册里"功能介绍(散文)+ 规格表(表格)+ 调用示例(代码)"。两条处理思路:

  • 按格式切:散文部分按语义分块、表格单独切、代码按 AST 切——每种内容有最优 chunk 方式。缺点:上下文碎片化、LLM 看到时不知道它们属于同一节
  • 按结构保留:一个完整的"功能说明章节"作为一个 chunk、内部保留 markdown 结构(标题 / 表格 / 代码块)。LLM 读这种 markdown chunk 理解好、但 chunk 体积大

生产推荐:结构性分块优先(按 ## 标题 / 章节切)、单 chunk 内保留混合内容的 markdown 结构。超长的再按格式细分。

特殊内容分块的评估

通用 chunk 的评估(§6.9)对代码 / 表格不完全适用。专门指标:

  • 代码 chunk 的 symbol 完整性:抽样 chunk 看是否完整包含一个函数 / 类、有无半截
  • 表格 chunk 的列头覆盖率:多少 chunk 带有列头(应 100%)
  • 对话 chunk 的 turn 切分正确率:是否在 speaker 切换时切开

这些指标各自独立、分别跑 gold set 评估。通用 recall@k 不能反映这层质量。

什么时候按特殊格式分块

判断依据:

  • 代码占知识库 > 20% → 值得上 AST 分块
  • 表格多、且用户会问表格内容 → 必须表格专门处理
  • 对话 / 访谈文档有检索需求 → 必须按 turn 分块
  • 全散文 → §6.3-§6.6 通用策略够用

早期 MVP 可以全用通用分块应急、但规模化前要按内容类型专门化——否则某类内容的 recall 永远上不去。

6.12 多级 chunking:parent-child 与分层检索

前面几节讨论 chunk size 时都有个困境:小 chunk 检索准但上下文不足、大 chunk 上下文丰富但检索精度降。§6.3 建议 200-500 token 折中、但这是强行平衡、不是最优解。多级 chunking 是 2023-2024 年逐渐成熟的模式——小 chunk 用于检索、大 chunk 用于喂 LLM,同时拿到两端优势。LangChain、LlamaIndex、Anthropic Contextual Retrieval 都在用这个模式。

粒度的两难

flowchart LR
    classDef small fill:#dfd,stroke:#080
    classDef large fill:#fed,stroke:#c60
    S[小 chunk 100-200 token]:::small
    L[大 chunk 500-1500 token]:::large
    S --> S1[语义集中 / 检索精度高]
    S --> S2[上下文不足 / LLM 难推理]
    L --> L1[上下文丰富 / LLM 理解好]
    L --> L2[主题分散 / embedding 不准]

单一粒度、总在 precision 和 context 之间二选一。

Parent-child 结构

多级 chunking 的核心思路:

  • Child chunk(小):100-200 token、用于 embedding 和检索
  • Parent chunk(大):500-1500 token、是一个或多个 child 的父级
  • 检索 → 扩展:query 命中 child chunk 后、返回对应 parent chunk 给 LLM
flowchart TB
    classDef doc fill:#fef,stroke:#808
    classDef parent fill:#fed,stroke:#c60
    classDef child fill:#dfd,stroke:#080
    D[文档]:::doc
    D --> P1[Parent 1 800 token]:::parent
    D --> P2[Parent 2 800 token]:::parent
    P1 --> C1a[Child 1a 150]:::child
    P1 --> C1b[Child 1b 150]:::child
    P1 --> C1c[Child 1c 150]:::child
    P2 --> C2a[Child 2a 150]:::child
    P2 --> C2b[Child 2b 150]:::child
    Q[Query] -.命中.-> C1b
    C1b -.返回 parent.-> P1
    P1 --> LLM[送 LLM]

Query embedding 和 child embedding 匹配——精度高。检索命中 child 后、按 parent_id 查 parent、把 parent 送 LLM——上下文足。

实现细节

数据模型:

{
    "chunk_id": "doc-A:child-5",
    "level": "child",
    "text": "...",           # 150 token
    "embedding": [...],       # child 文本的 embedding
    "parent_id": "doc-A:parent-2",
    "order_in_parent": 2
}

{
    "chunk_id": "doc-A:parent-2",
    "level": "parent",
    "text": "...",           # 800 token,可以重新 embed 也可以不 embed
    "child_ids": ["doc-A:child-4", "doc-A:child-5", "doc-A:child-6"]
}

检索时:

def retrieve(query, top_k=5):
    # 1. 用 child embedding 做相似度检索
    children = vector_store.search(query_embed, 
                                     filter="level=child", 
                                     top_k=top_k)
    
    # 2. 去重 parent(多个 child 命中同 parent 只返回一次)
    parent_ids = list(set(c.parent_id for c in children))
    
    # 3. 拉 parent 文本
    parents = [get_parent(pid) for pid in parent_ids]
    
    return parents[:top_k]  # 返回 parent 给下游

为什么比单级好

对比同一份数据的三种分块:

策略 Child embed 检索精度 上下文完整性
只用小 chunk (200 token) 200 token
只用大 chunk (800 token) 800 token
多级 child 检索 + parent 返回 200 token

多级的 child 用来匹配 query、parent 用来回答 query——两者各司其职。

典型 parent 大小的选择

Parent 的大小不能无限大——太大了浪费 LLM context:

Parent 大小 适用场景
400-600 token 一般 RAG、平衡好
800-1500 token 长答案、复杂推理场景
2000+ token 法律 / 科研文献等深度分析场景

Child 相对 parent 的比例:parent = 3-5 × child 是经验甜点。

和 Contextual Retrieval 的关系

§6.6 的 Contextual Chunk(给每个 chunk 注入文档上下文)和多级 chunking 思路互补

  • Contextual Retrieval:每个 chunk 自带文档宏观上下文(前缀)
  • Multi-level:每个 chunk 自带段落中观上下文(parent)

两者叠加:child chunk 的 embedding 包含 Contextual 前缀、检索命中后返回 parent——宏观 + 中观 + 精确匹配三维度齐全。这是 Anthropic 论文里 SOTA 设置的核心。

去重的工程细节

多个 child 命中同一 parent 时、不能重复返回同一 parent——浪费 context。去重策略:

  • 严格去重:top-k child 映射到 parent、重复的 parent 只留一次。可能 top-5 child 只对应 2 个独立 parent
  • 加权融合:child 命中数作为 parent 的分数加权——两个 child 命中的 parent 比一个命中的更相关
  • 多 parent 合并:命中的 parent 连续(如 parent-1 和 parent-2)时、考虑合并或保留顺序

生产常用严格去重 + 加权——简单且效果好。

多级 chunking 的其他变体

除了经典 parent-child、还有:

  • 三级:child (150t) → section (500t) → document (2000t)——检索命中后按问题复杂度选返回层
  • overlapping parent:parent 之间有 overlap(如每个 parent 覆盖 3 个 child、child 可以属于相邻两个 parent)——减少 boundary 问题
  • 异构 parent:同一文档有不同粒度的 parent(小段 + 大段)、按 query 类型选

超过三级的嵌套通常不值得——复杂度爆炸、收益边际递减。

常见坑

  • Parent 也 embed 进索引:浪费存储、检索精度还不如纯 child
  • Parent 尺寸没控制:有的 parent 几千 token、LLM context 被单个 parent 填满
  • 去重没做:top-5 都是同一 parent、信息浪费
  • 跨文档混用 child:child.parent_id 跨文档、同文档连贯性丢失
  • Parent 更新时忘了同步 child:文档改了 parent 改了但 child embedding 还是旧的——索引不一致

什么时候不用多级

多级不是免费——复杂度增加、运维成本升。以下场景用单级更好:

  • 文档本身短:推文、FAQ 问答——单级 chunk 就行
  • 检索 + 答案都要精确短:如错误码查询——chunk 就是答案
  • 极高 QPS 场景:多级多一次 parent 查询、每请求 +5-10ms
  • MVP 阶段:还没把单级做好、不要上多级

判断:当 single-level chunking 的"精度 vs 上下文"明显卡住时、上多级解锁。还没到瓶颈就上多级是过度工程。

评估多级 chunking

多级的评估要分两层:

  • Child-level recall:child chunk 的检索精度(和单级 chunk 对比)
  • Parent-level answer quality:parent 作 LLM context 后的 faithfulness / answer relevance

两个指标都应比 single-level 好——只一个好说明实现有问题。gold set 要针对性建、确保能测到"精度高 + 上下文好"的联合提升。

6.13 Chunk 和 embedding model 的联合调优

§6.3 讲的 chunk size 参数是"独立决定的"——但 chunk 不是在真空里切、它直接喂给 embedding model、决定检索质量。Chunk 策略和 embedding 模型是耦合的、经常被分开调优导致局部最优、全局次优。这节把两者作为系统整体看、给出联合调优方法。

为什么 chunk 和 embedding 必须联合考虑

flowchart LR
    classDef c fill:#fed,stroke:#c60
    classDef e fill:#def,stroke:#06c
    classDef q fill:#efe,stroke:#080
    C[Chunk 策略]:::c --> Q[检索质量]:::q
    E[Embedding 模型]:::e --> Q
    C -.影响.-> C1[chunk size 决定信息密度]
    E -.影响.-> E1[context window 决定能处理多长]
    E -.影响.-> E2[训练分布决定擅长什么长度]
    Q -.反馈.-> C & E

两者的耦合有三层:

  • Context window 约束:chunk 不能超过 embedding model 最大输入(§9.3)
  • 训练分布:embedding 模型在特定长度分布上训练、远超训练分布性能下降
  • 语义完整性:chunk 切到一半、embedding 的向量表达模糊

Context window 和训练分布的差异

很多工程师把"最大 context"当 chunk size 上限——错误。Embedding 模型的真实"甜点长度"往往远小于 max context:

模型 Max input 训练分布主峰 真实甜点
bge-small-zh 512 ~200 tokens 150-300 tokens
bge-m3 8192 ~500-1000 tokens 300-1200 tokens
text-embedding-3-small 8192 ~1000 tokens 400-1500 tokens
voyage-3 16000 长文本优化 1000-3000 tokens

chunk 远超甜点(比如把 bge-small 用在 1000 token chunk)——向量质量降、recall 降。

实测差异

同一份业务数据、chunk size 从 200 到 2000 的 recall 曲线(bge-m3):

chunk size | recall@10 | 存储(GB) 1M chunks
   200     |   0.87    | 0.8
   400     |   0.94    | 0.8 × 0.5 = 0.4 (chunk 少一半)
   600     |   0.96    | 0.27
   800     |   0.97    | 0.2
  1200     |   0.96    | 0.13
  1600     |   0.93    | 0.1
  2400     |   0.85    | 0.07

观察:

  • 600-800 token是 bge-m3 的实际甜点
  • 超过 1500 token 性能下降(超出训练主峰)
  • 短于 400 token 性能也差(信息密度低)

换个模型这个曲线就变——必须对目标 embedding 实测

联合调优流程

不要分别调、要联合测:

flowchart TB
    S1[准备 gold set] --> S2[选候选 embedding 模型 3-5 个]
    S2 --> S3[每个模型测 chunk size 扫描]
    S3 --> S4[画 model × chunk size 矩阵]
    S4 --> S5[按业务延迟 / 成本 / 精度选组合]

典型扫描矩阵:

Embedding chunk=200 400 600 800 1200
bge-small 0.82 0.84 0.83 0.80 0.72
bge-m3 0.87 0.94 0.96 0.97 0.96
text-embed-3-s 0.83 0.91 0.93 0.94 0.94

最佳组合:bge-m3 + chunk=800(0.97)。但如果延迟要求严、bge-small + chunk=400 也够(0.84)。

Chunk 长度分布的重要性

不是所有 chunk 都要同一长度——分布比均值重要

  • 均值 500 + 标准差 50(窄分布):chunk 长度整齐、embedding 质量稳定
  • 均值 500 + 标准差 400(宽分布):有 100 token 的小 chunk、也有 1500 的大 chunk——短的信息密度低、长的超出模型甜点

控制分布:

  • 结构化分块时、小段落合并到目标长度
  • 超长段落强制切分到 max 阈值
  • 目标:80% 的 chunk 落在 [甜点 × 0.7, 甜点 × 1.3] 区间

特殊情况:embedding 支持多粒度

现代 embedding 模型(bge-m3、text-embedding-3)兼容多长度——短 chunk 和长 chunk 质量差距小。这让 parent-child 多级 chunking(§6.12)实用:

  • Child chunk:150 token、embedding 精确
  • Parent chunk:800 token、embedding 仍可用
  • 同一模型、不需要切换

这种情况 chunk size 的单一取舍变成"粒度组合"取舍——更灵活。

联合调优的监控

上线后持续监控:

  • embed_length_distribution:chunk 输入 embedding 的 token 长度直方图
  • recall_by_chunk_size:按 chunk 长度分桶的 recall
  • embedding_saturation:embedding 向量的 L2 norm 分布——饱和说明 chunk 太短信息不足

分布漂移(比如新数据让 chunk 长度变)——触发重新评估。

常见反模式

  • chunk size 拍脑袋:选 500 因为"看起来合理"、不实测
  • 换 embedding 不改 chunk:从 bge-small 升到 bge-m3、但仍用 chunk=200——没用上 bge-m3 长 context 能力
  • chunk 分布过宽:没控制长度分布、效果不稳
  • 只测 recall 不看成本:chunk=200 recall 0.85、chunk=800 recall 0.95 但成本差 4 倍——可能 chunk=800 不值

跨 embedding 迁移的 chunk 重考虑

ch9 §9.13 讲 embedding 升级要重 embed——同时要重评估 chunk 策略。新模型甜点可能变:

  • 旧 bge-small(甜点 200)→ 新 bge-m3(甜点 800)
  • 老 chunk=250 继续用、浪费 bge-m3 的能力
  • 应该同时改 chunk=800、全量重 chunk + 重 embed

大多数团队 embedding 升级时只换模型不改 chunk——留钱在桌子上。

Chunk 和其他 RAG 组件的全局耦合

Chunk 不只和 embedding 耦合——也和:

  • Rerank:chunk 太长、cross-encoder 截断(ch14 §14.15)
  • Context packing:chunk 太大、top-k 塞不下(ch16)
  • LLM context:chunk 总 token 决定 LLM 看多少

全局最优 chunk size 要同时满足:

  • Embedding 的甜点
  • Rerank 的 512 token 约束
  • LLM context 的 token 预算
  • 检索的业务延迟

这些约束交叉出的"窗口"可能很窄——400-800 token 是多数 RAG 项目的 sweet spot、不是偶然。

联合调优的工程价值

认真做联合调优的团队能省:

  • 成本:存储 / embedding 调用 / 索引内存——小 chunk 可能多 4-10×
  • 延迟:向量库 / rerank 的 QPS 和 chunk 数成反比
  • 精度:对目标 embedding 的 sweet spot 让 recall +3-5 点

这些加起来是 RAG 整体性价比的 10-20% 差距——很值。

6.14 Chunk 的 debug 与异常识别

前面讲了分块策略和评估指标——但具体的 chunk 出了问题怎么查?生产里有几百万 chunk、一条坏 chunk 可能让某类 query 常年召回差。这节给出一套 chunk debug 的方法——从异常批量识别到单个 chunk 调试、帮团队快速定位 chunking 问题。

Chunk 异常的典型症状

flowchart TB
    classDef s fill:#fed,stroke:#c60
    S[Chunk 异常症状]
    S --> S1[某 chunk 从不被召回]:::s
    S --> S2[某 chunk 被召回但无关]:::s
    S --> S3[某 chunk 长度异常<br/>过短或过长]:::s
    S --> S4[某 chunk 内容断裂<br/>切到半句]:::s
    S --> S5[某 chunk 重复度高<br/>多个 chunk 雷同]:::s

每种症状指向不同问题——先分类、再查。

批量识别异常 chunk

遍历索引、找出统计上异常的 chunk:

def find_anomalous_chunks(chunks):
    anomalies = {
        "too_short": [],
        "too_long": [],
        "duplicate": [],
        "never_retrieved": [],
        "broken_boundary": [],
    }
    
    # 1. 长度异常
    token_counts = [len(c.tokens) for c in chunks]
    mean_len = np.mean(token_counts)
    std_len = np.std(token_counts)
    
    for c in chunks:
        if len(c.tokens) < mean_len - 2*std_len:
            anomalies["too_short"].append(c.id)
        if len(c.tokens) > mean_len + 2*std_len:
            anomalies["too_long"].append(c.id)
    
    # 2. 重复
    hashes = {}
    for c in chunks:
        h = minhash(c.text)
        if h in hashes and similarity(c.text, chunks[hashes[h]].text) > 0.95:
            anomalies["duplicate"].append(c.id)
        hashes[h] = c.id
    
    # 3. 从未被召回(结合 30 天的 retrieval log)
    retrieved_ids = set(fetch_recent_retrieved_ids())
    for c in chunks:
        if c.id not in retrieved_ids and c.indexed_at < 30_days_ago:
            anomalies["never_retrieved"].append(c.id)
    
    # 4. 边界断裂(文本以逗号 / 连词开头或结尾)
    for c in chunks:
        if c.text.startswith(("和", "而且", "但是", ",", "、")):
            anomalies["broken_boundary"].append(c.id)
    
    return anomalies

每周跑一次、产出 anomaly report——人工 review 样本、决定是否改 chunking 策略。

单 chunk 的 debug 场景

某 chunk 怀疑有问题、具体查:

def debug_chunk(chunk_id):
    chunk = get_chunk(chunk_id)
    
    print(f"Text (first 500 chars): {chunk.text[:500]}")
    print(f"Length: {len(chunk.tokens)} tokens")
    print(f"Source: {chunk.source_doc_id}")
    
    # 上下文:前后 chunk
    prev_chunk = get_chunk(chunk.prev_id)
    next_chunk = get_chunk(chunk.next_id)
    print(f"Previous chunk ends: ...{prev_chunk.text[-100:]}")
    print(f"This chunk starts: {chunk.text[:100]}...")
    print(f"Next chunk starts: {next_chunk.text[:100]}...")
    # 看边界切得合理吗?
    
    # 向量分布
    print(f"Embedding norm: {np.linalg.norm(chunk.vec)}")  # 应 ≈ 1
    
    # 最近邻:看这 chunk 和谁相似
    neighbors = vector_db.search(chunk.vec, top_k=5, exclude_self=True)
    for n in neighbors:
        print(f"  {n.id}: sim={n.score:.3f}, text: {n.text[:80]}")
    
    # 检索表现:过去 30 天被召回了多少次
    retrievals = query_log.count(chunk.id, last_days=30)
    clicks = click_log.count(chunk.id, last_days=30)
    print(f"Retrieved: {retrievals}, Clicked: {clicks}, CTR: {clicks/max(retrievals,1):.2f}")

这套脚本能回答:

  • Chunk 切得合理吗(看上下文边界)
  • Chunk 的语义邻居是谁(看相似 chunk)
  • Chunk 在生产被用得如何(看召回和点击)

Chunking 策略的对比工具

想知道 "换一种 chunking 策略效果如何"——对同一批文档跑不同策略:

def compare_chunking(docs, strategies):
    results = {}
    for strategy in strategies:
        chunks = strategy.chunk(docs)
        results[strategy.name] = {
            "chunk_count": len(chunks),
            "avg_length": np.mean([len(c.tokens) for c in chunks]),
            "length_std": np.std([len(c.tokens) for c in chunks]),
            "sample_chunks": random.sample(chunks, 5),
        }
    return results

# 使用
compare_chunking(sample_docs, [
    FixedLengthStrategy(size=500),
    StructuredStrategy(boundary="heading"),
    SemanticStrategy(similarity_threshold=0.7),
    ContextualStrategy(context_source="doc_summary"),
])

直接看 sample chunks 的文本、判断哪种切法最合理。比纯看数字直观得多。

异常 chunk 到 chunking 改进

识别了异常不是终点——要回流到策略改进:

  • 边界断裂 chunk 多 → 当前结构化分块失败、换语义分块
  • 超短 chunk 多 → 小段落没合并、加最小长度合并逻辑
  • 超长 chunk 多 → 段落太长没切、加最大长度强制切分
  • 重复 chunk 多 → 去重逻辑不严、加 simhash 去重

异常的分布指导下一版 chunking 策略的改进方向——不是拍脑袋调参。

从 badcase 到 chunk 问题

§20.18 讲 badcase 归因——当归因到 "找不到" 或 "塞不下"、接下来查是否 chunk 问题:

def trace_badcase_to_chunks(badcase):
    # 用户问 query、期望答案是 X
    # 但系统没找到相关 chunk
    
    # 1. 找 doc 里含答案的部分
    gold_doc = find_doc_containing(badcase.expected_answer)
    
    # 2. 这部分在哪个 chunk 里?
    gold_chunks = chunks_containing_text(gold_doc, badcase.expected_answer)
    
    # 3. 这些 chunk 被召回了吗?
    if not gold_chunks:
        print("答案文本被 chunk 边界切碎了!")
        # 关键信息跨 chunk、需要改分块
    else:
        print(f"答案在 chunks: {[c.id for c in gold_chunks]}")
        # chunk 存在、但没被召回——检索问题不是 chunking 问题

这个 trace 让 badcase 归因到具体 chunk——避免"感觉 chunking 不好"的主观判断。

Chunking 的测试用例库

积累 chunking 测试用例:

# chunking-test-cases.yaml
cases:
  - name: "长表格不应被切"
    input: "某包含 50 行表格的文档.md"
    assert: "每个 chunk 里表格完整或不包含表格"
  
  - name: "连续段落应合并到 min length"
    input: "多个 50 字段落的文档"
    assert: "没有少于 200 token 的 chunk"
  
  - name: "代码块边界"
    input: "含代码块的 markdown"
    assert: "代码块不跨 chunk"

每次改 chunking 策略、先过这些测试——防止 regression。

Chunk metadata 的质量检查

Chunking 不只是切文本、还要正确产生 metadata:

  • chunk 的 doc_title 是否来自正确的父 doc
  • chunk 的 section_path 是否正确层级
  • chunk 的 publish_date 是否继承自 doc
  • chunk 的 access_level 是否和 doc 一致

抽样检查 100 个 chunk 的 metadata 完整性——经常发现"title 是正确的、但 section_path 丢了"这种问题。

常见反模式

  • 不做异常识别:垃圾 chunk 长期污染索引
  • 单个 debug 没工具:每次查都从头写脚本、效率低
  • 不看实际 chunks:只看 aggregate 指标、不抽样看文本
  • Badcase 不归因到 chunk:修错地方、chunk 问题一直在
  • 测试用例缺失:改 chunking 策略总 regression

Debug 工具的维护

好的 chunking debug 工具链:

  • Anomaly report:每周自动跑、邮件发到团队
  • Debug UI:输入 chunk_id 看完整信息
  • Compare UI:对比两种策略的 chunks
  • Trace 从 badcase 到 chunk:badcase review 时一键查

初期建 1-2 人月、长期每季度维护 0.5 人天——投入小、debug 效率提升数倍

Chunking 调试的大局观

Chunking 是 RAG 的上游——上游 bug 下游表现各种奇怪。把 debug 工具建起来、让 chunking 变灰盒:

  • 每个 chunk 能追源(从哪个 doc、怎么切的)
  • 每个 badcase 能追 chunk(是 chunk 问题还是检索问题)
  • 每次策略改动有可控的 A/B

好 chunking 不是一次调对、是持续 debug 出来的——和其他 ML 系统一样。

6.15 Chunking 策略变更的迁移工程

项目跑一段时间就会发现当前 chunking 不是最优——太短或太长、按结构切错了、或者来了新的文档类型。但「改 chunking」不是改完代码就完事:已经索引的百万 chunk 要重新切、重新 embed,工程上等同一次局部或全量重建。这节和 §9.13 的 embedding 迁移是一对。

flowchart TB
    classDef cause fill:#fed,stroke:#c60
    R[要改 chunking 的原因]
    R --> R1[新文档类型<br/>旧策略处理不了]:::cause
    R --> R2[效果评估发现<br/>当前 chunk size 偏离甜点]:::cause
    R --> R3[升级 embedding<br/>新模型甜点不同]:::cause
    R --> R4[上多级 chunking<br/>parent-child 结构]:::cause
    R --> R5[支持代码 / 表格<br/>旧策略不够细]:::cause

先估规模,再选策略

动手前先算清楚影响范围——知道规模才能规划

def analyze_migration_scope(old_strategy, new_strategy):
    # 哪些 doc type 会受影响?
    affected_types = diff_affected_types(old_strategy, new_strategy)

    # 影响多少 chunk?
    affected_count = count_chunks_by_type(affected_types)

    # 预估成本(重 embed + 重索引)
    embed_cost = affected_count * 0.00005  # per chunk
    compute_cost = estimate_compute(affected_count)

    return {
        "affected_types": affected_types,
        "affected_count": affected_count,
        "estimated_cost_usd": embed_cost + compute_cost,
        "estimated_duration_hours": affected_count / 10000,  # 大致
    }

规模决定策略。影响超过一半 chunk 的大改用全量重切重建:建绿索引跑新 chunking、shadow 流量验证、切换后蓝索引保留 72 小时再下线。只影响某类文档的(比如新增了代码专用切法)用按类型局部 backfill,其他 chunk 不动,走 §8.13 那套机制。还有一种新老混用——新文档用新策略、老文档保持原样——只适合实在无法迁移时的短期 workaround,长期会让索引变成粒度不一的拼盘,各种怪问题都从这里来。

很多时候 chunking 改动本身就伴随 embedding 改动:新的 chunk size 需要匹配的模型、上 long-context embedding 时 chunk size 从 400 涨到 800、加代码专用 embedding 的同时也要加代码专用 chunking。这类情况合并成一次联合迁移,比分两次各重建一遍索引省得多。

成本上以 1000 万 chunk 为例:用 OpenAI-large 做 embedding 约 $650(按每 chunk 500 token 算),自托管 GPU 约 $50,蓝绿双份索引多存 72 小时约 $100,加上 2–3 人周工程时间。硬成本 $1000 上下,对大规模项目换来的长期质量提升明显划算。

验证、回滚与进度可见

没有 A/B 数据的迁移就是赌博——先切 10% 流量跑两周,比对 recall、faithfulness 和延迟:

# 部分 query 走老 chunking 的 index
# 部分走新 chunking 的 index
# 对比 recall / faithfulness / 延迟

@feature_flag("use_new_chunking")
def query(q, user_id):
    if feature_flag_on("use_new_chunking", user_id, bucket=10):  # 10% 流量
        return query_new_index(q)
    return query_old_index(q)

即便 A/B 看着好,切换后仍可能翻车:faithfulness 掉两个点、某类 query recall 下降、某种文档解析出错导致 chunk 丢失。所以回滚能力要提前备好——新旧索引并存 72 小时、切换做在配置层保证分钟级生效、新索引异常时能自动 fallback 到旧的。没有这条退路,事故就要靠重建老索引来修,代价高出一个量级。

迁移过程往往持续几小时到几天,进度必须实时可见,否则团队只能干等:

Chunking Migration Progress (started 2026-04-25 10:00)
Target: 8.5M chunks in "legal" doc_type
Progress: [████████░░░░] 62% (5.3M / 8.5M)
Rate: 300 chunks/sec
ETA: 2026-04-25 14:30
Failed: 42 (in DLQ)
Estimated cost so far: $420

跑完也不算结束,要持续观察 1–2 周:gold set recall 是否维持或提升、badcase 的数量和类型有无变化、用户满意度与投诉、成本和延迟指标。平稳两周才算迁移成功,才可以下线旧索引。

还有一件比技术更要紧的事:事先和业务方沟通。迁移期间查询可能变慢或结果不一致,静默操作换来的是用户投诉。技术工作几天能搞定,协调不好几周都推不动。

对应的五个常见陷阱正是上面每步的反面:不估规模就开跑(发现要跑好几天)、没 A/B 直接切(切了才知道不如预期)、不保留旧索引(回滚不了)、业务方不知情、不监控进度。

把迁移当成常态能力

节奏上,大迁移(结构大改或 embedding 升级)一到两年一次,小迁移按季度做局部 backfill,Contextual Retrieval、parent-child 这类改进则持续小步加。不要追求一次性设计出完美的 chunking——渐进演化,让每次迁移都保持在可管理的规模。

配套的态度是四条:按季度计划性 review 是否需要迁移、有数据支持再动手、小迁移多大迁移少、每次都留退路。

这套能力(backfill 工具、A/B 框架、进度监控)建一次长期用,它决定的其实是另一件事:chunking 到底是一个可以持续优化的系统,还是一份「上线后没人敢动」的遗产。没有迁移能力的团队,chunking 策略往往多年不变、技术债越积越重,最后放弃优化、质量停滞。这个原则适用于 RAG 的所有组件——先把迁移能力建起来,才谈得上演化

6.16 跨专栏关联:分块与预训练的数据切分

Embedding 模型本身是在某个 chunk 长度分布下训练的。BGE、E5 等模型的训练语料多数是 "512 token 以下的句对"——超长 chunk 在这类模型上的表达能力有限。《Serde 元编程》第 7 章讨论过类似的分块机制——serde 的 zero-copy 反序列化把字节流切成 Visitor 可消费的片段,核心约束也是"一段能自洽消费"。

更深一层:无论是 RAG chunking、分布式数据切片、还是编译器的基本块划分,切点选择都关乎下游消费者的独立可理解性。这是所有"分治"模式的共同哲学。

6.17 本章小结

  1. Chunk 是可检索的语义单元,不是字符切片——分块决定了下游能否把一段知识作为整体召回
  2. 三大策略:固定长度(最广)、结构化(保结构)、语义(按内容切)——生产常混用
  3. 三个工程参数:chunk_size、overlap、split_boundary——按 Embedding 模型特性和 domain 校准
  4. Contextual Chunk 是 2024 年的关键进展——给每个 chunk 注入文档级上下文,显著抬升检索精度
  5. 分块策略必须端到端评估——感觉不可靠,gold set + retrieval metric 才是评分标尺

下一章讨论 metadata 和权限模型——怎么让 chunk 带着业务边界进入索引,保证每个用户检索时只看得到自己应该看到的知识。