第一版分块我按固定字数切:600 字一块,重叠 100 字。跑起来才发现代码块被切成两半,一个列表被拆到两个块里,某个二级标题下面的论证前半段命中了、后半段没进上下文,模型拿着半截话给了个自信的结论。当时我以为要调的是 chunk 大小,后来才明白切分位置错了,调大小只是在错的维度上挪动。
切分这件事的目标不是「每块差不多长」,是让每个 chunk 成为一个语义上自洽、可被独立判断相关性的单元。
本系列第 5/15 篇 · 上一篇:拆开一条 RAG 链路 · 下一篇:Embedding 选型:别只看榜单分数
先把三个互相冲突的目标摆出来
切块大小不是越小越好,也不是越大越好,它同时被三件事拉扯:
召回精度:chunk 越短,向量越聚焦,一个片段里混入无关主题的概率越低,相似度峰值更尖锐。但太短会丢失局部语境,「它」「该接口」「上面这个限制」这类指代没了着落。
召回完整性:答案往往需要跨句、跨段的证据。切得太碎,答案被拆成两块,任何一块单独看都不完整,模型只能拼出一个残缺结论。
上下文预算:chunk 越大,送进模型的 token 越多。假设最终送 8 个片段,每块 300 字和每块 1000 字,上下文差了三倍多,延迟和成本也跟着差三倍。
这三个目标没有解析解,只有一个实用结论:让检索单元和阅读单元解耦。用小单元去检索,用大单元去阅读。这个思路贯穿后面所有策略。
固定窗口切分为什么总是差一点
text[i : i+size] 加一个 overlap,实现十行代码,但它在三类内容上必然出问题:
- 结构化文档:标题、章节、列表、表格都有天然边界,固定窗口从中间砍断,检索命中的那一块既没有标题也没有上下文,模型不知道这段在讲什么。
- 代码:函数体被切断,签名和实现对不上;即使不切断,把整个文件当一个 chunk 又会稀释掉具体函数。
- 长论证:结论和前提分居两块,命中前提那块的人看不到结论。
重叠能缓解一部分,但它是「把边界问题变成重复问题」:重叠区被编进两个 chunk,检索时同一段内容占掉两个 top_k 名额,去重逻辑又得补一层。我现在的默认是不靠重叠兜底,而是先把边界切对,再在确有必要的地方加一个很小的重叠(通常不到块长的十分之一)。
结构感知切分:先把文档变成树
对 Markdown / HTML 这类有标记的文档,我第一步不是切,是解析出标题树,让每个块继承自己的路径。
1 | def split_markdown(md, max_chars=800): |
三个细节值得说明。
代码块和表格整体保留。它们一旦被切断基本就不可读,宁可让这一块超长,也不要截断。超长时我的处理是保留结构 + 附一段摘要,而不是硬切。
embed_text 和 text 分开。标题路径拼进嵌入文本,能显著改善「问题」「做法」「结果」这类通用小标题的可辨识度;但展示给模型时只用正文,避免前缀重复占预算。这个前缀拼到哪一级需要试,我目前只到二级标题,再往下会把路径写得又长又吵。
section_path 作为元数据。它不只是给前缀用的,还能做过滤(「只在『退款政策』章节里查」)、做引用展示、做父文档回溯。第 4 篇说的元数据要在切分时挂载,主要就是挂这些。
不同文档类型的边界策略不一样:
| 类型 | 主边界 | 特别处理 |
|---|---|---|
| Markdown / 文档站 | 标题树 | 目录块、Front matter 剔除 |
| HTML 页面 | DOM 语义标签 | 先删 nav/footer/aside/script,再取 article |
| 源码 | AST 顶层节点 | 按函数/类切,保留签名与 import 上下文 |
| 版面分析 | 双栏重排、跨页表格合并、页眉页脚剔除 | |
| 表格数据 | 行组 | 每块带表头,必要时转成自然语言描述 |
| 对话 / 工单 | 轮次或话题 | 保留说话人和时间戳 |
PDF 是最费劲的。纯文本抽取经常把双栏左右拼成错行,向量的语义自然一塌糊涂。这类语料我宁可先花时间做版面还原,也不去后面调检索参数——输入坏了,下游做什么都像在猜。
Small-to-Big:用小锤子找,用大块读
前面说检索单元和阅读单元要解耦,落地方式就是父子两级 chunk。索引里存细粒度子块(比如 200 字),每个子块记 parent_id;召回命中子块后,按 parent_id 取回父块(整节或整篇)送给模型。
1 | def retrieve_small_to_big(query_vec, k_child=12, k_parent=4): |
用 max 而不是累加,是因为一个父块里命中多个子块,说明它相关,但不该因为切得碎就获得更高权重。去重后父块数量往往远小于 k_child,这一步顺带解决了「同一篇文章的三个分页全进上下文」的问题。
代价是上下文会变长。小语料场景(比如个人博客一百多篇文章)这个代价完全可以接受——检索只负责找到文章,阅读交给模型,几千 token 就能读完两三篇。大语料场景不能这么奢侈,得折中:父块取「命中子块所在章节」而不是整篇,或者按子块位置向前后各扩一个块,做成滑动窗口式的上下文扩展。
还有个变体值得提:late chunking。先让长上下文嵌入模型整篇过一遍,再对 token 表示做池化得到每个 chunk 的向量,这样每个块的向量里带着全文语境,指代和跨段依赖的表示更完整。它对长文档里的「它」「该条款」这类片段帮助明显,但要求模型支持足够长的输入,且索引构建更贵。我没有在所有语料上启用,只在指代密集的制度类文档上用。
chunk 大小和 top_k 是耦合的
这是最常被单独调、其实必须一起调的一对参数。关系很直观:上下文总量 ≈ 每块长度 × 最终片段数。定死了预算,块变大就得减少片段数;片段数少了,答案恰好落在被砍掉那块的风险就上升。
我的调整顺序是:先固定上下文预算(比如 4000 token),再根据语料类型定块长,最后反推 k_parent 和召回深度。
| 语料特征 | 块长(大致) | 策略 |
|---|---|---|
| FAQ、短条目、结构化记录 | 100–300 字 | 一条一块,不做父子,直接原文 |
| 技术文档、制度条款 | 300–800 字 | 按标题切 + small-to-big |
| 长报告、访谈、书籍章节 | 800–1500 字 | 按章节切,命中后扩窗 |
| 代码库 | 按函数 | 保留签名,附文件路径 |
评测时我用一个很土的判据:把评测集里每条问题的标准答案定位到具体 chunk,看它是否完整落在一个块内。如果大量标准答案被边界切开,那不是检索模型的问题,换嵌入也救不回来。这一步比看 Recall@K 的涨跌更早做,能省掉很多无效调参。
另一个坑是「标题路径前缀」被算进块长预算时,容易让 embed_text 超过嵌入模型的最大 token 数,尾部被静默截断。截断是 embedding 服务最常见的静默失败之一:请求成功、向量正常返回,只是最后三分之一内容从没被编码过。我会在索引构建时统计 embed_text 的 token 分布,把超过阈值的一律打日志。
元数据不只是装饰
每个 chunk 至少挂这些:doc_id、chunk_id、parent_id、section_path、source_uri、published_at / updated_at、acl、corpus_version。前四个服务于检索和回溯,中间两个服务于时效与引用展示,acl 服务于权限过滤,corpus_version 服务于灰度与回滚。
acl 尤其要在召回阶段生效。事后过滤会出现一种很隐蔽的问题:请求 top_k=10,其中 7 条用户无权查看,过滤完只剩 3 条,上下文稀薄,答案质量下降,而日志里查询本身「成功」。正确做法是把过滤条件下推到向量库的 filter 里,让 ANN 在可见子集上找满 10 条。这也是第 7 篇选向量库时要重点看的能力:带过滤条件的 ANN 会不会退化成暴力扫描。
优劣与边界
结构感知切分 + small-to-big 的收益:命中片段自洽、指代有上下文、同一文档重复占位少、引用能落到具体章节。代价是解析器要按文档类型分别维护,索引里多一层父子映射,重建成本更高,上下文也会变长。
什么时候不值得?语料天然是短条目(FAQ、工单摘要、商品描述),一条本来就是一个完整语义单元,再切就是破坏。另外,如果最终上下文预算极紧(比如要在端侧设备上跑),父文档回溯根本放不下,不如一开始就把块切得稍大、控制好边界。
切分定下来之后,下一个问题才是「这些块怎么变成向量」。同样的 chunk,用不同的嵌入模型、不同的归一化与前缀处理,召回结果可能差得很远。你现在库里,有多少标准答案是被 chunk 边界切成两半的?抽出二十条问题数一下,比调块大小更有说服力。
评论