RAG 工程与检索系统设计

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

作者 杨艺韬 · 14,231 字 · 发布于 · 更新于

Chunk 不是一段字符切片,是一个可检索的语义单元——分块策略决定了一段知识能不能被当作整体召回。

本章要点

  • 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 同步、审计日志。」

这段 100 来字的文本按"每 50 字一 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。选它的时候要分清两件常被混为一谈的事:

第一件是模型的硬限长——这是查得到的事实,超过就会被截断:

  • bge-small-zh-v1.5、E5 系列:最大序列长度 512 token,超出直接截断
  • bge-m3:支持 8192 token 输入
  • OpenAI text-embedding-3-small / text-embedding-3-large:都是 8192 token 上限(OpenAI Embeddings 指南,2026-09-25 核对;注意 small 和 large 的限长一样,差别在维度和效果,不在长度)

第二件是"多长的 chunk 效果最好"——这是经验问题,没有普适答案。硬限长只给出上界;实际甜点区往往远小于上界,因为 chunk 越长、向量越"均值化"(下面就讲这个)。所以限长照抄模型卡,甜点区必须自己扫。

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

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

overlap

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

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

split_boundary

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

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

Python 生态的 RecursiveCharacterTextSplitter(LangChain 的 langchain_text_splitters)和 SentenceSplitter(LlamaIndex 的 llama_index.core.node_parser)都实现了这套"逐级降级找切点"的机制。自己写也不难——20 行代码。

但有个中文场景必踩的坑:RecursiveCharacterTextSplitter 的默认 separators 只有 ["\n\n", "\n", " ", ""](langchain-core-1.2.26/libs/text-splitters/langchain_text_splitters/character.py:104)——一个中文标点都没有。中文没有词间空格,于是默认配置下一段没有空行的长中文会一路降级到 "",也就是从字符中间硬切。中文语料必须把 。!?; 显式传进 separators;同时注意 RecursiveCharacterTextSplitter 的 keep_separator 默认是 True(即 "start"),切下来的句号会挂到下一块的开头,想让「。」留在句尾要传 keep_separator="end"(langchain-core-1.2.26/libs/text-splitters/langchain_text_splitters/character.py:98,拼接逻辑在同文件 _split_text_with_regex)。

一份配置样例

# LangChain:RecursiveCharacterTextSplitter 的五个参数
# (chunk_size / chunk_overlap / length_function 定义在基类 TextSplitter 上,
#  见 langchain-core-1.2.26/libs/text-splitters/langchain_text_splitters/base.py:47;
#  separators、keep_separator 是 RecursiveCharacterTextSplitter 自己的,见同目录 character.py:95)
from langchain_text_splitters import RecursiveCharacterTextSplitter

def count_tokens(text: str) -> int:
    ...  # 用下游 Embedding 模型的 tokenizer 计数,自己实现

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # 不传的话默认 4000,几乎肯定不是你要的
    chunk_overlap=80,    # 不传的话默认 200
    separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
    keep_separator="end",  # 默认 True 等同 "start",句号会跑到下一块开头
    length_function=count_tokens,  # 按 tokenizer 计,不是字符数
)

注意 length_function——必须用下游 Embedding 模型的 tokenizer 计算长度,不能按字符数。同样 500 字符,纯中文和纯英文的 token 数能差出好几倍:英文平均几个字符才合一个 token,中文在不少 tokenizer 里接近甚至超过一字一 token。按字符数配 chunk_size,同一个数字在不同语种上对应的 token 数完全不同,中文 chunk 尤其容易越过 512 这类限长。

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 的 SemanticSplitterNodeParser(llama_index.core.node_parser)实现了这套逻辑(llamaindex-0.14.24/llama-index-core/llama_index/core/node_parser/text/semantic_splitter.py:263):

# 伪代码
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)  # breakpoint_percentile_threshold 默认就是 95
boundaries = [i for i, d in enumerate(distances) if d > threshold]
chunks = split_by_boundaries(sentences, boundaries)

