尊敬的各位同仁,
欢迎来到今天的讲座。在人工智能,特别是大型语言模型(LLM)飞速发展的今天,我们正面临一个看似简单却又充满挑战的基础问题:如何有效地处理海量的文本数据,并将其以一种对AI模型友好的方式呈现。LLM的强大能力毋庸置疑,但它们并非没有局限。其中最显著的,便是“上下文窗口”的限制。这意味着模型一次能够处理的文本量是有限的。
当我们需要向LLM提供一份长达数万甚至数十万字的文档,例如一份技术手册、一本小说、或者一份复杂的法律合同,我们不能简单地将整个文档一次性喂给模型。这就引出了一个核心需求:文本切割(Text Splitting),或者更常用的术语:文本分块(Chunking)。
然而,文本分块绝非简单地“剪切”文本。今天,我们将深入探讨一个在LLM应用开发中至关重要的工具——RecursiveCharacterTextSplitter。我们将从最基础的问题出发:为什么简单的长度切割会破坏段落的语义完整性?接着,我们将详细解析RecursiveCharacterTextSplitter如何以其巧妙的设计,在满足长度限制的同时,最大程度地保留文本的语义连贯性。
一、语义完整性:文本处理的基石
在深入RecursiveCharacterTextSplitter之前,我们必须先理解为什么“语义完整性”在文本处理中如此关键。
想象一下,你正在阅读一本关于量子物理学的教科书。如果你拿起一把剪刀,随机地将书页剪成固定大小的小纸片,然后试图从这些碎片中理解量子纠缠的复杂概念,你会有何感受?你可能会发现一个句子被截断了,一个段落的开头和结尾分属不同的纸片,甚至一个公式的一半在这一片,另一半在那一片。这样的阅读体验无疑是灾难性的,因为文本的“语义完整性”已经被彻底破坏了。
对于人类尚且如此,对于LLM而言,这个问题则更为严峻。LLM通过分析文本中的词语、句子、段落之间的关系来理解语境、提取信息、生成回答。如果输入给模型的信息是零散的、不连贯的,那么模型将难以:
- 理解上下文: 句子被切断,段落被肢解,模型无法建立起完整的语义图谱。
- 提取关键信息: 核心论点、事实或实体可能被分散到不同的块中,导致信息丢失或难以关联。
- 生成高质量响应: 基于破碎的上下文,模型容易产生不准确、不完整甚至“幻觉”的回答。
- 进行有效检索: 在检索增强生成(RAG)系统中,如果文本块不具备良好的语义边界,检索到的块可能无法提供足够连贯的上下文来回答用户查询。
简而言之,一个好的文本切割策略,其核心目标是在满足LLM上下文窗口限制的前提下,尽可能地保持文本的语义连贯性,将文本分解为最小的、仍能独立表达完整意义的单元。
二、简单长度切割的陷阱
现在,让我们通过一个具体的例子,看看最直观、最简单的文本切割方法——按固定长度切割——是如何破坏语义完整性的。
假设我们有一段描述某个概念的文本:
"大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代码辅助等。然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。"
如果我们的目标是将这段文本切割成最大长度为50个字符的块,并且我们采用最简单的字符计数方式进行切割,代码可能如下:
def simple_char_splitter(text: str, chunk_size: int):
chunks = []
current_pos = 0
while current_pos < len(text):
chunk = text[current_pos : current_pos + chunk_size]
chunks.append(chunk)
current_pos += chunk_size
return chunks
text_to_split = "大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代码辅助等。然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。"
chunk_size = 50
simple_chunks = simple_char_splitter(text_to_split, chunk_size)
print("--- 简单长度切割结果 ---")
for i, chunk in enumerate(simple_chunks):
print(f"Chunk {i+1} (长度: {len(chunk)}): '{chunk}'")
输出结果会是这样:
--- 简单长度切割结果 ---
Chunk 1 (长度: 50): '大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理'
Chunk 2 (长度: 50): '解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代'
Chunk 3 (长度: 50): '码辅助等。然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文'
Chunk 4 (长度: 50): '本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它'
Chunk 5 (长度: 50): '将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。'
我们来分析一下这些块:
- Chunk 1: "大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理" —— "理解"这个词被硬生生截断了。句子的主语和谓语被分开了。
- Chunk 2: "解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代" —— "代码辅助"的“代”字被截断。
- Chunk 3: "码辅助等。然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文" —— "文本量"的“文”字被截断。
- 等等…
这种切割方式的弊端显而易见:
- 词语被截断: 这是最直接的破坏,导致词语失去意义。
- 句子被截断: 一个完整的句子被一分为二,使得模型的语法分析和语义理解变得困难。
- 语义单元被破坏: 段落、论点、列表项等自然的语义边界完全被忽略。
- 上下文碎片化: 各个块之间缺乏逻辑连接,模型难以将它们拼接起来形成一个连贯的整体。
在RAG系统中,如果用户查询的是“LLM的局限性是什么?”,检索系统可能会返回Chunk 3,但由于其开头和结尾都是不完整的,模型很难从中准确地提取“上下文窗口有限”这一核心信息,或者需要更多的推理才能补全。
这就是为什么我们需要一个更智能、更精细的文本切割策略。
三、RecursiveCharacterTextSplitter:递归字符文本切割器
RecursiveCharacterTextSplitter正是为了解决上述问题而设计的。它不是简单地按固定长度暴力切割,而是采用一种“递归”的策略,尝试在尽可能大的语义单位处进行切割,如果切割后的块仍然过大,则继续使用更小的语义单位进行切割,直到所有块都满足长度限制。
3.1 核心思想:优先切割大的语义单元
其核心思想在于:它会尝试使用一组预定义的、按优先级排序的分隔符来切割文本。
这些分隔符通常按从大到小的语义单位排序:
- 双换行符 (
nn): 通常代表段落之间的分隔。这是最大的语义单元,优先尝试在此处切割。 - 单换行符 (
n): 通常代表一行文本的结束,或者列表项、代码行之间的分隔。 - 空格 (
): 代表词语之间的分隔。 - 空字符串 (
""): 如果以上分隔符都无法将文本切割到指定长度,最终的兜底策略,按单个字符切割。
3.2 “递归”的策略:迭代尝试
这里的“递归”并非指函数本身的递归调用,而是指其处理逻辑的迭代性质。
它的工作流程可以概括为:
- 给定一个长文本和一组分隔符列表
[sep1, sep2, sep3, ..., sepN]。 - 尝试使用第一个分隔符
sep1切割文本。 这会生成一系列子字符串。 - 检查每个子字符串的长度。
- 如果子字符串的长度小于或等于
chunk_size,则它是一个有效的块。 - 如果子字符串的长度仍然大于
chunk_size,那么这个子字符串就需要进一步处理。
- 如果子字符串的长度小于或等于
- 对那些过长的子字符串,使用下一个分隔符
sep2再次进行切割。 这就好像我们“递归”地对这些过长的部分应用了相同的切割逻辑,只是使用了更细粒度的分隔符。 - 这个过程会一直重复,直到用尽所有的分隔符,或者所有生成的块都满足
chunk_size的要求。最终,即使是单个字符的切割,也会被应用到那些无法通过更高级别分隔符切分的超长“词语”上。
通过这种方式,RecursiveCharacterTextSplitter会优先在段落边界切割,如果段落过长,则在行边界切割,如果行过长,则在词语边界切割,以此类推。这最大程度地保留了文本的语义单元。
3.3 关键参数解析
RecursiveCharacterTextSplitter有几个重要的参数,它们共同决定了切割的行为:
chunk_size(int): 每个文本块的最大长度。这是我们希望每个输出块能达到的上限。chunk_overlap(int): 块与块之间的重叠字符数。这是一个非常重要的参数,它有助于在相邻块之间保持上下文的连续性。当一个问题或概念横跨两个块时,重叠可以确保模型在处理某个块时,也能“看到”前一个块或后一个块的部分内容,从而更好地理解上下文。separators(List[str]): 一个字符串列表,用于指定切割文本时尝试的分隔符。分隔符的顺序至关重要,它定义了切割的优先级。默认值通常是["nn", "n", " ", ""]。length_function(Callable[[str], int]): 一个函数,用于计算文本块的长度。默认是Python内置的len函数,它计算字符数。然而,对于LLM,我们通常更关心的是“token”数量,因此可以传入一个基于tokenizer的长度函数。keep_separator(bool): 是否在切割后的块中保留分隔符。默认为False。如果设置为True,分隔符会出现在每个块的开头,这在某些场景下有助于理解块的来源和结构。
四、RecursiveCharacterTextSplitter 的实际应用与代码示例
现在,让我们通过具体的代码示例来展示RecursiveCharacterTextSplitter的强大功能。我们将使用langchain库中的实现,因为它是一个非常流行的LLM应用开发框架,并且其文本切割器功能完善。
首先,确保你已经安装了langchain:
pip install langchain
4.1 基本用法与语义优势展示
我们将再次使用之前的长文本,并应用RecursiveCharacterTextSplitter进行切割。
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_to_split = "大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代码辅助等。nn然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。"
# 为了更好地演示效果,我们稍微调整文本,让它有明确的段落分隔符 "nn"
# 并设置一个较小的 chunk_size 和 chunk_overlap
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=50,
chunk_overlap=10,
separators=["nn", "n", " ", ""] # 默认分隔符
)
recursive_chunks = text_splitter.split_text(text_to_split)
print("--- RecursiveCharacterTextSplitter 切割结果 ---")
for i, chunk in enumerate(recursive_chunks):
print(f"Chunk {i+1} (长度: {len(chunk)}): '{chunk}'")
输出结果:
--- RecursiveCharacterTextSplitter 切割结果 ---
Chunk 1 (长度: 47): '大型语言模型(LLM)在自然语言处理领域取得了显著进展。'
Chunk 2 (长度: 46): '它们能够理解、生成和处理人类语言,应用场景广泛,'
Chunk 3 (长度: 32): '包括内容创作、智能客服、代码辅助等。'
Chunk 4 (长度: 48): '然而,LLM的上下文窗口是有限的,这意味着它们一次性'
Chunk 5 (长度: 46): '处理的文本量存在上限。为了克服这一限制,文本分块'
Chunk 6 (长度: 45): '成为一个关键预处理步骤,它将长文档拆分为更小的、'
Chunk 7 (长度: 44): '可管理的部分,以便逐一输入到模型中进行处理。'
对比简单切割,我们可以看到显著的改进:
- 没有词语被截断: 所有切割都在词语之间进行,保证了词语的完整性。
- 段落优先: 第一个
nn分隔符被优先处理,将文本自然地分成了两大部分。虽然每个段落内部因长度限制仍需切割,但切割点是在词语之间。 - 可读性增强: 每个块都至少包含完整的词语,更容易理解其含义。
chunk_overlap效果: 虽然在这个例子中不明显,但重叠会在内部处理时确保相邻块之间共享部分上下文,例如,如果Chunk 2和Chunk 3有重叠,Chunk 3会包含Chunk 2末尾的一部分,增加连贯性。
4.2 自定义分隔符与优先级
分隔符的顺序是RecursiveCharacterTextSplitter的灵魂。我们可以根据不同的文档类型和需求自定义分隔符列表。
示例:处理Markdown文档
Markdown文档通常包含标题(#、##等)、列表项、代码块等结构。为了更好地保留这些语义单元,我们可以将标题分隔符放在前面。
from langchain.text_splitter import RecursiveCharacterTextSplitter
markdown_text = """
# 深入理解LLM分块策略
## 为什么需要分块
大型语言模型(LLM)因其强大的自然语言处理能力而备受关注。
然而,它们的上下文窗口限制了单次处理的文本量。
例如,GPT-3.5-turbo 可能只有4k或16k token的上下文。
## RecursiveCharacterTextSplitter
这是一种智能的文本分块器。
它优先在段落边界(`nn`)切割。
如果段落过长,则尝试在行边界(`n`)切割。
最终,如果必要,会在单词边界(` `)甚至字符边界(`""`)切割。
### 示例代码
```python
# 这是一个代码块
def hello_world():
print("Hello, world!")
总结:选择合适的分隔符至关重要。
"""
针对Markdown的自定义分隔符
优先按一级标题,然后二级标题,然后段落,然后行,然后空格,最后字符
markdown_splitter = RecursiveCharacterTextSplitter(
chunk_size=150, # 增大 chunk_size 以便看清结构
chunk_overlap=20,
separators=["nn#", "nn##", "nn###", "nn", "n", " ", ""]
)
markdown_chunks = markdown_splitter.split_text(markdown_text)
print("n— Markdown文档切割结果 —")
for i, chunk in enumerate(markdown_chunks):
print(f"Chunk {i+1} (长度: {len(chunk)}): ‘{chunk}’")
输出示例(可能因 `chunk_size` 和内容略有不同,但能体现结构):
— Markdown文档切割结果 —
Chunk 1 (长度: 57): ‘# 深入理解LLM分块策略’
Chunk 2 (长度: 60): ‘## 为什么需要分块’
Chunk 3 (长度: 129): ‘大型语言模型(LLM)因其强大的自然语言处理能力而备受关注。n然而,它们的上下文窗口限制了单次处理的文本量。n例如,GPT-3.5-turbo 可能只有4k或16k token的上下文。’
Chunk 4 (长度: 46): ‘## RecursiveCharacterTextSplitter’
Chunk 5 (长度: 30): ‘这是一种智能的文本分块器。’
Chunk 6 (长度: 132): ‘它优先在段落边界(nn)切割。n如果段落过长,则尝试在行边界(n)切割。n最终,如果必要,会在单词边界(`)甚至字符边界(""`)切割。’
Chunk 7 (长度: 48): ‘### 示例代码’
Chunk 8 (长度: 44): ‘pythonn# 这是一个代码块ndef hello_world():' Chunk 9 (长度: 20): ' print("Hello, world!")n‘
Chunk 10 (长度: 22): ‘总结:选择合适的分隔符至关重要。’
注意观察:
* 一级标题和二级标题都被保留在了独立的块中(Chunk 1, Chunk 2, Chunk 4, Chunk 7)。这是因为我们把 `"nn#"` 和 `"nn##"` 等放在了分隔符列表的前面。
* 代码块也被尽可能地作为一个整体保留。虽然这里因为 `chunk_size` 小,代码块内部被分割了,但它仍然试图在行级别分割,而不是随机字符分割。
* 每个逻辑段落,如“为什么需要分块”下的内容(Chunk 3),尽可能地保留在了一个块中。
这个例子清楚地展示了通过调整 `separators` 参数,我们可以如何指导 `RecursiveCharacterTextSplitter` 更好地理解和尊重特定文档类型的结构。
#### 4.3 `chunk_overlap` 的作用
`chunk_overlap` 参数在RAG系统中尤为重要。它允许相邻的文本块之间共享一部分内容。这有助于在检索时,即使检索到的主要块可能缺失某些上下文,其重叠部分也能提供必要的线索。
让我们用一个简短的例子来演示重叠:
```python
from langchain.text_splitter import RecursiveCharacterTextSplitter
short_text = "这是第一句话。这是第二句话。这是第三句话。这是第四句话。"
# 无重叠
splitter_no_overlap = RecursiveCharacterTextSplitter(
chunk_size=15,
chunk_overlap=0,
separators=["。"] # 以句号切割
)
chunks_no_overlap = splitter_no_overlap.split_text(short_text)
print("n--- 无重叠切割 ---")
for i, chunk in enumerate(chunks_no_overlap):
print(f"Chunk {i+1}: '{chunk}'")
# 有重叠
splitter_with_overlap = RecursiveCharacterTextSplitter(
chunk_size=15,
chunk_overlap=5, # 重叠5个字符
separators=["。"]
)
chunks_with_overlap = splitter_with_overlap.split_text(short_text)
print("n--- 有重叠切割 (overlap=5) ---")
for i, chunk in enumerate(chunks_with_overlap):
print(f"Chunk {i+1}: '{chunk}'")
输出:
--- 无重叠切割 ---
Chunk 1: '这是第一句话'
Chunk 2: '这是第二句话'
Chunk 3: '这是第三句话'
Chunk 4: '这是第四句话'
--- 有重叠切割 (overlap=5) ---
Chunk 1: '这是第一句话'
Chunk 2: '第一句话。这是第二句话' # 注意 '第一句话' 重叠了
Chunk 3: '第二句话。这是第三句话' # 注意 '第二句话' 重叠了
Chunk 4: '第三句话。这是第四句话' # 注意 '第三句话' 重叠了
在这个例子中,因为我们以句号切割,且句号在 chunk_size 范围内,所以每个块都以句号结束。当 chunk_overlap=5 时,你会看到每个后续块的开头都包含前一个块的最后5个字符(包括分隔符 。)。这种重叠对于模型理解相邻块之间的关系非常有益,尤其是在 RAG 场景中。
4.4 length_function:从字符到Token的度量
默认情况下,RecursiveCharacterTextSplitter使用 len() 函数计算字符长度。然而,LLM的上下文窗口限制通常是以“token”为单位的。不同的tokenizer对相同文本的token计数可能大相径庭。因此,使用一个基于LLM特定tokenizer的 length_function 是最佳实践。
以OpenAI模型为例,我们可以使用 tiktoken 库来计算token数。
首先,安装 tiktoken:
pip install tiktoken
然后,我们可以这样定义一个基于token的长度函数:
import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 假设我们使用 'cl100k_base' 编码,这是GPT-3.5-turbo和GPT-4使用的编码
tokenizer = tiktoken.get_encoding("cl100k_base")
def tiktoken_len(text):
return len(tokenizer.encode(text))
text_to_split = "大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代码辅助等。nn然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。"
# 使用 tiktoken_len 作为长度函数
token_splitter = RecursiveCharacterTextSplitter(
chunk_size=100, # 现在这个 chunk_size 是以 token 为单位
chunk_overlap=20, # 重叠也是以 token 为单位
length_function=tiktoken_len,
separators=["nn", "n", " ", ""]
)
token_chunks = token_splitter.split_text(text_to_split)
print("n--- 基于Token的切割结果 ---")
for i, chunk in enumerate(token_chunks):
token_count = tiktoken_len(chunk)
print(f"Chunk {i+1} (字符长度: {len(chunk)}, Token长度: {token_count}): '{chunk}'")
输出示例(chunk_size 为100 token,chunk_overlap 为20 token):
--- 基于Token的切割结果 ---
Chunk 1 (字符长度: 122, Token长度: 41): '大型语言模型(LLM)在自然语言处理领域取得了显著进展。它们能够理解、生成和处理人类语言,应用场景广泛,包括内容创作、智能客服、代码辅助等。'
Chunk 2 (字符长度: 131, Token长度: 46): '辅助等。nn然而,LLM的上下文窗口是有限的,这意味着它们一次性处理的文本量存在上限。为了克服这一限制,文本分块成为一个关键预处理步骤,它将长文档拆分为更小的、可管理的部分,以便逐一输入到模型中进行处理。'
可以看到,尽管chunk_size设置为100 token,但实际生成的块可能远小于100 token。这是因为RecursiveCharacterTextSplitter会优先使用分隔符进行切割,如果在分隔符处切割后,生成的块已经小于或等于目标chunk_size,它就不会再进一步切割。例如,上述例子中,整个文本在nn处被分成两部分,这两部分各自的token数都远小于100,因此它们成为独立的块。这种行为是符合预期的,因为它优先保持语义完整性。
如果一个自然段落的token数超过了chunk_size,那么RecursiveCharacterTextSplitter就会在其内部应用下一个分隔符(如n, ),直到满足长度限制。
4.5 文档类型与分隔符选择建议
选择合适的分隔符是优化RecursiveCharacterTextSplitter性能的关键。以下是一些常见文档类型及其推荐的分隔符策略:
| 文档类型 | 推荐 Separators (示例) | 优先级与Rationale |
|---|---|---|
| 通用散文 | ["nn", "n", " ", ""] |
高优先级: nn (段落) – 尽可能保持整个段落的完整性。中优先级: n (行) – 如果段落过长,则按行切割。低优先级: ` (词) - 如果行过长,则按词切割。<br>**最低优先级:**""` (字符) – 最终兜底,确保任何超长字符串都能被切割。 |
| Markdown | ["nn#", "nn##", "nn###", "nn", "n", " ", ""] |
高优先级: nn#, nn##, nn### (各级标题) – 优先将标题及其下的内容作为一个整体。这有助于保持文档的逻辑结构。次高优先级: nn (段落) – 保持段落完整性。中优先级: n (行) – 针对列表项、代码块等。低优先级: ` (词) - 确保词语完整。<br>**最低优先级:**""` (字符)。 |
| 代码 (Python) | ["nnclass ", "nndef ", "nn", "n", " ", ""] |
高优先级: nnclass, nndef (类和函数定义) – 优先将整个类或函数作为一个块。这对于代码理解至关重要。次高优先级: nn (逻辑代码块) – 保持大的代码块完整。中优先级: n (代码行) – 如果函数或类过长,则按行切割。低优先级: ` (标识符/关键词) - 保持代码元素的完整性。<br>**最低优先级:**""(字符)。<br>*注意:具体语言需要调整分隔符,例如JavaScript可能用function,Java可能用class和public static`等。* |
| JSON/YAML | ["n", ", ", "{", "}", "[", "]", " ", ""] |
高优先级: n (行) – JSON/YAML通常按行组织。中优先级: , (键值对分隔) – 保持键值对的完整性。低优先级: {, }, [, ] (结构分隔) – 保持对象和数组的边界。最低优先级: ` (空格) 和""` (字符)。目标是保持有效的JSON/YAML片段,但对于非常长的嵌套结构,可能需要更复杂的解析器。 |
| CSV/TSV | ["n", ","] (CSV) 或 ["n", "t"] (TSV) |
高优先级: n (行) – 保持每一行的完整性,即每一条记录。中优先级: , 或 t (列分隔) – 如果行过长,则按列切割,但这通常意味着一行数据本身就超过了chunk_size,可能需要更高级的处理,例如将某些列合并或摘要。 |
这个表格提供了一个起点,实际应用中可能需要根据特定文档的特点进行微调和实验。
五、高级场景与最佳实践
5.1 链式切割与专门化切割器
在某些复杂的场景下,单一的RecursiveCharacterTextSplitter可能不足以满足需求。这时,我们可以考虑链式切割或使用专门化的切割器。
例如,对于一份包含Markdown文本和嵌入式代码块的文档,我们可以:
- 首先使用一个专门的Markdown切割器(如LangChain中的
MarkdownTextSplitter,它在内部可能也使用了递归思想,但更专注于Markdown结构)将文档分解为Markdown块和代码块。 - 然后,对Markdown块使用
RecursiveCharacterTextSplitter(通用散文或Markdown分隔符)。 - 对代码块使用
PythonCodeTextSplitter或JSCodeTextSplitter等(针对代码的分隔符)。
LangChain提供了一系列开箱即用的专门化切割器,它们通常是RecursiveCharacterTextSplitter的变体,预设了针对特定格式的separators。
from langchain.text_splitter import MarkdownTextSplitter, PythonCodeTextSplitter
# 示例:MarkdownTextSplitter
md_splitter = MarkdownTextSplitter(chunk_size=500, chunk_overlap=50)
md_chunks = md_splitter.split_text(markdown_text) # 使用之前的 markdown_text
# 示例:PythonCodeTextSplitter
python_code = """
class MyClass:
def __init__(self, name):
self.name = name
def greet(self):
print(f"Hello, {self.name}")
def main():
obj = MyClass("World")
obj.greet()
if __name__ == "__main__":
main()
"""
py_splitter = PythonCodeTextSplitter(chunk_size=100, chunk_overlap=10)
py_chunks = py_splitter.split_text(python_code)
print("n--- MarkdownTextSplitter 结果 ---")
for i, chunk in enumerate(md_chunks):
print(f"Chunk {i+1} (长度: {len(chunk)}): '{chunk}'")
print("n--- PythonCodeTextSplitter 结果 ---")
for i, chunk in enumerate(py_chunks):
print(f"Chunk {i+1} (长度: {len(chunk)}): '{chunk}'")
这些专门化的切割器在内部往往已经预设了符合该文件类型的最佳分隔符策略,使得我们无需手动构建复杂的 separators 列表。
5.2 检索增强生成 (RAG) 中的应用
在RAG系统中,RecursiveCharacterTextSplitter是构建高质量检索索引的关键组件。良好的文本块能够:
- 提高检索精度: 语义完整的块更容易与用户查询匹配。
- 改善LLM生成质量: LLM接收到的上下文更连贯、更准确,从而生成更相关的回答。
一个常见的RAG策略是“父文档检索”(Parent Document Retrieval)或“小块检索,大块合成”(Small-to-Large Chunking):
- 创建小块用于检索: 使用较小的
chunk_size和适当的chunk_overlap来生成用于向量数据库索引的块。这些小块的目标是包含足够的信息来匹配查询,但又足够小,以便在检索时提高效率。 - 创建大块用于合成: 同时,保留原始文档的较大、更具上下文的块(或整个原始文档)。当小块被检索到时,我们可以用它来识别其对应的更大、更完整的父块。
- LLM上下文构建: 将检索到的小块作为触发器,然后将对应的大块(或父块)提供给LLM作为上下文进行生成。
这种策略兼顾了检索效率和LLM对完整上下文的需求。RecursiveCharacterTextSplitter非常适合生成这些不同粒度的块。
5.3 评估与迭代
没有“一劳永逸”的文本切割策略。最佳的 chunk_size、chunk_overlap 和 separators 组合取决于:
- 文档类型: 散文、代码、法律文本、技术手册等。
- 下游任务: 摘要、问答、信息提取、代码生成等。
- LLM的上下文窗口大小: 不同的模型有不同的限制。
- 性能要求: 检索速度、生成质量。
因此,实验和迭代是关键。
- 手动检查: 生成一些文本块,手动阅读它们,判断它们的语义完整性。
- 小规模测试: 在小数据集上运行RAG或LLM任务,评估不同切割策略对结果质量的影响。
- 指标评估: 如果有可用的评估指标(如RAG中的召回率、准确率,LLM的BLEU/ROUGE分数等),可以使用它们来量化不同策略的效果。
通过持续的调整和测试,我们可以找到最适合特定应用场景的文本切割方案。
六、与其他文本切割器的简要对比
CharacterTextSplitter:RecursiveCharacterTextSplitter实际上是CharacterTextSplitter的一个更智能的变体。CharacterTextSplitter也可以接受separator参数,但它会一次性用一个分隔符进行切割。如果结果块仍然过大,它不会“递归”地尝试用下一个分隔符。RecursiveCharacterTextSplitter的优势在于其迭代尝试不同分隔符的策略。TokenTextSplitter:TokenTextSplitter严格按照token数量来切割,通常不考虑语义边界,除非结合自定义的separators。它更关注精确控制token数量,而RecursiveCharacterTextSplitter则优先语义,同时使用length_function来约束token数量。在实际应用中,我们通常将RecursiveCharacterTextSplitter与一个基于token的length_function结合使用,以兼顾语义和token限制。- 基于句子的分割器 (如NLTK/SpaCy的Sentence Splitter): 这些工具擅长将文本分割成句子。它们在句子级别提供了非常好的语义完整性,但它们不处理更高级别的结构(如段落、章节),也不直接支持按固定长度分块的需求。它们可以作为
RecursiveCharacterTextSplitter的预处理步骤,或者在separators中包含句号等标点符号。
七、总结
RecursiveCharacterTextSplitter是处理大型文本以适应LLM上下文窗口限制的强大而灵活的工具。它通过优先在自然的语义边界(如段落、行、词语)进行切割,并迭代地尝试不同粒度的分隔符,有效地解决了简单长度切割破坏语义完整性的问题。通过精心选择chunk_size、chunk_overlap和定制separators,并结合基于token的length_function,我们可以极大地提升LLM应用(特别是RAG系统)的性能和可靠性。理解其工作原理和最佳实践,是构建高效、智能LLM应用的关键一步。