3.2 常见文本切分策略#

在上一节内容中我们已经介绍了如何将不同来源、不同格式的原始文档统一转换为 Document 对象。完成这一步后,文档虽然已经具备了标准化结构,但通常仍然不能直接进入向量化与检索流程,还需要在文档加载之后增加一个关键步骤,文本切分。

3.2.1 文本切分#

文本切分是指按照一定规则将原始文档拆分为若干相对独立、长度适中且语义尽可能完整的文本块(Chunk),以便后续完成向量化、入库与检索。

从工程角度看,文本切分并不是简单地将长文本“截短”,而是在长度约束与语义完整性之间寻找平衡。若不经过切分而直接对整页或整篇文档进行向量化,通常会带来下面两个问题:

① 输入长度超限;词嵌入模型通常存在最大输入长度限制,超出部分可能被截断,从而导致部分语义信息丢失。

② 语义粒度过粗;若单个文本块包含过多主题,其向量表示往往会混合多个语义方向,进而降低检索阶段的匹配精度。

因此,一个可用的文本切分策略通常需要同时考虑以下 3 个因素:

  • 长度是否可控;
  • 语义边界是否尽可能完整;
  • 是否适合后续检索与溯源。

本节将结合 LangChain 中常见的实现方式,介绍 5 类常见文本切分策略,并分析它们各自的适用场景与局限性。

3.2.2 按字符长度切分#

按字符长度切分是最直接、也是最容易实现的一种策略。其基本思路是:将原始文本按照固定字符数划分为多个文本块,并在相邻文本块之间保留一定长度的重叠部分(Overlap),以降低语义在边界处被直接截断的风险。

例如,若设定每个文本块长度为 500 个字符,并设置 100 个字符的重叠区,则系统会生成一系列长度近似的文本块,其中相邻块之间共享部分上下文。

这种方法的主要优点是实现简单、长度可控,适合快速构建原型系统;其缺点则在于切分边界完全由长度决定,容易破坏句子、段落或章节的自然语义结构。

具体地,严格按照字符长度切分的代码实现如下:

 1 def text_splitter(docs):
 2     text_splitter = CharacterTextSplitter(
 3         separator="", chunk_size=500, chunk_overlap=100,
 4         add_start_index=True)
 5     all_splits = text_splitter.split_documents(docs)
 6     for i in range(len(all_splits)):
 7         print("===============")
 8         page = all_splits[i].metadata["page"]
 9         s_index = all_splits[i].metadata["start_index"]
10         print(f"划分后的文本块:\n{all_splits[i].page_content}")
11         print(f"文本块长度:{len(all_splits[i].page_content)}")
12         print(f"原始文档对应片段:\n{docs[page].page_content[s_index:s_index + 500]}")

在上述代码中,第3行 separator="" 表示忽略默认分隔符,将整段文本视为连续字符串;chunk_size 表示每个文本块的目标长度;chunk_overlap 表示相邻文本块之间保留的重叠字符数;add_start_index=True 则用于记录当前文本块在原始文档中的起始位置,便于后续定位与验证。

需要注意的是,字符级重叠仅在同一个 Document 对象内部生效。对于多页 PDF 而言,不同页面在加载后往往是不同的 Document 实例,因此页与页之间默认不会自动生成重叠区。

该策略生成的结果通常类似如下形式:

===============
## 划分后的文本块:
前言 作为《跟我一起学机器学习》的姊妹篇,两年之后《跟我一起学深度学习》一书也终于出版了。北宋大家张载有言:“为天地立心,为生民立命,为往圣继绝学,为万世开太平”……
文本块长度:500
根据索引从原始文档定位对应片段:
前言 作为《跟我一起学机器学习》的姊妹篇,两年之后《跟我一起学深度学习》一书也终于出版了。北宋大家张载有言:“为天地立心,为生民立命,为往圣继绝学,为万世开太平”……

总体而言,按字符长度切分适合对系统进行初步验证,或者用于对检索精度要求不高的简单场景,以上完整代码可参见 Code/Chapter03/C02_chunk_method1.py 文件。

3.2.3 按词元长度切分#

虽然字符长度切分实现简单,但字符数并不等同于模型实际处理时的词元(Token)数量。对于中文文本来说,这一点尤其明显。某些常见词语可能会被编码为 1 个词元,而个别生僻字、特殊符号或中英文混排内容则可能对应多个词元。

