做过 RAG 的开发者大概都踩过一个坑:明明向量库里灌了大量文档,检索出来的片段却驴唇不对马嘴。排查下来,往往不是 Embedding 模型不行,而是文档切分出了问题——关键信息被一刀切断,或者一个文本块塞了太多无关内容,导致向量化后语义被稀释。LangChainGo 的 textsplitter 包提供了一套递归切分方案,核心思路是在保持语义完整的前提下控制块大小。这篇文章拆解它的 RecursiveCharacter 切分原理,并给出 Markdown 文档的实战切分示例。
为什么不能简单粗暴地定长截断
最直觉的切分方式就是按字符数截断:每 500 字切一刀。问题在于,切分位置是随机的,一段完整的代码块、一个关键的结论段落,都可能被从中间劈开。对于 RAG 场景,这种"断章取义"的文本块检索出来后,交给模型生成的答案自然也不靠谱。
RecursiveCharacter 换了一个思路。它维护一个分隔符优先级列表,默认是 ["\n\n", "\n", " ", ""]——分别对应段落分界、换行、空格和逐字符拆分。拿到一段长文本后,先尝试用双换行(段落边界)拆开。如果拆出来的某一段仍然太长,就退而用单换行继续拆;再不够,按空格拆;最后实在不行,才逐字符截断。
换句话说,段落能完整保留就绝不拆段落,只有段落本身超限了才往下降级。这样切出来的文本块,语义完整性比定长截断好得多。
核心结构与默认行为
textsplitter 包的主力类型是 RecursiveCharacter,字段不多但每个都有明确用途:
type RecursiveCharacter struct{ Separators []string ChunkSize int ChunkOverlap int LenFunc func(string)int KeepSeparator bool}通过 NewRecursiveCharacter 创建实例,支持函数选项:
splitter := textsplitter.NewRecursiveCharacter()几个默认行为值得留意。Separators 前面已经提过,四级分隔符从粗到细。LenFunc 默认用的是 utf8.RuneCountInString,也就是按 Unicode 字符数计算长度,一个汉字算 1 而不是按 UTF-8 编码算 3 个字节。实际项目里不建议依赖默认的 ChunkSize 和 ChunkOverlap,后面会演示如何显式指定。
ChunkOverlap:边界信息不丢失的关键
ChunkOverlap 这个参数容易被忽略,但对检索质量影响很大。
假设文档被切成 A、B 两个块,Overlap 设为 50,那 A 块末尾的 50 个字符会和 B 块开头的 50 个字符重复。这看起来浪费了存储和向量化的开销,但好处是:如果某条关键信息刚好跨越了 A 和 B 的切分边界,重叠区域能保证两个块都包含这段内容。检索的时候,至少有一个块能命中。
Overlap 设多大合适?一般建议是 ChunkSize 的 10%~20%。设太大,冗余严重、向量库膨胀;设太小或干脆不设,边界信息就可能"两头都查不到"。
实战:切分 Markdown 文档
下面演示一个常见场景——读取本地的 Markdown 文件,切分成适合灌入向量库的文本块。
先创建切分器,显式指定块大小和重叠量:
splitter := textsplitter.NewRecursiveCharacter( textsplitter.WithChunkSize(500), textsplitter.WithChunkOverlap(50),)500 个字符的块大小适合中等篇幅的技术文档,50 个字符的重叠刚好是 10%。
然后读取文件并切分:
content, err := os.ReadFile("docs/architecture.md")// 错误处理省略...chunks, err := splitter.SplitText(string(content))SplitText 返回 []string,每个元素就是一个文本块。如果需要给每个块打上来源标签(比如文件名),方便后续检索时过滤,可以用 CreateDocuments:
docs, err := textsplitter.CreateDocuments( splitter,[]string{string(content)},[]map[string]any{{"source":"architecture.md"}},)返回的 []schema.Document 里,每个 Document 同时携带文本内容(PageContent)和元数据(Metadata)。元数据存入向量库后,检索时就能按来源文件做筛选。
针对 Markdown 的专用切分器
RecursiveCharacter 是通用方案,如果文档本身就是 Markdown 格式,还有更"懂"结构的选择——MarkdownTextSplitter。
mdSplitter := textsplitter.NewMarkdownTextSplitter( textsplitter.WithChunkSize(500), textsplitter.WithHeadingHierarchy(true),)这里重点说 WithHeadingHierarchy(true) 这个选项。开启后,切分出来的每个文本块会自动在头部补上它所属的标题层级。比如一段正文在 ## 架构设计 > ### 数据库选型 下面,切分后这个块的开头就会带上对应的标题路径。
这对 RAG 的检索质量帮助很大。举个例子:用户问"数据库选型的依据是什么",但正文里可能从头到尾都没出现"数据库选型"这几个字(正文直接在讨论 MySQL 和 PostgreSQL 的对比),如果文本块里没有标题上下文,向量相似度可能不够高,导致这个块排不到前面。带上标题层级后,这个问题就缓解了。
自定义分隔符与 Token 级切分
默认的四级分隔符适合通用文本,但如果文档格式比较特殊,可以通过 WithSeparators 自定义。比如切分 Go 源码时,优先按函数边界拆分:
splitter := textsplitter.NewRecursiveCharacter( textsplitter.WithSeparators([]string{"\nfunc ","\n\n","\n"," ","",}), textsplitter.WithChunkSize(800),)把 "\nfunc " 放在最前面,切分器会优先在函数声明处下刀,尽量保证一个完整的函数不会被拆散到两个块里。
另一个常见需求是按 Token 数控制块大小。字符数和 Token 数并不总是线性对应的(尤其是中英混合文本),如果想精确控制每个块的 Token 量,可以直接用 TokenSplitter:
tokenSplitter := textsplitter.NewTokenSplitter( textsplitter.WithChunkSize(256), textsplitter.WithModelName("gpt-4"),)它内部会用指定模型的 Tokenizer 来计算长度,比按字符数估算更准确。
怎么判断切分效果好不好
切分参数没有标准答案,但有几个实操经验可以参考。
最简单的办法是把前几个块打出来看看。如果频繁出现句子从中间断开、或者一个块里塞了好几个不相关的段落,说明 ChunkSize 不合适或者分隔符没贴合文档格式。
更靠谱的验证方式是用实际问题做端到端测试。准备一组典型的用户问题,跑一遍 RAG 流程,看检索出来的文本块是否包含正确的答案。如果某个问题持续检索不到对应的内容,回头检查原文的切分情况,通常能找到切分不合理的地方。
关于块大小,技术文档一般设在 300~800 字符之间效果不错。对话记录或者比较碎的短文本,可以缩小到 200 左右。
写在最后
langchaingo/textsplitter 包把文档切分这件事做了很好的抽象:RecursiveCharacter 处理通用文本,MarkdownTextSplitter 专门针对 Markdown 做了结构感知,TokenSplitter 则解决 Token 精确计量的问题。在搭建 RAG 管线时,与其花大量时间调优 Embedding 模型或 Prompt,不如先回头审视一下文档切分的质量——很多时候,检索效果差的根源就藏在这里。
夜雨聆风