1.概述
本文介绍dify中文档切割的几种方式的详细实现,并对这几种方式的实现和关系进行了分析和比较。文本分割是,RAG框架实现中比较关键的一环,分割质量的好坏直接影响RAG回答的效果。dify提供了多种分割方式,可以根据不同的场景来进行适配。
2.函数调用关系
函数调用关系如下:
3.递归字符文本分割:RecursiveCharacterTextSplitter
3.1. _split_text函数
该函数的功能是:递归地按照分隔符列表对文本进行分割,确保分割后的文本块大小符合要求。
该函数的实现逻辑如下:
- 遍历分隔符列表,若分隔符列表为空,分隔符遍历被设置为"",也就是空分隔符。若分隔符列表不为空,则找到第一个分隔符,并使用第一个分隔符在文本中查找,若找到把第一个分隔符作为本次的分隔符,并把分隔符列表中剩余的分隔符保存到new_separators这个变量中。
- 使用在第一步选定的分隔符对文本进行分割,并根据是否要保留分隔符(self._keep_separator变量的值)来对分割后的文本块进行处理。最后得到分割好的文本块列表。
- 遍历分割好的文本块列表,并对每个文本块执行以下操作:
a. 若当前分块的长度小于self._chunk_size
,则把当前分块作为好的分块保存到:_good_splits
列表中。
b. 若当前分块的长度大于self._chunk_size
,则处理_good_splits
中的分块,对列表中较小的分块进行合并,合并的操作是在_merge_splits
函数中完成。合并完成后的分块大小就是比较合适的分块了。
c. 若当前分块的长度大于self._chunk_size
,则递归调用_split_text
函数继续处理该分块。
- 遍历完成后,若
_good_splits
列表中不为空,则继续对列表中较小的分块进行合并的操作。也就是3.b这个步骤的操作,也就是调用_merge_splits
函数来完成合并。 - 最后返回
3.2 什么样的分块需要合并
通过以上分析可知,较小的分块太小了,就会变得很多,此时不太容易维护。所以,需要对较小的分块进行合并,合并的操作是在_merge_splits
函数中完成的。我们来看一下_merge_splits
函数的实现逻辑:
-
获取分隔符长度
-
遍历
splits
中的每个文本分块,然后进行以下处理:1)获取当前小文本块的长度
_len
。2)检查将当前小文本块添加到
current_doc
后是否会超过self._chunk_size
。如果超过,检查total
是否已经超过self._chunk_size
,如果是则记录警告信息。 a. 调用
self._join_docs
方法将current_doc
合并成一个文本块,并添加到docs
列表中。 b. 通过循环移除
current_doc
中的前面的小文本块,直到满足重叠self._chunk_overlap
的要求。 -
将当前小文本块添加到
current_doc
中,并更新total
和index
。 -
处理最后一个文档
1)调用
self._join_docs
方法将最后一个current_doc
合并成一个文本块。2)如果合并后的文本块不为空,则添加到
docs
列表中。 -
返回最终的合并后的文本块列表
docs
。
def _merge_splits(self, splits: Iterable[str], separator: str, lengths: list[int]) -> list[str]:
# We now want to combine these smaller pieces into medium size
# chunks to send to the LLM.
# 获取分隔符长度
separator_len = self._length_function(separator)
docs = []
current_doc: list[str] = []
total = 0
index = 0
for d in splits:
# 获取当前分片的长度
_len = lengths[index]
# 检查是否超过块大小限制
if total + _len + (separator_len if len(current_doc) > 0 else 0) > self._chunk_size:
# 处理超过大小限制的情况
if total > self._chunk_size:
logger.warning(
f"Created a chunk of size {total}, which is longer than the specified {self._chunk_size}"
)
# 处理当前累积的文档
if len(current_doc) > 0:
doc = self._join_docs(current_doc, separator)
if doc is not None:
docs.append(doc)
# Keep on popping if:
# - we have a larger chunk than in the chunk overlap
# - or if we still have any chunks and the length is long
# 处理重叠部分:当有一个巨大的check比chunk的重叠部分还大,或则
# 任然还有一些chunk的长度太长
while total > self._chunk_overlap or (
total + _len + (separator_len if len(current_doc) > 0 else 0) > self._chunk_size and total > 0
):
# 移除前面的分片直到满足重叠要求
total -= self._length_function(current_doc[0]) + (separator_len if len(current_doc) > 1 else 0)
current_doc = current_doc[1:]
# 在遍历结束后,确保所有剩余的文档都被正确地合并并添加到结果列表中。
current_doc.append(d)
total += _len + (separator_len if len(current_doc) > 1 else 0)
index += 1
# 处理最后一个文档
doc = self._join_docs(current_doc, separator)
if doc is not None:
docs.append(doc)
return docs
4.CharacterTextSplitter分割
该类提供了文本分割的函数,该函数和其他类不同的是:它会对较小的分块进行合并,但不对长的分块再继续细分。该函数的代码如下:
def split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
# First we naively split the large input into a bunch of smaller ones.
# 1. 使用正则表达式初步分割文本
splits = _split_text_with_regex(text, self._separator, self._keep_separator)
# 确定实际使用的分隔符
_separator = "" if self._keep_separator else self._separator
_good_splits_lengths = [] # cache the lengths of the splits
# 缓存分割后的文本长度
for split in splits:
_good_splits_lengths.append(self._length_function(split))
# 合并分割结果
return self._merge_splits(splits, _separator, _good_splits_lengths)
5.FixedRecursiveCharacterTextSplitter固定递归分割
该类的分块实现和前面的实现差不多,只是在选择分隔符上略有不同。该类开始使用的是固定分割符。
def split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
# 使用固定分隔符切割文本
if self._fixed_separator:
chunks = text.split(self._fixed_separator)
else:
chunks = [text]
final_chunks = []
# 遍历所有分块,若分块长度大于设定的长度`_chunk_size`,则递归切割
for chunk in chunks:
if self._length_function(chunk) > self._chunk_size:
final_chunks.extend(self.recursive_split_text(chunk))
else:
final_chunks.append(chunk)
return final_chunks
recursive_split_text函数
该函数的实现逻辑如下:
1.确定合适的分割符
(1) 默认选择最后一个分隔符,作为当前分割符,保存到变量separator
中。
(2) 遍历所有分隔符,若当前分隔符在文本中出现过,则跳过后面的分隔符,选择该分隔符保存到变量separator
中,跳出遍历循环。
2.使用选择的分割符切割文本
(1) 如果 separator
不为空,则使用该分隔符将输入的文本 text
分割成多个块,并将结果存储在 splits
列表中。
(2) 若 separator
为空,则将输入的文本 text
转换为字符列表,并将结果存储在 splits
列表中。
3.遍历所有切割后的分块,处理这些分块
(1) 使用 self._length_function
方法计算当前分块 的长度。
(2) 若当前分块长度小于设定的长度_chunk_size
,则将其加入_good_splits中。表示这是一个好的分块。
(3) 如果当前分块长度大于或等于 _chunk_size
,
a. 处理存量的_good_splits
小分块的长度,对这些小分块进行判断是否需要合并,若需要合并则进行合并。
b. 递归调用自己,对该分块进行递归切割
4.处理最后一部分小的分块(也就是最后一部分_good_splits的内容):对小的分块进行合并判断,对需要合并的分块进行合并。
def recursive_split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
final_chunks = []
# Get appropriate separator to use
# 默认选择最后一个分隔符
separator = self._separators[-1]
# 遍历所有分隔符,若当前分隔符在文本中出现过,则跳过后面的分隔符,选择该分隔符。
for _s in self._separators:
if _s == "":
separator = _s
break
if _s in text:
separator = _s
break
# Now that we have the separator, split the text
# 使用选择的分隔符切割文本
if separator:
splits = text.split(separator)
else:
splits = list(text)
# Now go merging things, recursively splitting longer texts.
# 递归切割长文本
_good_splits = []
_good_splits_lengths = [] # cache the lengths of the splits
# 遍历所有切割后的分块
for s in splits:
s_len = self._length_function(s)
# 若当前分块长度小于设定的长度`_chunk_size`,则将其加入_good_splits中
if s_len < self._chunk_size:
_good_splits.append(s)
_good_splits_lengths.append(s_len)
else:
# 处理现有_good_splits中的小分块,对其中太小的分块进行合并(_merge_splits)
if _good_splits:
merged_text = self._merge_splits(_good_splits, separator, _good_splits_lengths)
final_chunks.extend(merged_text)
_good_splits = []
_good_splits_lengths = []
# 若分片长度大于设定的长度`_chunk_size`,则递归切割
other_info = self.recursive_split_text(s)
final_chunks.extend(other_info)
# 处理_good_splits中最后一部分分块,对其中太小的分块进行合并
if _good_splits:
merged_text = self._merge_splits(_good_splits, separator, _good_splits_lengths)
final_chunks.extend(merged_text)
return final_chunks
EnhanceRecursiveCharacterTextSplitter:增强递归字符分割
在该类中,其他的逻辑和父类RecursiveCharacterTextSplitter的相同。只不过,计算分片长度的函数的计算方式不同,该类中会通过嵌入模型先把文本分块转换成token,然后计算token的长度作为分块的长度。
def _token_encoder(text: str) -> int:
if not text:
return 0
if embedding_model_instance:
return embedding_model_instance.get_text_embedding_num_tokens(texts=[text])
else:
return GPT2Tokenizer.get_num_tokens(text)
在构建类实体时,把该函数作为计算分块长度的函数:
return cls(length_function=_token_encoder, **kwargs)
几种文本分割方式的区别
RecursiveCharacterTextSplitter
这种方式是一种基础的文本分割方式,该方式直接使用递归策略,从分隔符列表中依次尝试,并对每个文本块都进行分隔符检查的选择。
这种方式的特点是:通过递归对文本进行分割,处理开销较大,是一种基础的文本分割方式。
EnhanceRecursiveCharacterTextSplitter
这种方式继承了基础分割器的功能,只是增加了自定义token计数支持,提供模型特定的分词功能,并支持GPT2分词器作为后备处理器。
FixedRecursiveCharacterTextSplitter
这种方式把分割过程分成2个阶段:首先使用固定分隔符进行快速分割,然后分割过程中仅对超长块再进行递归处理。这种方式性能较优。
主要区别
特性 | 基础分割器 | 增强分割器 | 固定分割器 |
---|---|---|---|
分割策略 | 纯递归 | 纯递归+自定义计数 | 固定+递归 |
token计算 | 基础 | 模型特定 | 模型特定 |
性能 | 较低 | 中等 | 较高 |
适用场景 | 通用文本 | 特定模型文本 | 结构化文本 |
内存占用 | 高 | 中 | 低 |
总结
本文介绍了dify中文本分割的几种方式。并对这几种方式的实现进行了分析和比较。文本分割是,RAG框架实现中比较关键的一环,分割质量的好坏直接影响RAG回答的效果。dify中考虑到了文本的长度和语义的连贯性,会对小的分块进行合并,而且又会保证了文本有一定的重叠,保证了语义的连贯性。
另外,在其他的RAG框架中也可以使用tiktoken包来对文本块进行token化,然后再对token进行分割。这种方式相对比较简介,在后续文章中会继续分析这种实现方式的原理。