4. 文本分割策略:固定长度分割、递归字符分割、语义分割,如何选择chunk_size与chunk_overlap

文本分割,说白了就是决定“一刀切多长”。

很多同学觉得这步简单,随便设个256或512就完事了。我刚开始做RAG时也这么想,结果上线后用户问“张三去年签的合同编号是多少”,系统死活答不上来。后来一查,问题就出在分割上——关键信息被拦腰切断了。

所以今天咱们好好聊聊三种主流分割策略,以及两个最关键的参数:chunk_size和chunk_overlap。

4.1 三种分割策略的核心差异

先看一张对比图,帮你快速建立整体认知。

三种文本分割策略对比 固定长度分割 按字符数硬切 不考虑语义边界 速度最快 递归字符分割 按分隔符层级递归 保留段落/句子完整性 最常用 语义分割 基于embedding相似度 按主题/语义切分 质量最高但最慢 关键参数:chunk_size 与 chunk_overlap chunk_size(块大小) 决定每个文本块包含多少字符/token 太小→上下文碎片化;太大→检索噪声多 chunk_overlap(重叠大小) 相邻块之间重叠的字符/token数 保证边界信息不丢失,但增加存储成本 chunk_size 推荐:256~1024 chunk_overlap 推荐:10%~20%

4.2 固定长度分割

这是最原始的方法。不管内容是什么,数够字符就切一刀。

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separator="\n"
)
chunks = splitter.split_text(text)

优点就一个字:快。处理海量日志、固定格式报表时,我经常用它。

但缺点也很明显——它不懂语义。我在一个项目里用它切法律合同,结果把“甲方应于2024年12月31日前”切成了两段,检索时“2024年12月”和“31日前”分到了不同块,LLM根本理解不了完整日期。

避坑提醒:固定长度分割只适合纯文本、无结构、对精度要求不高的场景。千万别用在合同、论文、技术文档上。

4.3 递归字符分割

这是目前最主流的方案。LangChain、LlamaIndex默认用的就是它。

它的思路是:先按段落切,如果段落太长,再按句子切,句子还长,再按标点切……一层层递归下去。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)
chunks = splitter.split_text(text)

注意看separators的顺序——从大到小。先尝试用双换行(段落),不行再换单换行,再不行换句号……

我个人习惯把中文标点放在英文前面,因为中文句号比英文句点更能代表语义边界。

经验之谈:chunk_size设512~768,chunk_overlap设50~100,对大部分中文文档效果都不错。我做过测试,这个区间在检索召回率和LLM理解质量上平衡得最好。

4.4 语义分割

这是最“聪明”的方法。它不按字符数切,而是按语义边界切。

具体做法:把文本切成小段,每段算一个embedding向量。然后看相邻两段的相似度,如果相似度突然下降,说明话题变了,就在这切一刀。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    embeddings=OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=0.3
)
chunks = splitter.split_text(text)

这里有个关键参数breakpoint_threshold_amount,我一般设0.3~0.5。设得太小,切得太碎;设得太大,又切不动。

适用场景:长文章、多主题文档、对话记录。比如一篇论文包含“引言-方法-实验-结论”,语义分割能准确把四个部分分开,而递归分割可能会把“方法”和“实验”混在一起。

但代价也很明显——慢。embedding计算一次,相似度计算一次,处理100万字的文档,语义分割比递归分割慢5~10倍。

4.5 如何选择chunk_size与chunk_overlap

这两个参数没有“万能值”,但有几个经验法则。

场景 推荐chunk_size 推荐chunk_overlap 说明
短文本(问答、FAQ) 128~256 10~20 块小,检索精准
中等文档(新闻、博客) 512~768 50~100 平衡召回与理解
长文档(论文、报告) 1024~2048 100~200 保留上下文连贯性
代码/结构化文本 256~512 20~50 按函数/类边界切

chunk_overlap为什么重要?我举个例子。假设chunk_size=500,chunk_overlap=0,那么第1块是1~500字符,第2块是501~1000字符。如果“张三的合同编号是”刚好在500字符处结束,“A2024-001”在501字符开始,那两块都丢了完整信息。

加了overlap后,第1块末尾会包含第2块开头的一部分内容,保证边界信息至少在一个块里是完整的。

注意:overlap不是越大越好。设到30%以上,存储成本增加30%,检索时重复内容也会干扰排序。我一般控制在10%~20%。

4.6 我的选择建议

如果你刚开始做RAG,我建议这样选:

  1. 先用递归字符分割,这是最稳妥的起点。
  2. chunk_size从512开始,跑一遍测试,看检索结果是否完整。
  3. chunk_overlap设10%,也就是50左右。
  4. 如果发现关键信息被切碎,增大chunk_size到768或1024。
  5. 如果发现检索噪声太多,减小chunk_size到256或384。
  6. 如果文档主题变化明显,换成语义分割。

我曾经在一个金融项目里,用递归分割+chunk_size=512,结果用户问“2023年Q3营收”,系统总是返回Q2和Q4的数据。后来我把chunk_size调到1024,overlap调到100,问题就解决了。因为季度财报的完整描述需要800~900字符,512根本装不下。