CH2_02_character_splitter

4380 字
22 分钟
CH2_02_character_splitter

LangChain 提供了丰富且易于使用的文本分割器(Text Splitters),我们将介绍几种最核心的策略。

1. 固定大小分块#

这是最简单直接的分块方法。根据 LangChain 源码,这种方法的工作原理分为两个主要阶段:

(1)按段落分割CharacterTextSplitter 采用默认分隔符 "\n\n",使用正则表达式将文本按段落进行分割,通过 _split_text_with_regex 函数处理。

(2)智能合并:调用继承自父类的 _merge_splits 方法,将分割后的段落依次合并。该方法会监控累积长度,当超过 chunk_size 时形成新块,并通过重叠机制(chunk_overlap)保持上下文连续性,同时在必要时发出超长块的警告。

需要注意,CharacterTextSplitter 实际实现的并非严格的固定大小分块。根据 _merge_splits 源码逻辑,这种方法会:

  • 优先保持段落完整性:只有当添加新段落会导致总长度超过 chunk_size 时,才会结束当前块
  • 处理超长段落:如果单个段落超过 chunk_size,系统会发出警告但仍将其作为完整块保留
  • 应用重叠机制:通过 chunk_overlap 参数在块之间保持内容重叠,确保上下文连续性

所以,LangChain 的实现更准确地应该称为”段落感知的自适应分块”,块大小会根据段落边界动态调整。

from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# 1. 文档加载
loader = TextLoader("../../data/C2/txt/蜂医.txt", encoding="utf-8")
docs = loader.load()
# 2. 初始化固定大小分块器
text_splitter = CharacterTextSplitter(
chunk_size=200, # 每个块的大小
chunk_overlap=10 # 块之间的重叠大小
)

这里我们将 chunk_size 设为 200,虽然从下面能看出,实际块的长度并不是严格的 200,而是在它附近波动(块1=72,块2=201,块3=189,块4=133…)。

这正是因为如我们开头所说,CharacterTextSplitter 的工作方式是:

  1. 先用段落分隔符 \n\n 把文本切成段落;
  2. 然后 逐段合并,每合并一段就检查长度,如果刚好超过 chunk_size,就把前面的内容作为一个 chunk 输出。

所以这实际上就是一种 段落感知的自适应分块,优先保证段落的完整性。


关于 chunk_overlap,我们将它设为 10,表示相邻两个 chunk 之间会有 10 个字符的重叠。

这主要是为了 保持上下文的连续性,第一个 chunk 的结尾会出现在第二个 chunk 的开头,这样我们就不会在分块的分割处丢失信息。

需要注意的是,这里的单位指的既不是 word,也不是 token,而是字符数(characters). 我们在下面的输出中可以看到,len=72 表示 72 个字符(每个汉字、标点各算 1 个字符)。

如果我们希望按 token 分块的话,应该用基于 tiktoken 的分割器,例如 CharacterTextSplitter.from_tiktoken_encoder(chunk_size=500, chunk_overlap=50),这样 chunk_sizechunk_overlap 的单位就是 tokens 了。

# 3. 执行分块
chunks = text_splitter.split_documents(docs)

输出为:

Created a chunk of size 201, which is longer than the specified 200
Created a chunk of size 277, which is longer than the specified 200
Created a chunk of size 296, which is longer than the specified 200

需要注意的是,上述输出并不代表我们只分出了 3 个 chunks. 上面的输出只是警告,只在 单个段落已超长 时触发。

也就是说:

  • 如果没超过 -> 继续合并下一个段落,不产生警告;
  • 如果超过了 -> 把前面的内容作为一个 chunk 输出,也不产生警告
  • 但如果这一个段落 本身就超过 200 -> 触发警告。

所以,从下面我们可以看出,我们实际上分出了 14 个 chunk. 只有 3 个警告的原因是这 14 个 chunk 中,大多数 chunk 都是由多个小段落合并而成的(总长度 200\le 200 或略超 200)。