例如,在使用 Qwen3 系列词嵌入模型时,片段“是什么”将会被编码为 1 个词元(102021),而单个生僻字“鱻”则被拆分为了两个词元(100024和119)。因此,如果系统需要严格遵守模型输入上限,按词元长度切分通常比按字符长度切分更稳妥。

其核心思路是:先加载目标模型所使用的 tokenizer,再依据词元数量而非字符数量来控制每个文本块的长度,核心示例代码如下:

 1 from langchain_text_splitters import Tokenizer
 2 from transformers import AutoTokenizer
 3 
 4 tokenizer = AutoTokenizer.from_pretrained("../../Qwen/Qwen3-Embedding-0.6B")
 5
 6 def text_splitter(docs):
 7     text_splitter = CleanTokenTextSplitter(
 8         chunk_size=10, chunk_overlap=4, add_start_index=True)
 9     text_splitter._tokenizer = tokenizer
10     all_splits = text_splitter.split_documents(docs)
11     for i in range(len(all_splits)):
12         print("\n===============")
13         print(f"划分后的文本块:{all_splits[i].page_content}")
14         print(f"文本块字符长度:{len(all_splits[i].page_content)}")
15         token_ids = tokenizer.encode(all_splits[i].page_content, add_special_tokens=False)
16         print(f"词元编号:{token_ids}")
17         print(f"词元数量:{len(token_ids)}")
18         tokens = [tokenizer.decode([token_id]) for token_id in token_ids]
19         print(f"词元内容:{tokens}")

在上述代码中,第 4 行加载了 Qwen3 对应的 tokenizer,第 7~10 行通过自定义的 CleanTokenTextSplitter 按词元长度完成切分,并在输出中验证每个文本块的词元数量是否符合设定值。

上述代码运行结束后将会输出类似如下结果:

===============
划分后的文本块:上海在哪儿?垚和鱻这两个生
文本块字符长度:13
词元编号:[100633, 18493, 103379, 11319, 122400, 33108, 100024, 119, 105932, 21287]
词元数量:10
词元内容:['上海', '在', '哪儿', '?', '垚', '和', '�', '�', '这两个', '生']
===============
划分后的文本块:鱻这两个生僻字的读音是什么
文本块字符长度:13
词元编号:[100024, 119, 105932, 21287, 118134, 18600, 9370, 57553, 78685, 102021]
词元数量:10
词元内容:['�', '�', '这两个', '生', '僻', '字', '的', '读', '音', '是什么']

从上述输出结果可以明显看出,在划分好的块当中如果以字符长度而论,那么每个块对应的长度都超过了上面设定的长度 chunk_size=10,而从词元长度看则恰好满足设定的阈值。 不过,这种策略也存在明显局限,它依赖特定模型的 tokenizer,不同模型之间的分词规则可能并不一致,因此在更换嵌入模型时,切分结果也可能发生变化。总体而言,按词元长度切分更适合具有明确模型输入上限、并对稳定性要求较高的工程场景。完整代码可参见 Code/Chapter03/C04_chunk_method2_2.py 文件。

3.2.4 按语义边界递归切分#

无论是按字符长度切分,还是按词元长度切分,本质上都属于“长度优先”的策略。其问题在于,在限制固定长度后一个完整句子、一个完整段落,甚至一个自然语义单元,都可能在中间被截断。

为了解决这一问题,可以采用“语义优先”的递归切分策略。其基本思想是优先使用更高层级的分隔符进行切分,例如段落、换行或空格,只有当较大的语义单元仍然超过长度限制时,才进一步向更细粒度的分隔符递归切分。

在 LangChain 中,RecursiveCharacterTextSplitter 就是这一思路的典型实现。其默认分隔符顺序通常为 separators=["\n\n", "\n", " ", ""],这意味着系统会优先尝试按段落切分,其次按单行,再按空格,最后才退化为按字符强制切分。示例代码如下:

 1 def text_splitter_recursive(docs):
 2     text_splitter = RecursiveCharacterTextSplitter(
 3         chunk_size=45, chunk_overlap=5, add_start_index=True)
 4     all_splits = text_splitter.split_documents(docs)
 5     for i in range(len(all_splits)):
 6         print(f"划分后的文本块:\n{all_splits[i].page_content}")
 7         print(f"文本块字符长度:{len(all_splits[i].page_content)}")
 8         print("================\n")