语义分块的实操问题

  • 延迟高:每句都要 embedding,10 万字文档要几千次推理。离线解析时可接受,实时索引不行
  • 阈值漂移:不同 domain 的"话题切换"幅度不同,固定阈值不通用。需要按 domain 校准或用自适应阈值
  • 过度敏感:短文本、口语化对话可能每句话都话题漂移,切出一堆碎片 chunk。实现上有个缓冲旋钮:buffer_size(默认 1)会把每句和它前后各 N 句合并后再算 embedding,值越大越平滑、越不容易被单句噪声骗出切点

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

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

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

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

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

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

6.6 Contextual Chunk:2024 年的关键进展

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

例子:

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

再做 embedding。Anthropic 在若干知识域(代码库、小说、arXiv 论文、科学论文)上公开的 top-20 检索失败率是这样一条链(同一组数字在第 3 章 §3.1 也引用过):

  • 纯 Embedding 召回:5.7%
  • 换成 Contextual Embeddings:3.7%(相对下降 35%)
  • 再叠加 Contextual BM25:2.9%
  • 再加上重排序: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。这一步是 Contextual Chunk 能落地的前提——没有 cache,每个 chunk 都要把整篇文档重读一遍,成本按 chunk 数 × 文档长度 增长;有了 cache,重复的那部分按缓存读取价计费。省多少直接取决于厂商的缓存读取折扣,查当期定价页
  • 增量更新:文档变了,所有 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,但不花一分 LLM 成本,值得作为默认基线先上。

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 验证。

参数扫描要跑成什么样

这一节不给具体的 recall 数字——任何一张"chunk_size 500 时 recall 0.81"的表都只对产出它的那个语料有效,抄过来只会误导。要给的是扫描的做法和你该看的形状。

跑一次扫描,就是把下面这张表的最后三列在自己的 gold set 上填满:

chunk_size overlap strategy recall@10 MRR 索引 chunk 数
200 0 fixed ? ? ?
400 60 fixed ? ? ?
600 100 fixed ? ? ?
1000 150 fixed ? ? ?
400 60 structured ? ? ?
400 60 structured + Contextual ? ? ?

填完之后,看三件事:

  1. fixed 策略的 recall 应该是一条倒 U 形曲线——太小上下文不够、太大主题发散。找到那个峰值,它就是你这份语料的甜点区。如果曲线单调,说明扫描区间没覆盖到峰值,往两边再扩。
  2. 同 chunk_size 下 structured 应该优于 fixed——保留 section 结构是最便宜的一档收益。如果没优势,多半是第 5 章的解析没把结构抽出来,先回去查解析。
  3. Contextual 应该在 structured 之上再加一档——Anthropic 公开的数字是 top-20 失败率从 5.7% 降到 3.7%(§6.6)。那是他们的语料上的降幅,不是你的;你要确认的只是方向一致、以及这一档收益值不值它的 LLM 成本。

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

Gold set 怎么构造

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

  • 多样性:覆盖短问题、长问题、简单事实、复合推理、否定问、冷僻查询。长度分布最好照着真实 query 日志的分布配,而不是照搬某个固定比例
  • 真问题:从真实用户日志采样 >> 人工编造。后者会带编写者的偏见
  • 标注 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% 固定长度

这张表给的是扫描区间的起点,不是任何语料上的甜点值——按 index.md 的口径,chunk 甜点长度属于「必须自测」那一类,本专栏不给结论数。它的用法是:照着对应那一行圈出扫描范围,再按 §6.9 的做法在自己的 gold set 上扫出峰值。

多 embedding 模型的分块差异

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

可以确定的是硬限长(§6.3 给过):bge-small-zh-v1.5 和 E5 系列 512 token,bge-m3 8192、OpenAI text-embedding-3 系列 8192。规律也很直观——训练序列短的模型,甜点区也短:512 限长的模型不要往 500 token 上顶,长文本模型才有资格把 chunk 放大。

但"某模型的最佳 chunk_size 是多少"这件事不存在通用答案,它同时取决于模型、语种和你的问题分布。所以实操顺序是:先选定 embedding 模型,再在这个模型上扫 chunk_size,不要先定 chunk_size 再选模型。大多数项目倒过来做了——先按"感觉"切了 500 token、再强行适配某个模型,结果事倍功半。

Hybrid Chunking:多粒度索引

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

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