# 4. 打印结果
print(f"文本被切分为 {len(chunks)} 个块。\n")
print("--- 前5个块内容示例 ---")
for i, chunk in enumerate(chunks[:5]):
print("=" * 60)
# chunk 是一个 Document 对象,需要访问它的 .page_content 属性来获取文本
print(f'块 {i+1} (长度: {len(chunk.page_content)}): "{chunk.page_content}"')

输出为:

文本被切分为 14 个块。
--- 前5个块内容示例 ---
============================================================
块 1 (长度: 72): "# 蜂医
游戏《三角洲行动》中的支援型干员
蜂医是2024年琳琅天上发行的《三角洲行动》中的支援型干员之一,在早期版本是唯一一个支援型干员。"
============================================================
块 2 (长度: 201): "蜂医在游戏中能够使用战术装备“激素枪”:远程治疗队友或'自我治疗',还可以使用兵种道具“烟幕无人机”:释放长烟幕,和“蜂巢科技烟雾弹”:产生一团白色烟雾(使用激素枪对烟雾射击换变成绿色烟雾,可起到治疗作用),干员特长为“高效救援”:救援倒地队友时速度更快,在全面战场模式中约1.4秒就能救起队友,且被救起的队友能恢复更多生命值。在烽火地带中,还能够移除队友血量上限减少的负面效果。 \[1-2]****"
============================================================
块 3 (长度: 189): "* 中文名
罗伊•斯米
* 外文名
Roy smee \[2]**
* 别 名
罗伊、蜂医
* 性 别
...
蜂医在干员档案中标明他有一个妻子和女儿。
## 角色定位"

2. 递归字符分块#

在前面的章节中(见 CH1_01_langchain_example),我们已经尝试了使用 RecursiveCharacterTextSplitter 的默认配置来处理文档分块。现在让我们深入了解 RecursiveCharacterTextSplitter 的实现。这种分块器通过分隔符层级递归处理,相对与固定大小分块,改善了超长文本的处理效果。

算法流程

(1)寻找有效分隔符: 从分隔符列表中从前到后遍历,找到第一个在当前文本中存在的分隔符。如果都不存在,使用最后一个分隔符(通常是空字符串 "")。

(2)切分与分类处理: 使用选定的分隔符切分文本,然后遍历所有片段:

  • 如果片段不超过块大小: 暂存到 _good_splits 中,准备合并
  • 如果片段超过块大小:
    • 首先,将暂存的合格片段通过 _merge_splits 合并成块
    • 然后,检查是否还有剩余分隔符:
      • 有剩余分隔符: 递归调用 _split_text 继续分割
      • 无剩余分隔符: 直接保留为超长块

(3)最终处理: 将剩余的暂存片段合并成最后的块

实现细节

  • 批处理机制: 先收集所有合格片段(_good_splits),遇到超长片段时才触发合并操作。
  • 递归终止条件: 关键在于 if not new_separators 判断。当分隔符用尽时(new_separators 为空),停止递归,直接保留超长片段。确保算法不会无限递归。

与固定大小分块的关键差异

  1. 本质区别
    • 固定大小分块遇到超长段落时只能发出警告,然后原样保留这个超长块。换句话说,它放弃了对这个超长段的进一步切分。
    • 遇到超大片段时,会换用更细粒度的分隔符继续切。比如:段落切不动 → 用句号切 → 用逗号切 → 用空格切 → 逐字符切。它会把问题一直”递归下去”,直到块大小满足要求或分隔符用尽。
  2. 分隔符策略
    • 固定大小分块:只用一个分隔符(默认 \n\n,即段落标记);
    • 递归分块:使用 一组 按优先级排列的分隔符。
  3. 语义完整性
    • 固定大小分块:尽量保持段落完整,块的边界落在段落之间,语义相对较好;
    • 递归分块:更灵活,块大小更均匀,但如果不得不切到句号甚至逗号级别,可能会在句子中间断开,对中文等无明显词边界的语言需要额外配置分隔符(如 。、,)。