假设输入文本如下:

我是一个段落。

作为机器学习方向的一个重要分支,深度学习在近年来的发展可谓是大放异彩。
随着深度学习技术的不断发展,与之相关的技术应用已经深入渗透到了我们日常生活的方方面面。
如今,利用 ChatGPT 来作为日常生产力工具更是成为了一种共识。

上述代码运行结束后将会输出类似如下结果:

划分后的文本块:
我是一个段落。
文本块字符长度:7
================

划分后的文本块:
作为机器学习方向的一个重要分支,深度学习在近年来的发展可谓是大放异彩。
文本块字符长度:35
================

划分后的文本块:
随着深度学习技术地不断发展,与之相关的技术应用已经深入渗透到了我们日常生活的方方面面,从
文本块字符长度:44
================

根据结果可以再次印证, RecursiveCharacterTextSplitter 默认会优先按段落、换行切,不够再按字符切,所以在第3行中 chunk_sizechunk_overlap 并不是完全等同于字符串的长度,只是一个近似长度。同时,如果切分的分隔符恰好满足 "\n\n", "\n", " ",即符合语义切分条件,那么两个块之间没有 overlap 部分。例如上面第几个块之间没有重复部分,尽管 chunk_overlap=5,因为满足"\n" 切分。

此时可以看出,这种切分策略的优点是检索结果更自然且语义完整度更高;但是缺点就是长度不稳定且差异可能较大。这种切分策略就比较适合适合对教材、说明文档等文件进行处理。以上完整代码可参见 Code/Chapter03/C05_chunk_method3.py 文件。

3.2.5 按文档结构切分#

对于某些结构化文档,仅靠字符、词元或换行符并不足以体现其真正的语义边界。以 Markdown 文档、技术手册、课程讲义、API 说明文档为例,其内容通常天然以标题层级组织。此时,若能够按标题结构切分文本,通常可以获得更完整、更稳定的语义单元。

从 RAG 角度看,这类切分方式具有两个明显优势:一是每个文本块往往对应一个完整章节或子章节,语义较为集中;二是标题信息可以保留在 metadata 中,便于后续检索结果溯源与展示。

(1)基于 Markdown 标题语法切分

对于标准 Markdown 文件,可以直接使用 MarkdownHeaderTextSplitter 按标题层级进行切分。示例代码如下:

 1 from langchain_text_splitters import MarkdownHeaderTextSplitter
 2 
 3 def text_splitter_markdown(docs):
 4     headers_to_split_on = [("#", "一级标题"), ("##", "二级标题"), ("###", "三级标题")]
 5     text_splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
 6     all_splits = text_splitter.split_text(docs[0].page_content)
 7     for i in range(len(all_splits)):
 8         data = all_splits[i]
 9         print(f"目录信息:\n{data.metadata}")
10         print(f"文本块内容:\n{data.page_content}")
11         print(f"文本块字符长度:{len(data.page_content)}")
12         print("================\n")

在上述代码中,第4行 headers_to_split_on 用于声明不同标题层级的匹配规则,随后 MarkdownHeaderTextSplitter 会依据这些规则,将正文划分为多个带有层级元数据的文本块。第5~6行则是开始对 Markdown 文件进行分块处理。

# 第一章 跟我一起学深度学习
作为机器学习方向的一个重要分支,深度学习在近年来的发展可谓是大放异彩。
## 第 1.1 节 简介
随着深度学习技术地不断发展,与之相关的技术应用已经深入渗透到了我们日常生活的方方面面,从医疗保健、金融服务到零售、交通再到智能助理、智能家居等等,尤其是在以 GPT 为代表的大语言模型出现以后,深度学习技术的影子更是无处不在。
如今,利用 ChatGPT 来作为日常生产力工具更是成为了一种共识。
### 第 1.1.1 节 说明
例如在本书的成文过程过中 ChatGPT 就为我们提供了不少的灵感和启示,部分内容也是在 ChatGPT 的辅助下所完成,而这在 10 年乃至 5 年前都是难以想象的。

