文档切块策略:从固定长度到语义切块
切块是 RAG 里最容易被拍脑袋决定的参数,「按 500 字切」抄下来只要一秒钟。但 chunk 是检索单元,它的设计目标是「能独立回答一个问题」。这篇从切口复现讲到结构感知、父子块和语义切块,给出参数怎么定的完整思路。
切块参数是 RAG 里最容易被拍脑袋决定的一环。网上的教程大多写着「按 500 字切、重叠 50 字」,照抄只要一秒钟,但真按这个跑起来,会遇到一类很难排查的现象:检索明明命中了正确的文档,模型答出来的内容却是半截的。
原因通常不在模型,而在切的那一刀。
一、先看清 chunk 到底是什么
一个常见的理解偏差是把 chunk 当成「为了塞进模型而做的文本压缩」。按这个理解,切块的目标就是「尽量少切几刀」,块越大越好。
这个理解是错的。
chunk 是检索单元,不是生成单元。 它的设计目标是:能独立回答一个问题。
理解这一点,后面所有的取舍都顺了:
| 如果 chunk 是「生成单元」 | 如果 chunk 是「检索单元」 |
|---|---|
| 目标是塞得下,越大越省事 | 目标是被精确命中,要刚好装下一个完整语义 |
| 关心的是 token 上限 | 关心的是一个块里的内容是不是同一个主题 |
| 切块是格式问题 | 切块是检索精度的设计问题 |
关键在于:向量是按 chunk 生成的,所以一个 chunk 只能表达一个语义中心。如果一块里塞了三个主题,它的向量就是三个主题的平均值——跟哪个主题都不太像,检索时谁都匹配不精准。反之,如果一句话被从中间切断,那半句话的向量就是不完整的,它承载的信息也就丢了。
二、复现一次「切口事故」
假设原文是这么一段:
[原文]
X30 洗地机的滚刷采用防缠绕设计,通过梳齿结构把毛发刮入集尘盒,
避免缠绕在滚刷上影响清洁效果。该机型整机吸力为 18000Pa,
电池续航约 45 分钟,适合 100-150 平米户型。
[按固定 100 字硬切,切口落在第 2 行中间]
chunk_1: "...X30 洗地机的滚刷采用防缠绕设计,通过梳齿结构把毛发刮入集尘盒,避免缠绕在滚刷上影响清洁效果。该机型"
chunk_2: "整机吸力为 18000Pa,电池续航约 45 分钟,适合 100-150 平米户型。..."
现在用户问「X30 的吸力是多少」:
- chunk_1 里出现了「X30」和「吸力」的语境(洗地机、清洁),但没有数值
- chunk_2 里有「18000Pa」,但丢了主语——它开头是「整机吸力为」,型号信息留在了上一块里
嵌入模型按语义相似度排序,很可能会把 chunk_1 排到前面(因为它同时包含型号和「吸力」这个词的语义场),但它里面没有答案。模型拿着这块内容,要么说「资料中没有」,要么——更糟——顺着上下文猜一个数值出来。
这就是「检索命中了,答案却是错的」的典型成因。
把这一刀挪到句子边界上,问题立刻消失:
[按句子边界切]
chunk_1: "X30 洗地机的滚刷采用防缠绕设计,通过梳齿结构把毛发刮入集尘盒,
避免缠绕在滚刷上影响清洁效果。"
chunk_2: "X30 洗地机的整机吸力为 18000Pa,电池续航约 45 分钟,
适合 100-150 平米户型。"
chunk_2 同时包含了型号、参数名和数值,它可以独立地回答「X30 的吸力是多少」。注意 chunk_2 开头补上了「X30 洗地机的」——这是下一节要讲的元数据注入。
三、结构感知 + 递归切块
生产环境默认的组合是结构感知 + 递归切块,顺序不能颠倒:
原文
│
├─ 第 1 层:按文档的天然结构切
│ 标题层级(H1 / H2 / H3)→ 章节 → 段落 → 列表项
│ ↓ 如果某一级切出来仍然超长
├─ 第 2 层:在该单元内部按分隔符递归细分
│ 段落 \n\n → 换行 \n → 中文句末 。!? → 分号 ; → 逗号 , → 空格
│ ↓ 如果还是超长
└─ 第 3 层:才退回到按长度硬切
并按比例回填 overlap
这个顺序背后的逻辑很简单:
文档作者已经用标题和段落告诉你「哪些内容属于同一个话题」了,这是最可靠的语义边界信号,而且是免费的。按长度硬切等于把这个信号丢掉。
落到实现上,常见的做法是用 RecursiveCharacterTextSplitter 这类带分隔符优先级列表的切分器,并针对中文调整分隔符顺序(中文没有空格分词,。、;、!、? 应该排在前面的优先级)。技术文档、法律文本这类结构清晰的资料,也可以自研按标题层级切分的逻辑——因为在切块的同时还要把标题路径写进元数据,自己控制反而更顺手。
元数据注入:块要能自己说清自己是谁
切完的 chunk 不该是裸文本,每一块都要带上描述自己的字段:
| 字段 | 作用 | 不带的后果 |
|---|---|---|
| 文档 ID / 标题 | 溯源、去重 | 命中后无法定位原文 |
| 章节路径 | 补充上下文 | 「如上一节所述」这类指代失效 |
| 文档类型 | 检索前路由与过滤 | 问售后却召回商品参数 |
| 生效时间 / 版本 | 时效性过滤 | 召回已下线的旧政策 |
| 序号 chunk_index | 还原顺序、定位切口 | 无法判断答案是否跨块 |
| 权限标签 | 数据隔离 | 越权内容被检索出来 |
还有一个实用技巧:把标题路径拼进 chunk 文本本身。比如在每段前加上「X30 洗地机 > 产品参数 > 吸力」。这样即使切块时上下文被剥离,嵌入模型也能从文本里看到主题词,召回准确率会明显改善。
四、参数怎么定
块大小:300~800 中文字符
业界的常见区间是 300~800 中文字符,按 token 折算大致对应 300~500 token。这个区间不是拍出来的,两端各有明确的失败模式:
| 块太小(< 200 字) | 块太大(> 1000 字) |
|---|---|
| 一句话被切断,语义不完整 | 一块里塞了好几个主题,检索粒度粗 |
| 指代丢失(「它」「该机型」指向不明) | 向量是多个主题的平均值,跟谁都不像 |
| 检索命中率高但可用率低 | 大块天然带噪声,精排压力大 |
| TopK 要调很大才够用 | 上下文 token 浪费,还稀释注意力 |
用一张对照表把这种关系说清楚(下表是同一批语料、同一嵌入模型下不同切块粒度的方向性对照,数值随语料和模型变化,重要的是趋势):
| 块大小 | 单块语义完整性 | 向量区分度 | Recall@5 | 上下文噪声 |
|---|---|---|---|---|
| 150 字 | 低(常被切断) | 高 | 中(命中但缺信息) | 低 |
| 400 字 | 高 | 高 | 高 | 中 |
| 800 字 | 高 | 中 | 中高 | 中高 |
| 2000 字 | 高 | 低 | 中(靠 Rerank 救) | 高 |
可以看到 400~800 字这一段是「语义完整性」和「向量区分度」同时可接受的重叠区。低于这个区间,损失的是信息完整性;高于这个区间,损失的是检索精度。 两者都不可逆——切碎了后面补不回来,切大了后面也很难拆开。
重叠:10%~20%
重叠存在的唯一理由是:防止答案正好落在切口上。
无重叠:
chunk_1: "...电池续航约 45 分钟。"
chunk_2: "适合 100-150 平米户型。"
→ 问「续航和适用面积」时,两块各答一半
有重叠(15%):
chunk_1: "...电池续航约 45 分钟,适合 100-150 平米户型。"
chunk_2: "电池续航约 45 分钟,适合 100-150 平米户型。...后续内容"
→ 无论切口落在哪,两块里总有一块是完整的
按 400800 字、10%100 token** 是同一个量级。20% 重叠折算,重叠量大致是 **50100 字**,和按 token 折算的 **overlap 50
重叠也不是越大越好:它按比例放大存储和向量化成本。20% 的重叠意味着同样的内容要多算 20% 的向量、多占 20% 的索引空间,而且检索结果里会出现大量近似重复的片段,挤占 TopK 名额。所以 10%~20% 是个务实的区间,不是越大越保险。
一张参数速查表
| 文档类型 | 建议块大小 | 重叠 | 特殊处理 |
|---|---|---|---|
| 技术文档 / API 手册 | 300~500 token | 10% | 按标题层级切,保留代码块完整性 |
| 法律 / 政策条款 | 按「条」切 | 15% | 一条就是一块,绝不跨条切 |
| 商品参数表 | 按行 / 按参数项切 | 0~10% | 转成「型号 + 参数 + 数值」文本 |
| 对话 / 工单记录 | 按轮次切 | 20% | 保留说话人角色 |
| 长篇小说 / 报告 | 500~800 token | 15% | 按章节切,章节内再递归 |
五、表格和特殊结构:最容易被忽略的一类
表格是 RAG 里翻车最集中的地方,原因是解析器和切块器都不把表格当回事。
一个 PDF 里的商品参数表,常见结局有三种:被 pypdf 抽成一堆顺序错乱的单元格文本;被识别成图片完全丢失;被按行切开后每行只剩数值、丢了表头。三种结局都会导致同一类问题:
用户问「X30 的吸力多少」,知识库里有这个数字,但检索不到。因为向量库里存的是「18000」「45」「100-150」这种孤立数字,或者一段毫无结构的字词流。
处理办法是把表格显式地转成自然语言句子,在切块之前完成:
[原始表格]
型号 吸力 续航 适用面积 滚刷
X30 18000Pa 45min 100-150㎡ 防缠绕
[转换后,每个数据行生成一条独立文本]
"X30 洗地机:整机吸力 18000Pa,电池续航 45 分钟,
适用面积 100-150 平方米,滚刷类型为防缠绕滚刷。"
[合并成一条也可以,但一行一条的检索粒度更细]
转换之后,每个数据行都是一句完整的话,包含型号、参数名、数值三要素,能独立回答一个参数类问题。这一步看起来笨,但它解决的是结构信息在解析阶段就已经丢失的问题——等到检索阶段再想办法就晚了。
同类需要特殊处理的结构还有:代码块(不要从中间切开,否则语法不完整)、公式(转成文本描述)、多级列表(保留层级缩进)、页眉页脚(解析阶段就应该清掉)。
六、父子块:小块检索,大块生成
前面几节一直有个没解决的矛盾:
- 块要小,检索才准(语义单一、区分度高)
- 块要大,生成才好(上下文完整、信息充分)
父子块就是同时满足这两条的方案:
父块(大,比如 2000 字,含完整章节)
┌────────────────────────────────────────┐
│ 3.2 滚刷设计与清洁能力 │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ 子块 1 │ │ 子块 2 │ │ 子块 3 │ │ ← 子块各 300~500 字
│ │ 防缠绕 │ │ 吸力参数 │ │ 续航 │ │
│ └──────────┘ └──────────┘ └────────┘ │
└────────────────────────────────────────┘
检索阶段:只对「子块」的向量做相似度计算 → 定位精确
命中之后:取该子块所属的「父块」送进 Prompt → 上下文完整
实现上需要维护一张映射表(子块 ID → 父块 ID),向量库里存的是子块向量。检索命中子块后,按映射取出父块原文,必要时还可以把相邻子块一起带上。
| 只用小块 | 只用大块 | 父子块 | |
|---|---|---|---|
| 检索精度 | 高 | 低 | 高(用子块检索) |
| 送入生成的上下文 | 不完整 | 完整但含噪声 | 完整(用父块生成) |
| 存储开销 | 1x | 1x | 约 1.2x(父子两套文本) |
| 生成 token 开销 | 低 | 高 | 高(父块更长) |
| 实现复杂度 | 低 | 低 | 中(要维护映射) |
代价是清楚的:token 消耗变高、实现变复杂。所以它适合「精度要求高、且预算允许」的场景,不适合无脑全量上。
七、语义切块:效果更好,但贵且慢
前三节的切法都是基于形式的——标题、段落、句子、长度。语义切块换了个思路:用嵌入向量本身来判断哪里该切。
① 先按句子切开(粗粒度)
② 对每个句子算 embedding
③ 计算相邻句子的余弦相似度
④ 相似度骤降的位置 = 话题发生了转换 → 在这切一刀
⑤ 再按最大长度约束合并/拆分
句子: S1 S2 S3 S4 S5 S6 S7
相似度: .91 .88 .35 .87 .90 .31 .85
↑ ↑
切一刀 切一刀
结果: [S1 S2 S3] [S4 S5 S6] [S7 ...]
它的好处是能识别出没有标题的结构化程度低的文本里的话题边界——比如长篇会议记录、口语化的工单、没有小标题的说明文档。这些场景下结构感知无能为力,语义切块是唯一的办法。
代价也很明确:
| 维度 | 结构感知 + 递归 | 语义切块 |
|---|---|---|
| 依据 | 标题、段落、标点 | 相邻句的嵌入相似度 |
| 离线成本 | 低,纯文本处理 | 高,每个句子都要算 embedding |
| 速度 | 快 | 慢 |
| 边界稳定性 | 确定,可复现 | 受模型和阈值影响,换模型结果就变 |
| 适用 | 有结构的文档 | 无结构的长文本 |
所以务实的策略是分级使用:
默认走结构感知 + 递归切块;只有在「无结构 + 高价值」的关键场景(比如核心业务的口语化记录)才上语义切块。
八、改完怎么验证
切块策略改完,不能凭感觉说「好像好点了」。至少要盯这几个信号:
| 观察到的现象 | 大概率是切块的问题 | 往哪个方向调 |
|---|---|---|
| 召回命中但答案缺关键信息 | 答案被切口截断 | 检查是不是硬切;调大 overlap |
| 召回的块跟问题只是「沾边」 | 块太大,一个块多个主题 | 缩小块大小 |
| 答案跨在两块中间,两边都不完整 | 结构边界没被识别 | 改按结构切;启用父子块 |
| 参数类问题完全召不回 | 表格没被正确解析 | 表格转自然语言再入库 |
| 结果里大量近似重复片段 | overlap 过大 | 把重叠降到 10%~15% |
对照的指标也随之确定:切块属于检索侧,改动应该体现在 Context Recall 上。如果改完切块发现忠实度没变,那说明改的方向不对——召回没变化,生成侧自然也不会变。
小结
- chunk 是检索单元,设计目标是「能独立回答一个问题」,不是「塞得进模型」
- 切块顺序:先结构、再分隔符递归、最后才按长度硬切;文档作者的标题层级是最可靠的免费语义信号
- 参数区间:300
800 中文字符(约 300500 token),重叠 10%~20%;两端各有明确的失败模式,不是越大或越小越好 - 每个 chunk 必须带元数据(来源、章节、类型、生效时间),把标题路径拼进文本本身能明显改善召回
- 表格要在切块前转成「型号 + 参数 + 数值」的自然语言,否则参数类问题必然召不回
- 想要「小块检索、大块生成」就上父子块,代价是 token 和实现复杂度
- 语义切块效果更好但贵且慢,只在无结构 + 高价值的关键场景用
- 切块属于检索侧,改完要看 Context Recall 有没有涨——改切块却盯着忠实度看,等于白改
切块这件事最反直觉的地方在于:它对最终答案的影响,比换一个更强的模型大得多,但它的成本几乎为零。 大多数 RAG 效果不好的案例,根因都能追到「切得不对」上,而不是「模型不够强」。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。