flowchart TD A["原始文本"] --> B{"用最高优先级分隔符切分"} B --> C["片段 ≤ chunk_size?"] C -->|是| D["✅ 合格,暂存待合并"] C -->|否| E{"还有其他分隔符?"} E -->|是| F["🔄 递归:换下一级分隔符继续切"] F --> B E -->|否| G["⚠️ 放弃,保留为超长块"]
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
loader = TextLoader("../../data/C2/txt/蜂医.txt", encoding="utf-8")
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
# 针对中英文混合文本,定义一个更全面的分隔符列表
separators=["\n\n", "\n", "。", ",", " ", ""], # 按顺序尝试分割
chunk_size=200,
chunk_overlap=10
)
chunks = text_splitter.split_documents(docs)

如果不预先定义分隔符列表的话,RecursiveCharacterTextSplitter 使用的默认分隔符列表为 ["\n\n", "\n", " ", ""](4 级优先级)。实际上,如果所有段落都 \le chunk_size 的话,递归分块与固定大小分块的行为 完全一致,因为我们可以从上面的默认分隔符列表看到,递归分块也是先用 \n\n 切出段落。而如果每个段落都不超长的话,后续的分隔符根本没有机会上场。

flowchart LR A["文本段落都 ≤ chunk_size"] --> B["两者行为完全相同 ✅"] C["存在超长段落"] --> D["CharacterTextSplitter: ⚠️ 警告 + 原样保留"] C --> E["RecursiveCharacterTextSplitter: 🔄 用 \n 再切 → 用空格切 → 逐字符切"]

因此从这个角度看,递归分块相当于固定分块 + 兜底递归机制,多了一道保险。


在定义 separators 分隔符列表时,LangChain 实际上有丰富的多语言支持,对于无词边界语言(中文、日文、泰文等),我们可以添加:

separators=[
"\n\n", "\n", " ",
".", ",", "\u200b", # 零宽空格(泰文、日文)
"\uff0c", "\u3001", # 全角逗号、表意逗号
"\uff0e", "\u3002", # 全角句号、表意句号
""
]

而对于编程语言,RecursiveCharacterTextSplitter 能够针对特定的编程语言(如Python, Java等)使用预设的、更符合代码结构的分隔符。它们通常包含语言的顶级语法结构(如类、函数定义)和次级结构(如控制流语句),以实现更符合代码逻辑的分割:

# 针对代码文档的优化分隔符
splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON, # 支持Python、Java、C++等
chunk_size=500,
chunk_overlap=50
)
print(f"文本被切分为 {len(chunks)} 个块。\n")
print("--- 前5个块内容示例 ---")
for i, chunk in enumerate(chunks[:5]):
print("=" * 60)
print(f'块 {i+1} (长度: {len(chunk.page_content)}): "{chunk.page_content}"')

3. 语义分块#

语义分块(Semantic Chunking)是一种更智能的方法,这种方法不依赖于固定的字符数或预设的分隔符,而是尝试根据文本的语义内涵来切分。其核心是:在语义主题发生显著变化的地方进行切分。这使得每个分块都具有高度的内部语义一致性。LangChain 提供了 langchain_experimental.text_splitter.SemanticChunker 来实现这一功能。

3.1 实现原理#

SemanticChunker 的工作流程可以概括为以下几个步骤:

(1)句子分割 (Sentence Splitting):首先,使用标准的句子分割规则(例如,基于句号、问号、感叹号)将输入文本拆分成一个句子列表。

(2)上下文感知嵌入 (Context-Aware Embedding):这是 SemanticChunker 的一个关键设计。该分块器不是对每个句子独立进行嵌入,而是通过 buffer_size 参数(默认为1)来捕捉上下文信息。对于列表中的每一个句子,这种方法会将其与前后各 buffer_size 个句子组合起来,然后对这个临时的、更长的组合文本进行嵌入。这样,每个句子最终得到的嵌入向量就融入了其上下文的语义。