成本很好算:建几套索引就是几倍的存储和 embedding 开销。收益则要自己在 gold set 上量——它救的是那些"单一粒度怎么调都召不回"的查询,如果你的问题分布本来就集中在一种粒度上,多索引白花钱。多出来的这几套索引怎么折成钱,可以代进第 11 章 §11.8 的成本模型。

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 章讨论过)——多语言增量解析库。伪代码:

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 可显式请求展开

成熟的代码助手一般是两者混用——先懒加载补最近的调用目标,再靠预建的符号图处理跨文件跳转。对大型代码库 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 建议从 400-600 token 起步折中,但这是强行平衡,不是最优解。多级 chunking 是 2023-2024 年逐渐成熟的模式——小 chunk 用于检索、大 chunk 用于喂 LLM,同时拿到两端优势。主流框架都提供了现成实现:LangChain 的 ParentDocumentRetriever、LlamaIndex 的 AutoMergingRetriever 都是这个模式。

注意 LangChain 1.x 的包搬家:ParentDocumentRetriever 在 0.x 时代是 from langchain.retrievers import ParentDocumentRetriever,1.x 把这一批遗留组件整体挪进了独立发行的 langchain-classic 包,现在要写 from langchain_classic.retrievers import ParentDocumentRetriever(langchain-core-1.2.26/libs/langchain/langchain_classic/retrievers/parent_document_retriever.py:11)。网上大量教程还停在旧路径,照抄会直接 ImportError。LlamaIndex 一侧则是 from llama_index.core.retrievers import AutoMergingRetriever。

粒度的两难

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 是常见的起步值,同样要在自己的 gold set 上扫。

和 Contextual Retrieval 的关系

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

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

两者叠加:child chunk 的 embedding 包含 Contextual 前缀,检索命中后返回 parent——宏观 + 中观 + 精确匹配三维度齐全。需要说明的是,这套叠加是本专栏的组合建议,不是 Anthropic 那篇工程博客的做法——Contextual Retrieval 讲的是给 chunk 加文档级前缀再配 BM25 与 rerank,并没有引入 parent-child 层级;两者能不能叠出收益,要在自己的 gold set 上验证。

去重的工程细节

多个 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_id 取正文的查询,这是一次额外的存储往返
  • 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 上限——错误。能塞进去和塞进去效果最好是两件事,后者的甜点长度通常远小于 max input。

只有第一列是可查证的事实,照抄模型卡即可:

模型 Max input(模型卡)
bge-small-zh-v1.5 512
bge-m3 8192
OpenAI text-embedding-3-small / -large 8192
voyage-3 系列 32000

第二列——"这个模型在我的语料上的甜点长度是多少"——本专栏不给数字,因为它同时取决于模型、语种、文档类型和问题分布,抄来的值只会把你带偏。把 chunk 开到远超甜点(比如把 512 限长的 bge-small 顶到 1000 token)会先触发截断,再往下就是向量质量和 recall 一起降。

甜点曲线该长什么样

对目标 embedding 扫一遍 chunk size,你会得到两条方向相反的曲线:

  • recall 关于 chunk size 是一条倒 U 形——太短信息密度低,太长向量被均值化、还可能超训练分布。峰值就是这个模型在这份语料上的甜点。
  • 存储和 chunk 数关于 chunk size 单调下降,而且是可以直接算的:chunk 数 ≈ 总 token 数 ÷ (chunk_size − overlap),存储 ≈ chunk 数 × 维度 × 每维字节数。所以把 chunk_size 翻倍,索引规模大致减半。

这两条曲线的交点才是你要的取舍:recall 峰值往往不在存储最优处,要按业务容忍度选。换个模型或换个语料,曲线就变——必须对目标 embedding 实测,不能复用别人的结论。

联合调优流程

不要分别调、要联合测:

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

要填的是这样一张矩阵(格子里放你自己测出的 recall@10):

Embedding chunk=200 400 600 800 1200
bge-small-zh-v1.5 ? ? ? ?(已超 512 限长) ?(已超限长)
bge-m3 ? ? ? ? ?
text-embedding-3-small ? ? ? ? ?

读这张矩阵有两个要点。第一,先划掉超出模型限长的格子——512 限长的模型在 800 / 1200 列上测到的其实是"截断后的前 512 token",那个数字没有意义。第二,最高分未必是最终选择:短 chunk 的模型如果 recall 只差一点、而延迟和成本低一档,在延迟敏感的场景里可能才是对的选择。选的是组合,不是单点最优。

