AI 与大模型

文档切块策略:从固定长度到语义切块

切块是 RAG 里最容易被拍脑袋决定的参数,「按 500 字切」抄下来只要一秒钟。但 chunk 是检索单元,它的设计目标是「能独立回答一个问题」。这篇从切口复现讲到结构感知、父子块和语义切块,给出参数怎么定的完整思路。

2026-04-09 约 15 分钟 5947 字 RAG 切块 向量检索 大模型

切块参数是 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%20% 重叠折算,重叠量大致是 **50100 字**,和按 token 折算的 **overlap 50100 token** 是同一个量级。

重叠也不是越大越好:它按比例放大存储和向量化成本。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 是检索单元,设计目标是「能独立回答一个问题」,不是「塞得进模型」
  • 切块顺序:先结构、再分隔符递归、最后才按长度硬切;文档作者的标题层级是最可靠的免费语义信号
  • 参数区间:300800 中文字符(约 300500 token),重叠 10%~20%;两端各有明确的失败模式,不是越大或越小越好
  • 每个 chunk 必须带元数据(来源、章节、类型、生效时间),把标题路径拼进文本本身能明显改善召回
  • 表格要在切块前转成「型号 + 参数 + 数值」的自然语言,否则参数类问题必然召不回
  • 想要「小块检索、大块生成」就上父子块,代价是 token 和实现复杂度
  • 语义切块效果更好但贵且慢,只在无结构 + 高价值的关键场景用
  • 切块属于检索侧,改完要看 Context Recall 有没有涨——改切块却盯着忠实度看,等于白改

切块这件事最反直觉的地方在于:它对最终答案的影响,比换一个更强的模型大得多,但它的成本几乎为零。 大多数 RAG 效果不好的案例,根因都能追到「切得不对」上,而不是「模型不够强」。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。