1. 为什么说文本分块决定了RAG的上限
1.1 检索单元与生成质量的连锁关系
做RAG项目这几年,我一直有个判断:评估一个检索增强生成系统的上限,最先看的是文本分块策略,而不是Embedding模型选得多强、Prompt写得多么花哨。原因是整个RAG流水线里,向量化、检索、重排、生成这几个环节全部围绕一个核心单位展开——被切出来的文本块(chunk)。文本块既是向量的最小载体,也是检索召回的最小单元,更是喂给大模型的最小上下文。
可以这么理解:如果把RAG系统比作一家餐厅,Embedding模型是厨师,大模型是最终上菜的顾客,文本分块则是后厨的切配工序。切得好,后厨出菜稳定、顾客吃得舒服;切得稀碎,再好的厨师也难做出完整的菜。分块直接决定三件事:召回质量、上下文完整性、生成答案的准确率。你切出来的块如果把一个完整句子拦腰截断,检索召回后大模型看到的只能是一堆语无伦次的碎片;反之,如果块太大,又容易被向量模型压缩成平均语义,导致检索结果不精准。
适合阅读这篇内容的人,我大致分为三类:一是刚开始搭RAG知识库、被“为什么我的问答效果这么差”困扰的开发者;二是已经在用LangChain做文档问答,但想系统搞懂文本分割器(Splitter)参数怎么调的人;三是需要处理各种格式文档(PDF、Markdown、Word、HTML)并希望保留结构化信息的工程师。我会把LangChain家族里常用的文本分割器全部过一遍,再给出一个可以直接套用的实战流程。
1.2 分块不佳的典型翻车现场
先看几个真实踩过的坑,方便你对照自己是否正在经历。
第一个翻车现场:不分块,整本手册直接塞进向量库。某同事最开始做产品说明书问答时,把一份30页的PDF整篇丢进Embedding模型,向量库倒是建成了,但用户问“怎么重置设备密码”时,召回的结果永远是一整本手册的向量,雷同度极高,Top 5全是一个模子出来的。大模型看着一整篇泛泛的文档,只能给出一句“请参考说明书第X章”。这是典型的块粒度过大导致向量表征失焦。
第二个翻车现场:分块太机械,每500字符一刀切。另一个项目拿字符数作为唯一标准,500个字符一切,结果把表格、代码片段、标题和正文完全撕裂。用户问“API的鉴权参数在哪”,召回来的块里只剩半个表格,列名都在上一个块里。大模型再怎么生成也拼不出正确答案,因为信息本身就被拆散了。
第三个翻车现场:完全不做重叠(overlap)。很多教程教你设chunk_size=500,但没提overlap,结果正好把一段逻辑连贯的话从中间切断,块与块之间丢失了上下文衔接。虽然每两个相邻块在语义上都存在断点,检索时关键词刚好落在断裂处的概率还不低,但召回的块侧来看,信息往往是不完整的。
这三个问题有一个共同点:工程上看起来都没报错,向量库也建出来了,可就是召回质量差。所以把文本分块单独拉出来重点讲,是RAG落地绕不开的基础课。
2. LangChain文本分割器全景:先选对工具
2.1 常用Splitter一览与选型
LangChain的langchain_text_splitters模块提供了一整套文本分割器,用之前建议先对家族成员有个整体认知。我按适用场景列一个速查表:
| 分割器 | 切分依据 | 适用场景 | 注意事项 |
|---|---|---|---|
RecursiveCharacterTextSplitter | 递归匹配分隔符列表 | 通用文档、纯文本、代码混合文档 | 默认首选,需自定义分隔符 |
CharacterTextSplitter | 固定字符分隔 | 分隔符非常明确的文本 | 分隔符单一,遇到复杂布局容易失效 |
TokenTextSplitter | Token数量 | 对接模型上下文窗口 | 不同类型模型Token计算方法不同 |
SentenceTransformersTokenTextSplitter | 模型内部分词 | 与特定Embedding模型深度绑定 | 依赖对应分词库 |
MarkdownHeaderTextSplitter | Markdown标题层级 | 笔记、文档站、README | 天然保留结构化信息 |
HTMLHeaderTextSplitter | HTML标签结构 | 网页正文抓取后的清洗 | 要配合HTMLSectionSplitter理解布局 |
RecursiveJsonSplitter | JSON键/数组元素 | 配置、接口返回等半结构化数据 | 按深度切分,适合层级很大的JSON |
单看表会很抽象,实际选型只需要记住一个原则:先用最通用的工具,发现它在特定格式上不够好,再换针对性工具。对我来说,RecursiveCharacterTextSplitter是90%场景的起点,Markdown/HTML分隔器则是做文档站和知识库时的有力补充。
2.2 RecursiveCharacterTextSplitter才是默认首选
为什么把RecursiveCharacterTextSplitter放到默认首选位置?因为它解决了一个其他分割器没解决的问题:文档中分隔符的优先级是不同的。段落之间的双换行是最高级别的分隔,单换行次之,空格再弱一些,最后才是字符级的暴力切割。多数技术文档恰恰是按这种层级组织文本的。
换个生活化的说法,它像人切菜时的思路:先沿最自然的大纹理切,切出来的块还太大,就沿小纹理继续切,直到每个块都符合大小要求。而不是拿一把砍刀固定长度乱剁。
RecursiveCharacterTextSplitter内部维护一个分隔符列表,默认大概是这样的顺序:
["\n\n", "\n", " ", ""]处理流程可以归纳成三步:
- 用最高优先级分隔符
\n\n(段落)把原始文本切成若干段; - 如果某一段长度还是超过
chunk_size,就用下一级分隔符\n继续切; - 如果切到最后仍超长,就用空字符串
""逐个字符切,这一步是兜底,避免死循环。
简单说,它尽量保持语义上自然的块,实在超过了上限,才牺牲语义完整性。
对应的使用示例也很直接:
from langchain_text_splitters import RecursiveCharacterTextSplitter text = """项目背景介绍 本系统是一套面向内部业务的跨平台数据管理系统,核心功能包括数据接入、质量校验、可视化分析。 主要模块如下: - 数据接入模块:支持多种数据源,并提供增量同步能力。 - 质量校验模块:基于规则引擎,对字段缺失、值域异常进行实时检查。 """ splitter = RecursiveCharacterTextSplitter( chunk_size=50, chunk_overlap=10, length_function=len ) chunks = splitter.split_text(text) for idx, chunk in enumerate(chunks): print(f"===== chunk {idx} =====") print(chunk)注意这里chunk_size=50的“50”指的是50个字符(len统计),并不是50个Token。先记住这个区别,后面我会专门讲字符与Token之间的关系。
2.3 其他Splitter的适用场景拆解
再看几个值得掌握的专用分割器。
CharacterTextSplitter是最简朴的分割器,它只按单个分隔符切分。适合那种分隔符非常规整、不会出现嵌套结构的文本。比如按句号分、按分号分。缺点是遇到复杂布局就非常吃力,一旦段落里出现多级分隔,固定分隔符就显得僵化。
MarkdownHeaderTextSplitter很有意思,它根据Markdown的一级标题、二级标题、三级标题层级来分块。比如一份技术文档的结构是“# 第一章”下面的“## 1.1 背景”,它会把“## 1.1 背景”下的正文切成一个块,并把“第一章”“1.1 背景”作为标题元数据保留下来。这种做法对知识库极其友好,因为检索召回某一个块时,你能知道它属于哪个章节,可以做更精准的引用溯源。后续如果想做父子块检索或者分层检索,这是很关键的一步。
TokenTextSplitter按模型分词后的Token数量切分,更贴近模型视角。用一个实际的Embedding服务时,如果模型最大输入是512个Token,用TokenTextSplitter(chunk_size=256)会比用字符数分块安全得多,因为不会出现误算了中英文长度导致向量化报错的问题。
实操上,我经常组合使用:先按Markdown标题切分出大段落,再用RecursiveCharacterTextSplitter将过长的段落二次切分,同时把标题信息透传到每个子块。语言环境里的中文用户对这种方式接受度最高,既保住了结构,又控制住了长度。
3. 实战:手把手完成一个知识库分块流程
3.1 准备示例文档和依赖
我以一个模拟项目X的运维手册为例,演示完整的文本分块流程。先建立一个示例文档ops_manual.md:
# 运维手册 ## 1. 系统部署 ### 1.1 环境要求 生产环境需要本次安装的运行时版本大于等于某个指定版本,内存建议不低于8GB,磁盘剩余空间不低于20GB。 ### 1.2 部署步骤 1. 解压安装包到指定目录。 2. 修改配置文件中的连接串信息。 3. 执行初始化脚本,等待数据库结构创建完成。 4. 启动主服务,观察日志输出。 ## 2. 故障排查 ### 2.1 服务无法启动 确认端口是否被占用,检查日志中的错误码,定位异常模块。 ### 2.2 磁盘空间不足 清理临时文件,归档历史日志,必要时扩缩容磁盘资源。然后在工程里创建虚拟环境并安装依赖:
pip install langchain langchain-text-splitters langchain-openai chromadb这里需要说明一下,langchain-text-splitters的导入路径与早期LangChain版本不同。如果你沿用旧代码里from langchain.text_splitter import ...的写法,在较新版本中可能已经不可用。统一的写法是from langchain_text_splitters import ...。
3.2 参数调优:chunk_size与chunk_overlap的正确打开方式
很多人把chunk_size和chunk_overlap看成两个孤立的参数,但实际上它们是一组配合使用的约束。我把它们的含义翻译成大白话:
chunk_size:每个块的目标“最大长度”,超过会被继续切分。chunk_overlap:相邻两个块之间共享的“重叠长度”,目的是让跨块的上下文信息不要彻底丢失。
为什么需要重叠?因为你永远无法保证一个语义完整的句子恰好落在一个块内。句子的后半句可能垂到下一个块,如果没有重叠,检索时如果命中的是上一个块,下一个块中的承接内容就无法被模型看到。加上重叠之后,块与块之间有了缓冲带,上下文衔接更自然。
重叠数值的设定,我通常取chunk_size的10%到20%。比如chunk_size=500时,chunk_overlap取50到100。如果太大,会出现严重的重复存储问题:同一个句子被多个块重复引用,向量库里冗余度暴增,检索时一个关键词还会召回到大量内容雷同的块,反而干扰重排模型。太小则等于没有重叠。
再看一个实际数字案例。假设文档总长度为2000个字符,chunk_size=500、chunk_overlap=50,理论上生成的块数可以用一个简单公式估算:
块数 = ceil((总长度 - overlap) / (chunk_size - overlap))
代入:ceil((2000 - 50) / (500 - 50)) = ceil(1950 / 450) ≈ 5。也就是说会产生5个左右的块。如果完全不重叠:ceil(2000 / 500) = 4。多出来的一个块,就是重叠机制带来的上下文冗余,这是合理代价。
在这个模拟项目X里,先用一段脚本打印分块结果:
from langchain_text_splitters import RecursiveCharacterTextSplitter with open("ops_manual.md", "r", encoding="utf-8") as f: raw_text = f.read() splitter = RecursiveCharacterTextSplitter( chunk_size=80, chunk_overlap=12, length_function=len, add_start_index=True ) chunks = splitter.split_text(raw_text) for idx, chunk in enumerate(chunks): print(f"----- chunk {idx} -----") print(chunk)chunk_size=80是我为了演示故意调小的,生产环境不会这么小。通过输出可以清楚看到:总会被优先以段落边界切割,过长的段落继续以单换行切分,单换行还不满足再按空格切。中文文本没有空格分隔,兜底逻辑就会按字符切,这也是为什么中文场景下要重视自定义分隔符。
3.3 元数据保留与后处理
真正生产环境里,我们不仅需要分块文本本身,还要保留来源信息。单纯split_text只能拿回字符串列表,无法携带文件名、章节、页码这些上下文。更推荐的方式是使用create_documents或split_documents来处理Document对象。
我通常这样组织:
from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("ops_manual.md", encoding="utf-8") documents = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=256, chunk_overlap=40, length_function=len, ) split_docs = splitter.split_documents(documents) for doc in split_docs: print(doc.page_content[:20], "| meta:", doc.metadata)注意split_documents会把原Document中的metadata自动复制到每个子块。如果有多个文件来源,加载时为每个文档加上source字段,后续检索定位到某个块,就能知道它来自哪个文件。这一步看似简单,但没做的话,等知识库膨胀到几千个块时,你根本没法溯源,问答系统给出答案用户也没法核对。
实际开发中,我还会在分块后做一次清洗和后处理。比如块内有多余空行,我会压缩成单个换行;块首尾出现孤立标点,我会尝试把它们合并到相邻块;块内容如果只有一两个字符,我会直接丢弃,因为它们基本是纯噪声。这些后处理逻辑不需要很复杂,但能显著提升向量入库的质量。
4. 参数背后的原理与经验值
4.1 chunk_size到底取多少:三条经验法则
被问得最多的问题是:“chunk_size到底选多少合适?”这个问题没有标准答案,但有三条经验法则可以参考。
第一条,取决于Embedding模型和重排模型的输入上限。如果采用的向量接口最长输入是512个Token,那么块长建议控制在256到384个Token,留出安全余量。一旦块长超过模型输入上限,向量化阶段会出现截断或直接报错。
第二条,取决于检索方案。如果只用向量Top K召回,块小一些(200到300 Token)通常更精准。如果后面还接了重排序模型,块可以适当放大到400到600 Token,先宽松召回,再由重排模型精挑细选。重排模型能帮你补救一些块稍大带来的语义稀释,但前提是你得先有重排环节。
第三条,取决于文档本身的结构密度。操作手册这类强结构文档,按章节切分最理想,块的大小天然由内容决定。法律合同、学术论文这类长段落文档,块往往需要一个上限,防止一个1000字的段落整段入库。代码文档的特殊性更强,最好让代码块和说明文字分开切分,否则“代码+注释+正文”揉在一起,检索定位会很难受。
我用一张表总结不同场景的经验区间:
| 场景 | 建议块长 | 建议重叠 | 理由 |
|---|---|---|---|
| 通用知识库问答 | 300-500 Token | 10%-15% | 平衡语义完整性与召回精准度 |
| 代码检索增强 | 150-300 Token | 5%-10% | 代码块独立性强,重叠过多会引入冗余 |
| 法律/学术长文本 | 500-800 Token | 10%-15% | 避免破坏论证结构,块太小会丢失上下文 |
| 结构化文档 | 按标题章节切 | 0-5% | 标题是天然语义边界,不必强凑字数 |
| 面向重排模型 | 400-600 Token | 10%-15% | 给重排模型足够的候选上下文 |
这些数据不是死标准,我是拿真实项目调试出来的。也建议你在自己的语料上做A/B对比,往下看我会给一个简单评估方法。
4.2 用Token而非字符数来度量
进到实际工程阶段,建议尽早抛弃“字符数”这个度量单位。中文场景下,用len计算字符数容易产生严重误判。举个直观例子:一段纯中文的80字文本,在大多数模型里大概是35到45个Token,而一段包含大量英文和数字符号的80字符文本,Token数可能达到60以上。如果Embedding模型限512 Token,你按500字符切,英文场景很容易悄悄超限。
用Token计数的写法如下:
import tiktoken from langchain_text_splitters import RecursiveCharacterTextSplitter tokenizer_name = "cl100k_base" tokenizer = tiktoken.get_encoding(tokenizer_name) def token_len(text): return len(tokenizer.encode(text)) splitter = RecursiveCharacterTextSplitter( chunk_size=256, chunk_overlap=30, length_function=token_len, separators=["\n\n", "\n", "。", ";", ",", " ", ""], )tiktoken是一个常用的分词库,如果你用的是其他模型,通常也能在它的配套SDK里找到分词器。关键是length_function参数完全可定制,你传入什么函数,分割器就以什么单位衡量长度。这是LangChain文本分割器非常优雅的设计。
chunk_size=256表示256个Token,overlap设30,只占总长的12%左右,基本符合我上面提到的经验区间。
4.3 常见分块误区
第一个误区是把所有文档都交给同一个Splitter处理。PDF转出来的文本、网页抓取后的正文、Markdown源文件,它们的结构差异非常大。PDF文本经常丢失换行和缩进,网页正文有大量HTML残留,Markdown则有清晰标题。统一用一套参数来处理,效果一定差。正确做法是先识别文档类型,再选择匹配的Splitter。
第二个误区是只关注块的长度,不关注块的边界质量。好的分块应该“块内主题唯一,块间语义清晰”。如果一块里包含了两个完全无关的段落,即使长度完全符合chunk_size,检索时也很容易因为关键词分散导致向量被平均化。这也是为什么我前面一直强调尽量优先按段落和标题切分,再考虑长度上限。
第三个误区是忽视重叠带来的副作用。重叠并不是越多越好。我见过有人把overlap设成chunk_size的一半,结果向量库存储量直接膨胀30%以上,而且Embedding成本明显增加。检索阶段经常召回几个只有少许差别的相似块,白白占满Top K名额。重叠的目的是缓冲,不是复制。
第四个误区是忽略块与块之间的连续性验证。分块完成后,不少人直接入库,从不检查相邻块首尾是否断裂。建议在入库前跑一个抽查脚本,把相邻块的尾部与头部拼接起来看看,如果出现明显句子断裂,就要重新调整分隔符或overlap。这一步特别花时间,但对中文文本尤其值得。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
把真实项目里高频出现的问题整理成速查表,方便你直接对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 生成的块是空字符串 | 连续多个分隔符导致空段 | 加载文档后先做空行压缩与strip |
| 中文句子被硬切破碎 | 分隔符列表没有包含中文标点 | 在separators中加入“。!?”等标点 |
| 向量化时报长度超限 | 字符长度与Token长度判断偏差 | 改用Token长度函数 |
| 一个Markdown章节被拆成多块且丢失标题 | 直接使用通用分割器而非结构化分割器 | 使用MarkdownHeaderTextSplitter |
| 表格内容拆分后无法阅读 | 固定字符切割破坏行列结构 | 优先整表切块,或按行合并后再切 |
| 代码和说明被切到同一块 | 文档混杂代码与正文 | 按代码块边界先拆分再分别处理 |
| 召回结果全是内容重复的块 | overlap设置过大 | 调低重叠比例,抽查相邻块重复度 |
| 某个主题提问永远召回不到 | 该主题恰好被切到多块,向量互相稀释 | 缩小chunk_size,按更细主题重新切 |
这些问题的共同点在于:它们不是报错形式出现的,问题往往藏在实际问答效果里,排查周期特别长。所以建立分块后的人工抽查习惯,比等系统出问题再找原因效率高很多。
5.2 三个实测有效的调优技巧
第一个技巧是组合使用不同分割器。有一次处理一批Markdown格式的课程讲义,直接使用RecursiveCharacterTextSplitter,结果所有标题被当作普通文本切断。后来改成先用MarkdownHeaderTextSplitter切出章节,再对超长章节二次切分。整体效果提升明显,检索召回到的块都自带标题上下文,回答时模型能说出“根据第X章的内容”,可信度大增。
第二个技巧是为不同段落设置不同的chunk_size。一个折中方案是文档加载后,先按段落长度做粗分类:短段落保留为完整块,长段落再做递归切分。也就是说短的自然段落可以作为不可再分的最小单元,防止过小的切分破坏语义。LangChain的TextSplitter本身没有内置这个逻辑,但你可以先分组再调用,或者自定义一个继承TextSplitter的类,重写split_text方法。
第三个技巧是在分块前做一次文本标准化。常见处理包括:压缩连续空行、统一换行符、去掉不可见字符、将全角空格转半角或删除。这些看起来不起眼的操作,往往能大幅提升分隔符匹配的稳定性。我记得有一份来源不明的手册,里面夹杂着大量全角空格和Windows换行,普通"\n"匹配效果极差,做完标准化后分块才走上正轨。
5.3 分块效果如何快速评估
很多人不知道分块效果怎么评估。其实不必一上来就做复杂的端到端评测,先用一个“三看”法就能过滤掉大部分问题。
一看块内完整性:随机抽20个块,看每块的句子是否完整、主题是否单一。我通常让助手角色脚本随机抽一些块打印出来,人工扫一遍,半小时内就能判断有没有严重切割问题。
二看检索命中率:先准备10到15个标准问题,用简单的向量检索把Top 3结果打印出来,看正确答案是否在召回结果里。如果好几个问题根本召回不到相关块,基本可以怀疑分块粒度过大或过小。
三看生成质量:这个问题只在大模型生成端看,不考虑Prompt优化,先用一个中规中矩的Prompt做问答。如果答案经常出现“上下文不完整”的表述,说明召回到的块没有覆盖答案所需信息;如果答案出现内容相互矛盾,说明块内信息源混乱,可能多个块把不同主题揉在一起了。
这套三看法不用任何额外框架,适合在项目初期快速定位问题。
我个人在实际操作中的体会是,文本分块在整个RAG系统里属于投入产出比极高的环节。很多人花大量时间调Embedding模型、换数据库,却忽略了最基础的分块策略。其实修正分块往往立竿见影:召回准确率上来了,大模型拿到的上下文干净了,答案质量自然就提升。如果你正在做一个RAG项目,建议先把这个环节打磨透,再去碰那些花哨的高级功能。
最后再分享一个小技巧:所有分块参数在代码里最好用配置项而不是硬编码,这样你就能在同一个语料集上快速做多组参数对比。我用这种方式,曾经把某个内部知识库的Top 5召回命中率从62%逐步提升到81%,过程不复杂,就是不断切、不断看、不断调。