(3)计算语义距离 (Distance Calculation):计算每对相邻句子的嵌入向量之间的余弦距离。这个距离值量化了两个句子之间的语义差异——距离越大,表示语义关联越弱,跳跃越明显。

(4)识别断点 (Breakpoint Identification)SemanticChunker 会分析所有计算出的距离值,并根据一个统计方法(默认为 percentile)来确定一个动态阈值。例如,它可能会将所有距离中第 95 百分位的值作为切分阈值。所有距离大于此阈值的点,都被识别为语义上的“断点”。

(5)合并成块 (Merging into Chunks):最后,根据识别出的所有断点位置,将原始的句子序列进行切分,并将每个切分后的部分内的所有句子合并起来,形成一个最终的、语义连贯的文本块。

断点识别方法 (breakpoint_threshold_type)

如何定义“显著的语义跳跃”是语义分块的关键。SemanticChunker 提供了几种基于统计的方法来识别断点:

  • percentile (百分位法 - 默认方法):

    • 逻辑: 计算所有相邻句子的语义差异值,并将这些差异值进行排序。当一个差异值超过某个百分位阈值时,就认为该差异值是一个断点。
    • 参数: breakpoint_threshold_amount (默认为 95),表示使用第 95 个百分位作为阈值。这意味着,只有最显著的 5% 的语义差异点会被选为切分点。
  • standard_deviation (标准差法):

    • 逻辑: 计算所有差异值的平均值和标准差。当一个差异值超过“平均值 + N * 标准差”时,被视为异常高的跳跃,即断点。
    • 参数: breakpoint_threshold_amount (默认为 3),表示使用 3 倍标准差作为阈值。
  • interquartile (四分位距法):

    • 逻辑: 使用统计学中的四分位距(IQR)来识别异常值。当一个差异值超过 Q3 + N * IQR 时,被视为断点。
    • 参数: breakpoint_threshold_amount (默认为 1.5),表示使用 1.5 倍的IQR。
  • gradient (梯度法):

    • 逻辑: 这是一种更复杂的方法。它首先计算差异值的变化率(梯度),然后对梯度应用百分位法。对于那些句子间语义联系紧密、差异值普遍较低的文本(如法律、医疗文档)特别有效,因为这种方法能更好地捕捉到语义变化的“拐点”。
    • 参数: breakpoint_threshold_amount (默认为 95)。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.document_loaders import TextLoader
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-zh-v1.5",
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)

这里我们实例化一个 embedding 模型,其中的 normalize_embeddings=True 指的是:告诉 HuggingFace 的 embedding 模型,将输出的向量做 L2 归一化,使得每个向量的模长变为 1.

简单回顾一下 L2 归一化,对于一个原始向量 v=[v1,v2,...,vn]\mathbf{v}=[v_1,v_2,...,v_n],归一化后就变成:

vnorm=vv2,vnorm2=1\mathbf{v}_{\text{norm}}=\frac{\mathbf{v}}{||\mathbf{v}||_2},||\mathbf{v}_{\text{norm}}||_2=1

这样做的原因是,归一化后 向量点积=余弦相似度

anormbnorm=cos(θ)\mathbf{a}_{\text{norm}}\cdot \mathbf{b}_{\text{norm}}=\cos(\theta)

对于 RAG 检索,这有几个好处:

  • 检索更快:余弦相似度需要除法(模长相乘),而归一化后直接用点积算,省了除法运算;
  • 兼容 Milvus / FAISS 等向量库:很多向量库的默认索引(如 Milvus 的 IP 度量)假设输入已是归一化向量;
  • 分数有界:归一化后的点积范围是 [1,1][-1,1] ,方便设定相似度阈值。
