在上一章中,我们刚刚讨论了如何将文档加载到标准格式中,现在我们要谈论如何将它们分割成较小的块。这听起来可能很简单,但其中有很多微妙之处会对后续工作产生重要影响。
文章目录
- 1、为什么要做文档分割?
- 2、文档分割方式
- 3、基于字符分割:RecursiveCharacterTextSplitter 与 CharacterTextSplitter
- 3.1、短句分割
- 3.1、长句分割
- 4、基于Token分割
- 5、分割Markdown文档
- 5.1、分割一个自定义 Markdown 文档
- 5.2、分割数据库中的 Markdown 文档
- Reference
1、为什么要做文档分割?
优点:
- 1、模型大小和内存限制。
- 2、计算效率。
- 3、序列长度限制。
- 4、更好的泛化:通过在多个文档块上进行训练,模型可以更好地学习和泛化到各种不同的文本样式和结构。
- 5、数据增强:分割文档可以为训练数据提供更多的样本。例如,一个长文档可以被分割成多个部分,并分别作为单独的训练样本。
缺点:
- 可能导致一些上下文信息的丢失,尤其是在分割点附近。因此,如何进行文档分割是一个需要权衡的问题。
因此,为了确保语义的准确性,我们应该尽量将文本分割为包含完整语义的段落或单元。
2、文档分割方式
Langchain 中文本分割器都根据 chunk_size (块大小)和 chunk_overlap (块与块之间的重叠大小)进行分割:
- chunk_size 指每个块包含的字符或 Token (如单词、句子等)的数量
- chunk_overlap 指两个块之间共享的字符数量,用于保持上下文的连贯性,避免分割丢失上下文信息
Langchain提供了很多文本切割的工具,区别在怎么确定块与块之间的边界、块由哪些字符/token组成、以及如何测量块大小。其中langchain默认使用RecursiveCharacterTextSplitter:
- 1、CharacterTextSplitter():按字符来分割文本。
- 2、MarkdownHeaderTextSplitter():基于指定的标题来分割markdown 文件。
- 3、TokenTextSplitter():按token来分割文本。
- 4、SentenceTransformersTokenTextSplitter() : 按token来分割文本
- 5、RecursiveCharacterTextSplitter():按字符串分割文本,递归地尝试按不同的分隔符进行分割文本。
- 6、Language() - 用于 CPP、Python、Ruby、Markdown 等。
- 7、NLTKTextSplitter():使用 NLTK(自然语言工具包)按句子分割文本。
- 8、SpacyTextSplitter() - 使用 Spacy按句子的切割文本。
3、基于字符分割:RecursiveCharacterTextSplitter 与 CharacterTextSplitter
如何进行文本分割,往往与我们的任务类型息息相关。当我们拆分代码时,这种相关性变得尤为突出。因此,我们引入了一个语言文本分割器,其中包含各种为 Python、Ruby、C 等不同编程语言设计的分隔符。在对这些文档进行分割时,必须充分考虑各种编程语言之间的差异。
我们将从基于字符的分割开始探索,借助 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的研究中,由于大模型规模非常大,模型参数很多,在大模型上跑完来验证参数好不好训练时间成本很高,所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。']
设置空格分隔符。可以看到出现了提示"Created a chunk of size 23, which is longer than the specified 20",意思是“创建了一个长度为23的块,这比指定的20要长。”。
# 是因为CharacterTextSplitter优先使用我们自定义的分隔符进行分割,所以在长度上会有较小的差距
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的研究中,由于大模型规模非常大',
'由于大模型规模非常大,模型参数很多',
'在大模型上跑完来验证参数好不好训练时间成本很高',
'所以一般会在小模型上做消融实验来验证哪些改进是有效的再去大模型上做实验。']
3.1、长句分割
some_text = """在编写文档时,作者将使用文档结构对内容进行分组。 \
这可以向读者传达哪些想法是相关的。 例如,密切相关的想法\
是在句子中。 类似的想法在段落中。 段落构成文档。 \n\n\
段落通常用一个或两个回车符分隔。 \
回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 \
句子末尾有一个句号,但也有一个空格。\
并且单词之间用空格分隔"""
print(len(some_text)) # 177
# CharacterTextSplitter默认的字分割符是双换行符即\n\n
c_splitter = CharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separator=' '
)
c_splitter.split_text(some_text)
['在编写文档时,作者将使用文档结构对内容进行分组。 这可以向读者传达哪些想法是相关的。 例如,密切相关的想法 是在句子中。 类似的想法在段落中。 段落构成文档。',
'段落通常用一个或两个回车符分隔。 回车符是您在该字符串中看到的嵌入的“反斜杠 n”。 句子末尾有一个句号,但也有一个空格。 并且单词之间用空格分隔']
对于递归字符分割器,依次传入分隔符列表,分别是双换行符、单换行符、空格、空字符,
因此在分割文本时,首先会采用双换行符进行分割,同时依次使用其他分隔符进行分割(谁放列表前,谁优先级就大)。
意思就是先通过\n\n分割,然后在分割出来的每一段里继续用 [“\n”, " ", “”]分割,但每次分割要尽可能满足chunk_size和chunk_overlap
'''
# 默认字分割符是一个列表即["\n\n", "\n", " ", ""]
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=80,
chunk_overlap=0,
separators=["\n\n", "\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”。',
'句子末尾有一个句号,但也有一个空格。',
'并且单词之间用空格分隔']
4、基于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)
# 可以看出token长度和字符长度不一样,token通常为4个字符
# 注:目前 LangChain 基于 Token 的分割器还不支持中文
['foo', ' bar', ' b', 'az', 'zy', 'foo']
5、分割Markdown文档
5.1、分割一个自定义 Markdown 文档
分块的目的是把具有上下文的文本放在一起,我们可以通过使用指定分隔符来进行分隔,但有些类型的文档(例如 Markdown )本身就具有可用于分割的结构(如标题)。
Markdown 标题文本分割器会根据标题或子标题来分割一个 Markdown 文档,并将标题作为元数据添加到每个块中。
# 定义一个Markdown文档
markdown_document = """# Title\n\n \
## 第一章\n\n \
李白乘舟将欲行\n\n 忽然岸上踏歌声\n\n \
### Section \n\n \
桃花潭水深千尺 \n\n
## 第二章\n\n \
不及汪伦送我情"""
print(markdown_document)
# Title
## 第一章
李白乘舟将欲行
忽然岸上踏歌声
### Section
桃花潭水深千尺
## 第二章
不及汪伦送我情
from langchain.text_splitter import MarkdownHeaderTextSplitter#markdown分割器
# 定义想要分割的标题列表和名称
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)#message_type
md_header_splits = markdown_splitter.split_text(markdown_document)
print(len(md_header_splits),md_header_splits)
print(md_header_splits[0])
print(md_header_splits[1])
print(md_header_splits[2])
3
[Document(page_content='李白乘舟将欲行 \n忽然岸上踏歌声', metadata={'Header 1': 'Title', 'Header 2': '第一章'}), Document(page_content='桃花潭水深千尺', metadata={'Header 1': 'Title', 'Header 2': '第一章', 'Header 3': 'Section'}), Document(page_content='不及汪伦送我情', metadata={'Header 1': 'Title', 'Header 2': '第二章'})]
page_content='李白乘舟将欲行 \n忽然岸上踏歌声' metadata={'Header 1': 'Title', 'Header 2': '第一章'}
page_content='桃花潭水深千尺' metadata={'Header 1': 'Title', 'Header 2': '第一章', 'Header 3': 'Section'}
page_content='不及汪伦送我情' metadata={'Header 1': 'Title', 'Header 2': '第二章'}
5.2、分割数据库中的 Markdown 文档
在上一章中,我们尝试了 Notion 数据库的加载,Notion 文档就是一个 Markdown 文档。我们在此处加载 Notion 数据库中的文档并进行分割。
from langchain.document_loaders import NotionDirectoryLoader#Notion加载器
loader = NotionDirectoryLoader("./data/Notion_DB")
docs = loader.load()
txt = ' '.join([d.page_content for d in docs])# 如果Notion_DB下有多个md文件,那就拼一起
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(len(md_header_splits), md_header_splits[0])#分割结果
8
page_content="This is a living document with everything we've learned working with people while running a startup. And, of course, we continue to learn. ...."
metadata={'Header 1': "Blendle's Employee Handbook (1)"}
Reference
- [1] 吴恩达老师的教程
- [2] DataWhale组织