也正因如此,对于这些热门应用背后技术的探索便逐渐成为了计算机行业及高校所追捧的对象。但对于绝大多数初学者来讲,想要跨入深度学习这一领域依旧存在着较高的门槛。所以,一本数形结合、 动机原理并重、 细致考究的入门书籍对于初学者来讲就显得十分必要了。

上述代码运行结束后将会输出类似如下结果:

目录信息:
{'一级标题': '第一章 跟我一起学深度学习'}
文本块内容:
作为机器学习方向的一个重要分支,深度学习在近年来的发展可谓是大放异彩。
文本块字符长度:35
================

目录信息:
{'一级标题': '第一章 跟我一起学深度学习', '二级标题': '第 1.1 节 简介'}
文本块内容:
随着深度学习技术的不断发展,与之相关的技术应用已经深入渗透到了我们日常生活的方方面面……
文本块字符长度:148
......

从上述输出结果可以看出,这种方法能够较好地保留章节语义与目录信息,不过其局限也较明显,若单个章节内容过长,仍可能超出嵌入模型长度限制,因此通常需要与其他长度控制策略配合使用。完整代码可参见 Code/Chapter03/C06_chunk_method4_1.py 文件。

(2)基于自定义规则切分

并非所有文档都遵循标准 Markdown 语法。在实际项目中,部分内容可能来自 Word 导出文本、企业内部资料或特定格式的知识文档,其章节结构虽然存在,但标题标记并不符合 Markdown 标准。

例如,某些文本可能采用如下结构:

第一章 机器学习
机器学习基础概念介绍。
1.1 什么是有监督学习
定义:有监督学习也叫做有指导学习,它是指模型在训练过程中需要通过真实值来对训练过程进行指导的学习过程。在有监督模型的训练过程中,每次输入模型的数据都是形如(x,y)这样的样本对,而模型最终学到的就是从输入x到输出y这样的映射关系。
1.1.1 常见有监督算法
常见有监督算法有:线性回归、逻辑回归、决策树、贝叶斯算法等。
1.2 什么是无监督学习
定义:无监督学习......
第二十章 深度学习
深度学习基础概念介绍。
20.1 什么是卷积操作?
定义:卷积操作是指.....
20.10.1 常见的卷积神经
常见的卷积神经网络有:LeNet5、ResNet等。

此时,如果仍希望按章节层级进行切分,就需要通过正则表达式自定义标题规则。示例代码如下:

 1 def text_splitter_markdown(docs):
 2     header_patterns = [(1, r"^\s*第\S+章.*"),
 3         (2, r"^\s*\d+\.\d+(?!\.)\s+.+$"),
 4         (3, r"^\s*\d+\.\d+\.\d+(?!\.)\s+.+$")]
 5     text_splitter = RegexTextSplitter(header_patterns)
 6     all_splits = text_splitter.split_text(docs[0].page_content)
 7     for i in range(len(all_splits)):
 8         data = all_splits[i]
 9         print(f"目录信息:\n{data.metadata}")
10         print(f"文本块内容:\n{data.page_content}")
11         print(f"文本块字符长度: {len(data.page_content)}")
12         print(f"================\n")

在上述代码中,第2~4行通过正则表达式定义了 3 级标题规则,分别对应“章”“节”和“小节”层级。只要原始文本能够稳定匹配这些模式,就可以像处理 Markdown 一样完成结构化切分。

对于上面的示例文本,上述代码运行结束后将会输出类似如下结果:

目录信息:
{'1 级标题': '第一章 机器学习'}
文本块内容:
机器学习基础概念介绍。
文本块字符长度:11
================

目录信息:
{'1 级标题': '第一章 机器学习', '2 级标题': '1.1 什么是有监督学习'}
文本块内容:
定义:有监督学习也叫做有指导学习,它是指模型在训练过程中需要通过真实值来对训练过程进行指导的学习过程。在有监督模型的训练过程中,每次输入模型的数据都是形如(x,y)这样的样本对,而模型最终学到的就是从输入x到输出y这样的映射关系。
文本块字符长度:115

目录信息:
{'1 级标题': '第一章 机器学习', '2 级标题': '1.1 什么是有监督学习', '3 级标题': '1.1.1 常见有监督算法'}
文本块内容:
常见有监督算法有:线性回归、逻辑回归、决策树、贝叶斯算法等。
文本块字符长度:30
================

目录信息:
{'1 级标题': '第一章 机器学习', '2 级标题': '1.2 什么是无监督学习'}
文本块内容:
定义:无监督学习......
文本块字符长度:14
================