# 初始化 SemanticChunker
text_splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile" # 也可以是 "standard_deviation", "interquartile", "gradient"
)
loader = TextLoader("../../data/C2/txt/蜂医.txt", encoding="utf-8")
documents = loader.load()
docs = text_splitter.split_documents(documents)
print(f"文本被切分为 {len(docs)} 个块。\n")
print("--- 前2个块内容示例 ---")
for i, chunk in enumerate(docs[:2]):
print("=" * 60)
print(f'块 {i+1} (长度: {len(chunk.page_content)}):\n"{chunk.page_content}"')

输出为:

# percentile 的切分结果
文本被切分为 2 个块。
--- 前2个块内容示例 ---
============================================================
块 1 (长度: 566):
"# 蜂医
游戏《三角洲行动》中的支援型干员
蜂医是2024年琳琅天上发行的《三角洲行动》中的支援型干员之一,在早期版本是唯一一个支援型干员。
...
1. 1[角色设定](#1)
2."
============================================================
块 2 (长度: 1776):
"2[角色定位](#2)
3. 3[技能](#3)
1) ▪[战术装备 - 激素枪](#3-1)
...
* 4
[](#sup-4)
[《三角洲行动》全新黑夜之子赛季4月17日正式上线](/reference/65245483/533aYdO6cr3_z3kATPSPmKqhNHmQNIivurSFV-ZzzqIPmGapB4rjFJkjrtQw87hkBETK4sgwNIZMxr6nWxIb8a5OJbh9XO1mwCo).腾讯游戏 \[引用日期2025-06-13]**
"
# standard_deviation 的切分结果
文本被切分为 1 个块。
--- 前2个块内容示例 ---
============================================================
块 1 (长度: 2343):
"# 蜂医
游戏《三角洲行动》中的支援型干员
蜂医是2024年琳琅天上发行的《三角洲行动》中的支援型干员之一,在早期版本是唯一一个支援型干员。
...
* 4
[](#sup-4)
[《三角洲行动》全新黑夜之子赛季4月17日正式上线](/reference/65245483/533aYdO6cr3_z3kATPSPmKqhNHmQNIivurSFV-ZzzqIPmGapB4rjFJkjrtQw87hkBETK4sgwNIZMxr6nWxIb8a5OJbh9XO1mwCo).腾讯游戏 \[引用日期2025-06-13]**
"
# interquartile 切分结果与上同
# gradient 切分结果
文本被切分为 2 个块。
--- 前2个块内容示例 ---
============================================================
块 1 (长度: 551):
"# 蜂医
游戏《三角洲行动》中的支援型干员
蜂医是2024年琳琅天上发行的《三角洲行动》中的支援型干员之一,在早期版本是唯一一个支援型干员。
...
- 体    重
75 kg \[3]**
## 目录
1."
============================================================
块 2 (长度: 1791):
"1[角色设定](#1)
2. 2[角色定位](#2)
...
* 4
[](#sup-4)
[《三角洲行动》全新黑夜之子赛季4月17日正式上线](/reference/65245483/533aYdO6cr3_z3kATPSPmKqhNHmQNIivurSFV-ZzzqIPmGapB4rjFJkjrtQw87hkBETK4sgwNIZMxr6nWxIb8a5OJbh9XO1mwCo).腾讯游戏 \[引用日期2025-06-13]**
"

需要注意的是,LangChain 中的 SemanticChunker 没有任何的 chunk_size 上限,也就是说,分块完全由语义距离决定,如果相邻句子间的语义距离都较小(低于阈值),所有句子会合并成一个块,不管它有多长

在这里我们可以看到,如果选用 standard_deviationinterquartile 策略,整个文档就只会被切成 1 块。

所以,如果遇到长文档语义连贯的情况,可以考虑在语义分块的外面再加一层长度兜底,防止单块切得太长。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CH2_02_character_splitter
https://datawhalechina.github.io/all-in-rag/#/chapter1/03_get_start_rag
作者
HAC
发布于
2026-06-18
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
HAC
观之非易,行且克难
Greetings
欢迎来到我的博客!这里主要分享我的学习笔记与兴趣爱好。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
32
分类
5
标签
13
总字数
79,889
运行时长
0
最后活动
0 天前

文章目录