上一章讨论了文档标准化加载,现在转向文档的细分,这虽简单却对后续工作有重大影响。
一、为什么要进行文档分割
- 模型大小和内存限制:大型GPT模型参数众多,需大量计算和内存,而硬件设备如GPU或TPU有内存限制,文档分割有助于在这些限制内工作。
- 计算效率:长文本序列需更多资源,分块可提高计算效率。
- 序列长度限制:GPT模型有最大序列长度限制(如2048个token),超长文档需分割。
- 更好的泛化:多块训练增强模型对不同文本样式和结构的泛化。
- 数据增强:分割可增加训练样本,如将长文档分成多个独立样本。
注意事项:分割可能导致上下文信息丢失,特别是在分割点附近,需权衡分割方法。
图 4.3.1 文档分割的意义
单一字符分割易失语义,应分至完整语义段落或单元以保准确性。
二、文档分割方式
Langchain 中文本分割器都根据 chunk_size (块大小)和 chunk_overlap (块与块之间的重叠大小)进行分割。
图 4.3.2 文档分割示例
- chunk_size 指每个块包含的字符或 Token (如单词、句子等)的数量
- chunk_overlap 指两个块之间共享的字符数量,用于保持上下文的连贯性,避免分割丢失上下文信息
图 4.3.3 文档分割工具
Langchain提供多种文档分割方式,区别在怎么确定块与块之间的边界、块由哪些字符/token组成、以及如何测量块大小
三、基于字符分割
文本分割方法与任务类型紧密相关,尤其在拆分代码时。我们引入了语言文本分割器,含多种编程语言分隔符,需考虑不同语言差异
我们将从基于字符的分割开始探索,借助 LangChain 提供的 RecursiveCharacterTextSplitter 和 CharacterTextSplitter 工具来实现此目标。
CharacterTextSplitter 是字符文本分割,分隔符的参数是单个的字符串;RecursiveCharacterTextSplitter 是递归字符文本分割,将按不同的字符递归地分割(按照这个优先级[“\n\n”, “\n”, " ", “”]),这样就能尽量把所有和语义相关的内容尽可能长时间地保留在同一位置。因此,RecursiveCharacterTextSplitter 比 CharacterTextSplitter 对文档切割得更加碎片化
RecursiveCharacterTextSplitter 需要关注的是如下4个参数:
- separators - 分隔符字符串数组
- chunk_size - 每个文档的字符数量限制
- chunk_overlap - 两份文档重叠区域的长度
- length_function - 长度计算函数
3.1 短句分割
# 导入文本分割器
from langchain.text_splitter import RecursiveCharacterTextSplitter, CharacterTextSplitter
chunk_size = 20 #设置块大小
chunk_overlap = 10 #设置块重叠大小
# 初始化递归字符文本分割器
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
# 初始化字符文本分割器
c_splitter = CharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
接下来我们对比展示两个字符文本分割器的效果。
text = "在AI的研究中,由于大模型规模非常大,模型参数很多,在大模型上跑完来验证参数好不好训练时间成本很高,所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。" #测试文本
r_splitter.split_text(text)
['在AI的研究中,由于大模型规模非常大,模',
'大模型规模非常大,模型参数很多,在大模型',
'型参数很多,在大模型上跑完来验证参数好不',
'上跑完来验证参数好不好训练时间成本很高,',
'好训练时间成本很高,所以一般会在小模型上',
'所以一般会在小模型上做消融实验来验证哪些',
'做消融实验来验证哪些改进是有效的再去大模',
'改进是有效的再去大模型上做实验。']
可以看到,分割结果中,第二块是从“大模型规模非常大,模”开始的,刚好是我们设定的块重叠大小
#字符文本分割器
c_splitter.split_text(text)
[‘在AI的研究中,由于大模型规模非常大,模型参数很多,在大模型上跑完来验证参数好不好训练时间成本很高,所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。’]
可以看到字符分割器没有分割这个文本,因为字符文本分割器默认以换行符为分隔符,因此需要设置“,”为分隔符。
# 设置空格分隔符
c_splitter = CharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separator=','
)
c_splitter.split_text(text)
Created a chunk of size 23, which is longer than the specified 20
['在AI的研究中,由于大模型规模非常大',
'由于大模型规模非常大,模型参数很多',
'在大模型上跑完来验证参数好不好训练时间成本很高',
'所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。']
设置“,”为分隔符后,分割效果与递归字符文本分割器类似。
可以看到出现了提示"Created a chunk of size 23, which is longer than the specified 20",意思是“创建了一个长度为23的块,这比指定的20要长。”。这是因为CharacterTextSplitter优先使用我们自定义的分隔符进行分割,所以在长度上会有较小的差距
3.2 长文本分割
接尝试对长文本进行分割。
# 中文版
some_text = """在编写文档时,作者将使用文档结构对内容进行分组。 \
这可以向读者传达哪些想法是相关的。 例如,密切相关的想法\
是在句子中。 类似的想法在段落中。 段落构成文档。 \n\n\
段落通常用一个或两个回车符分隔。 \
回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 \
句子末尾有一个句号,但也有一个空格。\
并且单词之间用空格分隔"""
print(len(some_text))
177
我们使用以上长文本作为示例。
c_splitter = CharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separator=' '
)
'''
对于递归字符分割器,依次传入分隔符列表,分别是双换行符、单换行符、空格、空字符,
因此在分割文本时,首先会采用双分换行符进行分割,同时依次使用其他分隔符进行分割
'''
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separators=["\n\n", "\n", " ", ""]
)
字符分割器结果:
c_splitter.split_text(some_text)
['在编写文档时,作者将使用文档结构对内容进行分组。 这可以向读者传达哪些想法是相关的。 例如,密切相关的想法 是在句子中。 类似的想法在段落中。 段落构成文档。',
'段落通常用一个或两个回车符分隔。 回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 句子末尾有一个句号,但也有一个空格。 并且单词之间用空格分隔']
递归字符分割器效果:
r_splitter.split_text(some_text)
['在编写文档时,作者将使用文档结构对内容进行分组。 这可以向读者传达哪些想法是相关的。 例如,密切相关的想法 是在句子中。 类似的想法在段落中。',
'段落构成文档。',
'段落通常用一个或两个回车符分隔。 回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 句子末尾有一个句号,但也有一个空格。',
'并且单词之间用空格分隔']
如果需要按照句子进行分隔,则还要用正则表达式添加一个句号分隔符
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=30,
chunk_overlap=0,
separators=["\n\n", "\n", "(?<=\。 )", " ", ""]
)
r_splitter.split_text(some_text)
['在编写文档时,作者将使用文档结构对内容进行分组。',
'这可以向读者传达哪些想法是相关的。',
'例如,密切相关的想法 是在句子中。',
'类似的想法在段落中。 段落构成文档。',
'段落通常用一个或两个回车符分隔。',
'回车符是您在该字符串中看到的嵌入的“反斜杠 n”。',
'句子末尾有一个句号,但也有一个空格。',
'并且单词之间用空格分隔']
这就是递归字符文本分割器名字中“递归”的含义,总的来说,我们更建议在通用文本中使用递归字符文本分割器
四、基于 Token 分割
很多 LLM 的上下文窗口长度限制是按照 Token 来计数的。因此,以 LLM 的视角,按照 Token 对文本进行分隔,通常可以得到更好的结果。 通过一个实例理解基于字符分割和基于 Token 分割的区别
# 使用token分割器进行分割,
# 将块大小设为1,块重叠大小设为0,相当于将任意字符串分割成了单个Token组成的列
from langchain.text_splitter import TokenTextSplitter
text_splitter = TokenTextSplitter(chunk_size=1, chunk_overlap=0)
text = "foo bar bazzyfoo"
text_splitter.split_text(text)
# 注:目前 LangChain 基于 Token 的分割器还不支持中文
[‘foo’, ’ bar’, ’ b’, ‘az’, ‘zy’, ‘foo’]
可以看出token长度和字符长度不一样,token通常为4个字符
五、分割Markdown文档
5.1 分割一个自定义 Markdown 文档
分块旨在聚相关文本,可使用分隔符或利用文档结构(如Markdown的标题)。Markdown标题分割器按标题分块,并将标题作元数据。
# 定义一个Markdown文档
from langchain.document_loaders import NotionDirectoryLoader#Notion加载器
from langchain.text_splitter import MarkdownHeaderTextSplitter#markdown分割器
markdown_document = """# Title\n\n \
## 第一章\n\n \
李白乘舟将欲行\n\n 忽然岸上踏歌声\n\n \
### Section \n\n \
桃花潭水深千尺 \n\n
## 第二章\n\n \
不及汪伦送我情"""
我们以上述文本作为 Markdown 文档的示例,上述文本格式遵循了 Markdown 语法,如读者对该语法不了解,可以简单查阅该教程 :Markdown 教程
# 定义想要分割的标题列表和名称
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)#message_typemessage_type
md_header_splits = markdown_splitter.split_text(markdown_document)
print("第一个块")
print(md_header_splits[0])
print("第二个块")
print(md_header_splits[1])
第一个块
page_content='李白乘舟将欲行 \n忽然岸上踏歌声' metadata={'Header 1': 'Title', 'Header 2': '第一章'}
第二个块
page_content='桃花潭水深千尺' metadata={'Header 1': 'Title', 'Header 2': '第一章', 'Header 3': 'Section'}
可以看到,每个块都包含了页面内容和元数据,元数据中记录了该块所属的标题和子标题。
5.2 分割数据库中的 Markdown 文档
在上一章中,我们尝试了 Notion 数据库的加载,Notion 文档就是一个 Markdown 文档。我们在此处加载 Notion 数据库中的文档并进行分割。
#加载数据库的内容
loader = NotionDirectoryLoader("docs/Notion_DB")
docs = loader.load()
txt = ' '.join([d.page_content for d in docs])#拼接文档
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
]
#加载文档分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
md_header_splits = markdown_splitter.split_text(txt)#分割文本内容
print(md_header_splits[0])#分割结果
page_content='Let’s talk about stress. Too much stress. \nW