目录信息:
{'1 级标题': '第二十章 深度学习'}
文本块内容:
深度学习基础概念介绍。
文本块字符长度:11
================

目录信息:
{'1 级标题': '第二十章 深度学习', '2 级标题': '20.1 什么是卷积操作?'}
文本块内容:
定义:卷积操作是指.....
文本块字符长度:14
================

目录信息:
{'1 级标题': '第二十章 深度学习', '2 级标题': '20.1 什么是卷积操作?', '3 级标题': '20.10.1 常见的卷积神经'}
文本块内容:
常见的卷积神经网络有:LeNet5、ResNet等。
文本块字符长度:26
================

从工程角度看,自定义结构切分的价值在于它使原本不符合标准格式的文档,也能够获得近似章节级别的语义组织方式。不过,这种方法依赖于规则设计的稳定性,一旦原始文本格式发生变化,匹配规则往往也需要随之调整。以上所有完整示例代码,可参见 Code/Chapter03/C06_chunk_method4_2.py 文件。

3.2.6 混合切分策略#

在真实的 RAG 系统中,往往很少只依赖单一切分策略。原因在于,任何单一方法都只能兼顾部分目标。例如,结构切分有利于保持章节语义,但可能导致文本块过长;长度切分有利于控制输入上限,但可能破坏语义边界。

因此,工业实践中更常见的做法是采用混合切分策略,其中最典型的一类方案可以概括为:结构优先,长度兜底。

其基本思路是先按照标题层级或文档结构进行粗粒度切分,以保留章节语义; 再对超长文本块使用递归切分或长度切分,以满足嵌入模型输入约束,示例代码如下:

 1 def text_splitter_mixture(docs):
 2     headers_to_split_on = [("#", "一级标题"), ("##", "二级标题"), ("###", "三级标题")]
 3     text_md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
 4     all_md_splits = text_md_splitter.split_text(docs[0].page_content)
 5     text_rec_splitter = RecursiveCharacterTextSplitter(
 6         chunk_size=20, chunk_overlap=3, add_start_index=True)
 7     all_rec_splits = text_rec_splitter.split_documents(all_md_splits)
 8     for i in range(len(all_rec_splits)):
 9         data = all_rec_splits[i]
10         print(f"目录信息:\n{data.metadata}")
11         print(f"文本块内容:\n{data.page_content}")
12         print(f"文本块字符长度:{len(data.page_content)}")
13         print("================\n")

在上述代码运行结束以后,将会得到类似如下结果:

目录信息:
{'一级标题': '第一章 跟我一起学深度学习', 'start_index': 0}
文本块内容:
作为机器学习方向的一个重要分支,深度学习
文本块字符长度:20
================

目录信息:
{'一级标题': '第一章 跟我一起学深度学习', 'start_index': 17}
文本块内容:
度学习在近年来的发展可谓是大放异彩。
文本块字符长度:18

目录信息:
{'一级标题': '第一章 跟我一起学深度学习', '二级标题': '第 1.1 节 简介', 'start_index': 0}
文本块内容:
随着深度学习技术地不断发展,与之相关的技
文本块字符长度:20
================
....

从上出结果可以看出,对于原始 Markdown 文件的第一章内容“作为机器学习方向的一个重要分支,深度学习在近年来的发展可谓是大放异彩。”,由于超过了上面设定的长度20,所以被切分成了两个块,大家可以将上面前两个块的结果和第2.4.1节输出结果的第一个块进行对比。

同时,原始章节结构信息被保留在 metadata 中,而正文部分则按照长度约束被进一步拆分。这种策略通常能够在语义完整性、长度控制和检索效果之间取得较好的平衡,因此在实际系统中被广泛采用。以上所有完整示例代码,可参见 Code/Chapter03/C08_chunk_method5.py 文件。

至此,我们已经介绍了 5 类常见文本切分策略。它们之间并不存在绝对优劣,而是服务于不同的数据形态与系统目标。 在实际项目中,文本切分策略往往需要结合文档类型、嵌入模型限制、检索效果以及系统成本反复调整。后续章节将在此基础上继续介绍向量模型与语义检索系统的构建过程。

引用#

[1] https://docs.langchain.com/oss/python/langchain/knowledge-base