前言
上一篇把文档解析成了 Markdown,建议大家阅读 juejin.cn/post/769690... 接下来要做的事是给向量模型喂多长的文本,以及在哪里下刀。这一步叫分块(Chunking)。
分块要权衡的一件事是块的大小。块太大,一个向量要代表太多主题,细节被抹平,检索不够精确,计算也变重;块太小,上下文被切碎,召回的片段可能缺主语、缺前提。具体取多大,还得看下游要什么:像判情感倾向、抽字段这种只需盯住一小段的活,块小一点更合适;像写摘要、归纳主题这种要纵览全局的活,块大一点更合适。
这篇文章先介绍有哪些分块策略,再看 FastRAG 选了哪一种、为什么这样选。
分块指标
分块本质上由三个东西决定:
| 组成部分 | 含义 | FastRAG 对应 |
|---|---|---|
| 大小(Size) | 单个块的目标长度上限 | chunk_size,默认 800 rune |
| 重叠(Overlap) | 相邻块之间共享的字符/Token 数 | 当前实现固定为 0 |
| 拆分(Split) | 在哪里下刀:段落、句子、标点、固定步长 | Markdown 标题 → 递归分隔符 → 硬切 |
这里的 rune 指 Unicode 码点,不是 UTF-8 字节,也不是模型的 Token,所以 800 rune 不能直接换算成 800 Token。
常见的分块策略
下面这些方法并不是互斥的分类。固定长度、递归分隔符和文档结构主要决定切分边界,重叠决定相邻块是否重复保留内容,实际开发过程中可能是把它们组合起来。
固定大小分块(Fixed Size Chunking)
最简单的做法,按固定长度一段一段地切,通常作为其他策略的基准线。
适用场景是长度均匀、格式统一的批量文本,比如客服工单、操作日志;也常用来对一批数据先做粗切。实现简单、可预测,块好管理。问题是不管内容边界,纯按字数下刀,容易在句子中间截断,比如把"呼吸系统的结构与功能"切成"呼吸系"和"统的结构与功能"。
递归分块(Recursive Chunking)
按预先定好的一组分隔符,从强到弱逐级拆分:先用段落(\n\n),装不下再按行(\n),再按句末标点、逗号、空格,尽量在语义较强的边界上断开。
适合成段的长文本,比如年报正文、医学教材、会议纪要等。
文档特定分块(Document Specific Chunking)
不按字数切,而是按文档自己标好的层级来切:Markdown 看到 ## 就在那里断,源码看到函数定义就断,PPT 一页一块。
适合本来就带显式层级的文档。切出来的块天然对应真实的语义单元,还能顺带带上层级元数据(这段属于哪一章)。但是也有问题:文档不规范、或同一篇里混了几种格式,规则就失灵。
重叠分块(Overlap Chunking)
用滑动窗口切分,让新块和前一块共享一段内容(比如 Chunk Size 100、Overlap 20),把边界处的上下文补回来。
适合前后句强依赖的文档,比如合同条款、专利、临床指南,能降低关键语义恰好落在边界上被切掉的风险。代价是索引里的块数变多,存储和向量化开销更大;相似片段还会占据多个召回名额,挤掉其他有用内容。
语义分块(Semantic Chunking)
比较相邻句子、小段的语义相似度,或者用主题分割、大模型判断,找到话题转换的位置,在那里断开。
适合话题会在中途切换的连续文本,比如访谈记录、长对话、主题交错的会议纪要。它尝试让同一话题的内容留在一个块里,但能否改善检索,还得看模型、阈值和具体资料。相比纯规则,它通常需要额外的模型计算,成本更高;术语密集或反复举例的段落,也可能被误判为话题切换。
混合分块(Hybrid Chunking)
没有哪一种分块是万能的,实际实现常把几种拼起来:比如先按标题切出结构块,再对超长的那几块按标点降级细分,最后把过短的碎片并回去。
适合来源杂、格式不齐的资料库,单一策略很难同时照顾到全部;代价是链路长、要调的参数多,出了问题也较难定位是哪一层造成的。
FastRAG 选了哪一种
答案是混合分块(Hybrid Chunking):把文档特定分块和递归分块混合起来,再用固定大小兜底。具体四步:
- 第一刀按 Markdown 标题切,用的是文档特定分块,保住结构边界;
- 超长的小节用递归分块,按标点逐级降级拆分;
- 还超长就按 rune 硬切,退回固定大小的兜底;
- 最后合并过短的碎片。
整条链路里没有语义分块,也没有 overlap。
这么选有两个原因。一是解析产物就是 Markdown,天然带标题层级,边界又是文档自己的真实结构。二是递归分隔符是纯规则的,成本低、结果可读、边界可预期。
顺便说一句,FastRAG 的文档解析链路先把各种格式统一成 Markdown,分块因此可以复用标题和段落规则,省掉为每种原始格式各写一套切分逻辑的成本。不过统一格式不等于所有结构都适用同一套规则:超长表格和代码块,仍然可能在切分时被拆开。
按 Markdown 标题切小节
文档特定分块在这里落地。parseMarkdownBlocks 维护一个标题栈,遇到新标题时把当前小节 flush 出去。
go
// parseMarkdownBlocks 按标题把 markdown 切成有序小节,并维护标题链。
//
// 代码围栏内的 # 不算标题------这是最容易出错的地方:
// 文档里嵌一段 shell 注释就能把整篇的层级结构带偏,而且不会报错。
func parseMarkdownBlocks(content string) []mdBlock {
每一块都带标题链作为前缀,写进 Content。好处是召回时模型能看到"这段属于哪个章节";代价是标题链也占长度预算。
随后按长度收拢。固定大小在这里退化成一个上限,而不是切分规则:小节能装下就合并,装不下才递归拆。
css
for _, b := range parseMarkdownBlocks(content) {
body := strings.TrimSpace(b.body)
if body == "" {
continue
}
piece := renderPiece(b.path, body)
n := runeLen(piece)
if n > opts.ChunkSize {
flush()
chunks = append(chunks,
splitBySeparators(body, lastSegment(b.path), b.path, opts.ChunkSize)...)
continue
}
if bufLen > 0 && (bufPath != b.path || bufLen+n > opts.ChunkSize) {
flush()
}
if bufLen == 0 {
bufPath = b.path
}
if buf.Len() > 0 {
buf.WriteString("\n\n")
bufLen += 2
}
buf.WriteString(piece)
bufLen += n
}
判断里有两个条件:路径变了,或长度装不下,都先输出旧缓冲区。路径检查避免为了凑长度,把"一、呼吸系统的结构与功能"和"第十二节 呼吸衰竭"的内容当成同一个章节块。
超长小节递归降级
当一个小节太长、装不进 chunk_size,就进入递归分块。分隔符从强到弱依次是:空行(段落)→ 换行 → 句末标点 → 逗号 → 空格。
arduino
func splitRec(text, prefix, title, path string, budget, depth int) entity.Chunks {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
if runeLen(text) <= budget {
return entity.Chunks{{
Title: title, HeadingPath: path, Content: prefix + text,
}}
}
if depth >= len(separators) {
return hardCut(text, prefix, title, path, budget)
}
sep := separators[depth]
// ...
已经不超预算就直接输出;所有分隔符都用完,进入最后的硬切。其他情况在当前层执行 strings.Split。
分到某个部分仍超长,就先输出当前缓冲区,再对这个部分递归,depth+1 只让它尝试更弱的边界:
scss
for _, part := range strings.Split(text, sep) {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if runeLen(part) > budget {
flush()
out = append(out, splitRec(part, prefix, title, path, budget, depth+1)...)
continue
}
if curLen > 0 && curLen+runeLen(part)+len(sep) > budget {
flush()
}
if curLen > 0 {
cur.WriteString(sep)
curLen += len(sep)
}
cur.WriteString(part)
curLen += runeLen(part)
}
先 flush 再递归,是为了保住原文顺序。否则手上有前一段没输出,后面的长段却先递归写进结果,返回内容就乱序了。
这里还有两个细节。一是正文长度用 runeLen(码点)算,分隔符长度用 len(sep)(字节)算,两者单位不统一;中文标点一个要占 3 字节,所以预算并没有严格对齐。二是 strings.Split 拆出来的分隔符,只在同一个缓冲区内被重新拼回;一旦发生换块或递归,边界处的标点就不会补回来。所以"按标点断开",断点并不总能原样保留原文标点。
这套策略利用标点和段落近似语义边界,成本低,但读不懂"除非""但是"之后的条件依赖。实测里,同一个章节被切成两片:#1 在"呼吸道的阻力,"处结束,#2 从"其中以呼吸道阻力影响最大"继续:
bash
切片 #1 末尾:
- (4)最大通气量:是以最快的速度和尽可能深的幅度进行呼吸时所测得的每分通气量。能反 映机体的通气贮备能力,其大小取决于胸廓的完整性和呼吸肌的力量、肺的弹性和呼吸道的阻力,
切片 #2 开头:
一、呼吸系统的结构与功能
其中以呼吸道阻力影响最大。若比预计值降低20%以上为不正常。
"其中"需要前文说明指哪组影响因素,但 #2 如果独立召回,调用方不会自动拿到 #1。项目有 Order 字段,却没有自动读取相邻片段的流程。标题前缀能补章节,补不了这类跨块依赖。
标题链占预算,正文不能被挤成零
超长小节继续切时,每一块都要带原来的标题链,否则后面的半块又会失去上下文。代码先把路径从正文预算里扣掉:
arduino
func splitBySeparators(text, title, path string, size int) entity.Chunks {
prefix := ""
if path != "" {
prefix = path + "\n"
}
budget := size - runeLen(prefix)
if budget < size/4 {
budget = size / 4
}
return splitRec(strings.TrimSpace(text), prefix, title, path, budget, 0)
}
budget = size - runeLen(prefix) 很容易理解:一块内容的长度是前缀加正文。问题出在标题特别长的时候,扣完以后正文空间可能很小,甚至是负数。
当前实现保留至少 size/4 的正文预算。这样能继续推进,不会陷入"永远切不出正文"的状态,但前缀加正文可能超过 size。它选择的是不要让标题完全挤掉正文,而不是任何情况下都严格满足总长度。
例如 size=800、前缀长 700,正文预算会从 100 抬到 200,拼起来就是 900。读者照着代码调用时要知道这个代价,否则会以为返回值一定小于 800。
硬切与短片段合并
如果递归走完所有分隔符还有超长内容,就只能按 rune 硬切:
go
func hardCut(text, prefix, title, path string, budget int) entity.Chunks {
if budget < 1 {
budget = 1
}
runes := []rune(text)
out := make(entity.Chunks, 0, len(runes)/budget+1)
for len(runes) > 0 {
n := budget
if n > len(runes) {
n = len(runes)
}
out = append(out, &entity.Chunk{
Title: title, HeadingPath: path, Content: prefix + string(runes[:n]),
})
runes = runes[n:]
}
return out
}
这里没有任何 overlap:前一块尾部不会复制到后一块开头。runes = runesn: 每次从剩余内容继续,步长就是本次取出的长度。
按 rune 切的好处是不会在 UTF-8 字节中间截断中文,但它不保证句子、代码块或表格完整。超长代码即使在解析阶段保住了围栏,走到这一步也会被拆开。所以"支持 Markdown"不等于"所有 Markdown 结构都能原样成为完整切片"。
主切分按标题链隔开,最后还有一个整理步骤:
go
func finalize(chunks entity.Chunks, opts ChunkOptions) entity.Chunks {
out := make(entity.Chunks, 0, len(chunks))
for _, c := range chunks {
if len(out) > 0 && runeLen(c.Content) < opts.MinChunk {
prev := out[len(out)-1]
if merged := prev.Content + "\n\n" + c.Content; runeLen(merged) <= opts.ChunkSize {
prev.Content = merged
continue
}
}
out = append(out, c)
}
for i, c := range out {
c.Order = i
}
return out
}
短于 MinChunk(默认 50)的片段尝试向前合并,只要总长度不超限;比较的是含标题前缀的整个 Content 长度,且只有前面已存在片段时才会触发向前合并。正文变了,但前一片的 Title 和 HeadingPath 没有更新,判断里也没有比较两片路径。如果两片路径不同,合并正文以后仍保留前一片的标题和路径,就可能让正文与 metadata 表达的章节范围不一致。
函数最后重排 Order。这会影响后续 ChunkID,因为 ID 同时使用顺序和正文;分块策略一变,不只是片段数量变了,一批切片的身份也可能跟着变化,需要重新导入索引。
小结
FastRAG 的分块可以概括成:Markdown 标题切出结构边界,递归分隔符降级拆分,固定 chunk_size 做上限,必要时硬切,最后合并零碎片段。没有 overlap,也没有语义模型参与边界判断。
如果你把默认 chunk_size 从 800 调到 400,切片数量通常会变多,标题链占比会变高;调到 1600,块少一些,但单个向量可能丢细节,跨块依赖也未必减少。评估分块效果时,最好固定一组问题和检索配置,对比不同块大小下答案依据是否完整、重复片段是否增多,以及生成时喂进去的 Token 量,而不是只看"切出来多少块"。
下一篇看这些片段怎么调用 Embedding,尤其是批量响应顺序与空标题。向量数量正确,也可能挂错内容。