Chunk 长度分布的重要性

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

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

控制分布:

  • 结构化分块时,小段落合并到目标长度
  • 超长段落强制切分到 max 阈值
  • 给分布定一个目标,例如大部分 chunk 落在 [甜点 × 0.7, 甜点 × 1.3] 区间(比例和宽度按自己的语料定)

特殊情况:embedding 支持多粒度

现代 embedding 模型(bge-m3、text-embedding-3)兼容多长度——限长足够长,150 token 的 child 和 800 token 的 parent 都能完整编码、不被截断。这让 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_norm:未归一化模型输出向量的 L2 norm 分布,看有无离群(归一化模型恒为 1,这一项不用看)

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

常见反模式

  • chunk size 拍脑袋:选 500 因为"看起来合理"、不实测
  • 换 embedding 不改 chunk:从 bge-small 升到 bge-m3、但仍用 chunk=200——没用上 bge-m3 长 context 能力
  • chunk 分布过宽:没控制长度分布、效果不稳
  • 只测 recall 不看成本:chunk_size 减半,chunk 数和向量条数大致翻倍(索引内存、存储跟着翻倍),反过来也一样——小 chunk 换来的那点 recall 涨幅未必买得起。扫描表里必须同时有 recall 一列和成本一列

跨 embedding 迁移的 chunk 重考虑

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

  • 限长 512 的模型换成限长 8192 的模型,甜点区大概率整体右移——但移到哪要重扫,不能照抄别人的数
  • 沿用为旧模型调出来的 chunk size,等于没用上新模型的长上下文能力
  • 正确做法是把 §6.9 那次扫描重跑一遍,然后全量重 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 预算
  • 检索的业务延迟

这些约束交叉出的"窗口"可能很窄——先把这四条上界各自写下来、取交集,再在交集里扫 recall,顺序反了就可能扫出一个 rerank 根本吃不下的 chunk size。

联合调优的工程价值

认真做联合调优的团队,收益来自三个方向,而且都是可以自己算出来的:

  • 成本:索引规模 ≈ 总 token ÷ (chunk_size − overlap)。chunk_size 差一倍,向量条数、存储和索引内存就差近一倍(embedding 按 token 计费的账单则基本只随 overlap 变,总 token 数不因 chunk_size 改变)
  • 延迟:候选集和索引规模相关,rerank 的耗时随候选数线性增长
  • 精度:把 chunk 调到目标 embedding 的甜点上,recall 会从倒 U 形曲线的侧翼回到峰值——具体涨多少,就是你扫描矩阵里峰值和当前值的差

这三项都用自己的数代进去算,加起来通常不是小数目——代价只是跑一次扫描。

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())
    cutoff_30d = time.time() - 30 * 24 * 3600
    for c in chunks:
        if c.id not in retrieved_ids and c.indexed_at < cutoff_30d:
            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 时一键查

一次搭好、之后按季度小修——投入不大,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 * avg_tokens_per_chunk * price_per_token  # 单价查当期定价页
    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 / measured_chunks_per_hour,  # 用自己压测的吞吐
    }

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

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

成本要按当期单价现算,式子很简单:chunk 数 × 每 chunk 平均 token 数 × 单价。以 1000 万 chunk、每 chunk 500 token 为例,就是 50 亿 token;按 text-embedding-3-large 的挂牌价($0.13 / 百万 token,OpenAI 定价页,2026-09-25 核对)代进去,50 亿 × 0.13 / 100 万 约 $650;自托管 GPU 则按自己的机时和折旧另算。再加上蓝绿双份索引多存 72 小时的存储——embedding 与存储这部分硬成本大致在四位数美元以内,另需 2–3 人周工程时间,对大规模项目换来的长期质量提升明显划算。用之前请查当期定价页重算一遍,embedding 单价这几年一直在降。

验证、回滚与进度可见

没有 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 17:50
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 元编程》专栏第 4 章的 Deserializer / Visitor 可以对照着看——Deserializer 把输入拆成 Visitor 能逐个消费的值(seq 的元素、map 的键值对),核心约束也是"一段能自洽消费"。

更深一层:无论是 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 带着业务边界进入索引,保证每个用户检索时只看得到自己应该看到的知识。