从零手搓 RAG 之分块策略

前言

上一篇把文档解析成了 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 出去。

代码:domain/service/splitter.go

go 复制代码
// parseMarkdownBlocks 按标题把 markdown 切成有序小节,并维护标题链。
//
// 代码围栏内的 # 不算标题------这是最容易出错的地方:
// 文档里嵌一段 shell 注释就能把整篇的层级结构带偏,而且不会报错。
func parseMarkdownBlocks(content string) []mdBlock {

每一块都带标题链作为前缀,写进 Content。好处是召回时模型能看到"这段属于哪个章节";代价是标题链也占长度预算。

随后按长度收拢。固定大小在这里退化成一个上限,而不是切分规则:小节能装下就合并,装不下才递归拆。

代码:domain/service/splitter.go

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,就进入递归分块。分隔符从强到弱依次是:空行(段落)→ 换行 → 句末标点 → 逗号 → 空格。

代码:domain/service/splitter.go

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 只让它尝试更弱的边界:

代码:domain/service/splitter.go

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 字段,却没有自动读取相邻片段的流程。标题前缀能补章节,补不了这类跨块依赖。

标题链占预算,正文不能被挤成零

超长小节继续切时,每一块都要带原来的标题链,否则后面的半块又会失去上下文。代码先把路径从正文预算里扣掉:

代码:domain/service/splitter.go

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 硬切:

代码:domain/service/splitter.go

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 结构都能原样成为完整切片"。

主切分按标题链隔开,最后还有一个整理步骤:

代码:domain/service/splitter.go

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,尤其是批量响应顺序与空标题。向量数量正确,也可能挂错内容。

相关推荐
喵个咪1 小时前
GoWind Ops 拆解:不引入分布式锁的多副本协调
后端·go·自动化运维
迷途小羔羊1 小时前
环境搭建
前端·javascript·后端
SimonKing1 小时前
DeepSeek 新模型免费白嫖,夯夯夯,赶紧用起来
java·后端·程序员
夕阳_醉了1 小时前
让 AI 回答“打字般”浮现:用 Vue 3 + DeepSeek 实现流式聊天
前端·vue.js·人工智能
打工仔折腾 AI1 小时前
介入式脑机接口的跨尺度解码与闭环调控:从算法落地到后端工程实践
人工智能·后端·python·深度学习·算法·ai agent 实战
SabrinaW7111 小时前
基于 C++17 与 RTTR 的注册式对象工厂:配置驱动的动态对象创建
java·前端·数据库
喵个咪1 小时前
GoWind Ops 深度拆解:纯出站 Agent + MQTT 信令,把「管机器、跑项目、发脚本、构建分发」做扎实
后端·go·自动化运维