跳至主要内容
分块不是切字符,是切语义边界

分块不是切字符,是切语义边界

第一版分块我按固定字数切: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 这类有标记的文档,我第一步不是切,是解析出标题树,让每个块继承自己的路径。

PYTHON37 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def split_markdown(md, max_chars=800):
root = {"title": None, "level": 0, "path": [], "blocks": []}
stack = [root]
for block in parse_blocks(md): # 段落 / 列表 / 代码块 / 表格
if block.type == "heading":
node = {"title": block.text, "level": block.level, "path": [], "blocks": []}
while stack and stack[-1]["level"] >= block.level:
stack.pop()
node["path"] = [n["title"] for n in stack if n["title"]] + [block.text]
stack[-1].setdefault("children", []).append(node)
stack.append(node)
else:
stack[-1]["blocks"].append(block)
chunks = []
for node in walk(root):
buf, parts = 0, []
for b in node["blocks"]:
if b.type in ("code", "table"):
if parts:
chunks.append(make_chunk(node, parts)); parts, buf = [], 0
chunks.append(make_chunk(node, [b])) # 代码/表格整体保留
continue
if buf + len(b.text) > max_chars and parts:
chunks.append(make_chunk(node, parts)); parts, buf = [], 0
parts.append(b); buf += len(b.text)
if parts:
chunks.append(make_chunk(node, parts))
return chunks

def make_chunk(node, parts):
prefix = " > ".join(node["path"])
return {
"text": "\n".join(p.text for p in parts),
"embed_text": f"{prefix}\n" + "\n".join(p.text for p in parts),
"section_path": node["path"],
"doc_id": node["doc_id"],
}

三个细节值得说明。

代码块和表格整体保留。它们一旦被切断基本就不可读,宁可让这一块超长,也不要截断。超长时我的处理是保留结构 + 附一段摘要,而不是硬切。

embed_text 和 text 分开。标题路径拼进嵌入文本,能显著改善「问题」「做法」「结果」这类通用小标题的可辨识度;但展示给模型时只用正文,避免前缀重复占预算。这个前缀拼到哪一级需要试,我目前只到二级标题,再往下会把路径写得又长又吵。

section_path 作为元数据。它不只是给前缀用的,还能做过滤(「只在『退款政策』章节里查」)、做引用展示、做父文档回溯。第 4 篇说的元数据要在切分时挂载,主要就是挂这些。

不同文档类型的边界策略不一样:

类型 主边界 特别处理
Markdown / 文档站 标题树 目录块、Front matter 剔除
HTML 页面 DOM 语义标签 先删 nav/footer/aside/script,再取 article
源码 AST 顶层节点 按函数/类切,保留签名与 import 上下文
PDF 版面分析 双栏重排、跨页表格合并、页眉页脚剔除
表格数据 行组 每块带表头,必要时转成自然语言描述
对话 / 工单 轮次或话题 保留说话人和时间戳

PDF 是最费劲的。纯文本抽取经常把双栏左右拼成错行,向量的语义自然一塌糊涂。这类语料我宁可先花时间做版面还原,也不去后面调检索参数——输入坏了,下游做什么都像在猜。

Small-to-Big:用小锤子找,用大块读

前面说检索单元和阅读单元要解耦,落地方式就是父子两级 chunk。索引里存细粒度子块(比如 200 字),每个子块记 parent_id;召回命中子块后,按 parent_id 取回父块(整节或整篇)送给模型。

PYTHON8 行
1
2
3
4
5
6
7
8
def retrieve_small_to_big(query_vec, k_child=12, k_parent=4):
hits = vector_search(query_vec, top_k=k_child) # 子块,精度高
scored = {}
for h in hits:
p = parents[h.parent_id]
scored[p.id] = max(scored.get(p.id, 0.0), h.score)
top = sorted(scored.items(), key=lambda x: -x[1])[:k_parent]
return [{"text": parents[i].text, "source": parents[i].section_path} for i, _ in top]

用 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 边界切成两半的?抽出二十条问题数一下,比调块大小更有说服力。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#RAG #分块 #索引

评论