1. DocumentTransformer 组件
在 LangChain 中,使用 文档加载器 加载得到的文档一般来说存在着几个问题:原始文档太大、原始文档的数据格式不符合需求(需要英文但是只有中文)、原始文档的信息没有经过提炼等问题。
背景:如果将这类数据直接转换成向量并存储到数据库中,会导致在执行相似性搜索和 RAG 的过程中,错误率大大提升。 所以在 LLM 应用开发中,在加载完数据后,一般会执行多一步 转换 的过程,即将加载得到的 文档列表 进行转换,得到符合需求的 文档列表。
转换涵盖的操作就非常多,例如:文档切割、文档属性提取、文档翻译、HTML 转文本、重排、元数据标记等都属于转换。
在架构中添加上转换步骤,更新后的架构/运行流程 如下所示:
在 LangChain 中针对文档的转换也统一封装了一个基类 BaseDocumentTransformer
,所有涉及到文档的转换的类均是该类的子类,将大块文档切割成 chunk 分块的文档分割器也是 BaseDocumentTransformer 的子类实现。
BaseDocumentTransformer
基类封装了两个方法:
- transform_documents():抽象方法,传递文档列表,返回转换后的文档列表。
- atransform_documents():转换文档列表函数的异步实现,如果没有实现,则会委托 transform_documents() 函数实现。
在 LangChain 中,文档转换组件分成了两类:文档分割器(使用频率高)
、文档处理转换器(使用频率低,老版本写法)
。
并且目前 LangChain 团队已经将 文档分割器 这个高频使用的部分单独拆分成一个 Python 包,哪怕不使用 LangChain 框架本身进行开发,也可以使用其文本分割包,快速分割数据,在使用前必须执行以下命令安装:
pip install -qU langchain-text-splitters
对于文本分割器来说,除了继承 BaseDocumentTransformer
,还单独设置了文本分割器基类 TextSplitter
,从而去实现更加丰富的功能,BaseDocumentTransformer 衍生出来的类图
2. 字符分割器基础使用技巧
在文档分割器中,最简单的分割器就是——字符串分割器,这个组件会基于给定的字符串进行分割,默认为 \n\n,并且在分割时会尽可能保证数据的连续性。分割出来每一块的长度是通过字符数来衡量的,使用起来也非常简单,实例化 CharacterTextSplitter 需传递多个参数,信息如下:
separator
:分隔符,默认为 \n\n。is_separator_regex
:是否正则表达式,默认为 False。chunk_size
:每块文档的内容大小,默认为 4000。chunk_overlap
:块与块之间重叠的内容大小,默认为 200。length_function
:计算文本长度的函数,默认为 len。keep_separator
:是否将分隔符保留到分割的块中,默认为 False。add_start_index
:是否添加开始索引,默认为 False,如果是的话会在元数据中添加该切块的起点。strip_whitespace
:是否删除文档头尾的空白,默认为 True。
如果想将文档切割为不超过 500 字符,并且每块之间文本重叠 50 个字符,可以使用 CharacterTextSplitter 来实现,代码如下:
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter
# 1.构建Markdown文档加载器并获取文档列表
loader = UnstructuredMarkdownLoader("./项目API文档.md")
documents = loader.load()
# 2.构建分割器
text_splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=500,
chunk_overlap=50,
add_start_index=True,
)
# 3.分割文档列表
chunks = text_splitter.split_documents(documents)
# 4.输出信息
for chunk in chunks:
print(f"块内容大小:{len(chunk.page_content)},元数据:{chunk.metadata}")
输出内容:
Created a chunk of size 771, which is longer than the specified 500
Created a chunk of size 980, which is longer than the specified 500
Created a chunk of size 542, which is longer than the specified 500
Created a chunk of size 835, which is longer than the specified 500
块内容大小:251,元数据:{'source': './项目API文档.md', 'start_index': 0}
块内容大小:451,元数据:{'source': './项目API文档.md', 'start_index': 246}
块内容大小:771,元数据:{'source': './项目API文档.md', 'start_index': 699}
块内容大小:435,元数据:{'source': './项目API文档.md', 'start_index': 1472}
块内容大小:497,元数据:{'source': './项目API文档.md', 'start_index': 1859}
块内容大小:237,元数据:{'source': './项目API文档.md', 'start_index': 2359}
块内容大小:980,元数据:{'source': './项目API文档.md', 'start_index': 2598}
块内容大小:438,元数据:{'source': './项目API文档.md', 'start_index': 3580}
块内容大小:293,元数据:{'source': './项目API文档.md', 'start_index': 4013}
块内容大小:498,元数据:{'source': './项目API文档.md', 'start_index': 4261}
块内容大小:463,元数据:{'source': './项目API文档.md', 'start_index': 4712}
块内容大小:438,元数据:{'source': './项目API文档.md', 'start_index': 5129}
块内容大小:542,元数据:{'source': './项目API文档.md', 'start_index': 5569}
块内容大小:464,元数据:{'source': './项目API文档.md', 'start_index': 6113}
块内容大小:835,元数据:{'source': './项目API文档.md', 'start_index': 6579}
块内容大小:489,元数据:{'source': './项目API文档.md', 'start_index': 7416}
使用 CharacterTextSplitter 进行分割时,虽然传递了 chunk_size 为 500,但是仍然没法确保分割出来的文档一直保持在这个范围内,这是因为在底层 CharacterTextSplitter 是先按照分割符号拆分整个文档,然后循环遍历拆分得到的列表,将每个列表逐个相加,直到最接近 chunk_size 窗口大小时则完成一个 Document 的组装。
但是如果基于分割符号得到的文本,本身长度已经超过了 chunk_size,则会直接进行警告,并且将对应的文本单独变成一个块。
核心代码如下:
# langchain_text_splitters/character->CharacterTextSplitter::split_text
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.
separator = (
self._separator if self._is_separator_regex else re.escape(self._separator)
)
splits = _split_text_with_regex(text, separator, self._keep_separator)
_separator = "" if self._keep_separator else self._separator
return self._merge_splits(splits, _separator)
def _split_text_with_regex(
text: str, separator: str, keep_separator: bool
) -> List[str]:
# Now that we have the separator, split the text
if separator:
if keep_separator:
# The parentheses in the pattern keep the delimiters in the result.
_splits = re.split(f"({separator})", text)
splits = [_splits[i] + _splits[i + 1] for i in range(1, len(_splits), 2)]
if len(_splits) % 2 == 0:
splits += _splits[-1:]
splits = [_splits[0]] + splits
else:
splits = re.split(separator, text)
else:
splits = list(text)
return [s for s in splits if s != ""]
3.递归字符文本分割器
普通的字符文本分割器只能使用单个分隔符对文本内容进行划分,在划分的过程中,可能会出现文档块 过小 或者 过大 的情况,这会让 RAG 变得不可控,例如:
- 文档块可能会变得非常大,极端的情况下某个块的内容长度可能就超过了 LLM 的上下文长度限制,这样这个文本块永远不会被引用到,相当于存储了数据,但是数据又丢失了。
- 文档块可能会远远小于窗口大小,导致文档块的信息密度太低,块内容即使填充到 Prompt 中,LLM 也无法提取出有用的信息。
那么有没有一种分割方案,可以解决这个问题呢?按照 分隔符 初次分割的时候,去检测块内容,如果太大就按照提供的 备选分隔符 二次分割,如果太小则合并前后的块,最后让所有的块内容长度都控制在指定的大小并尽可能接近呢?
在 LangChain 中就为这种方案提供了一个分割器组件—— RecursiveCharacterTextSplitter,即递归字符串分割,这个分割器可以传递 一组分隔符 和 设定块内容大小,根据分隔符的优先顺序对文本进行预分割,然后将小块进行合并,将大块进行递归分割,直到获得所需块的大小,最终这些文档块的大小并不能完全相同,但是仍然会逼近指定长度。
RecursiveCharacterTextSplitter 的分隔符参数默认为 [“\n\n”, “\n”, " ", “”],即优先使用换两行的数据进行分割,然后在使用单个换行符,如果块内容还是太大,则使用空格,最后再拆分成单个字符。
所以如果使用默认参数,这个字符文本分割器最后得到的文档块长度一定不会超过预设的大小,但是仍然会有小概率出现远小于的情况(目前也没有很好的解决方案)。
例如使用递归字符文本分割器修改需求,示例代码如下:
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
loader = UnstructuredMarkdownLoader("./项目API文档.md")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
add_start_index=True,
)
chunks = text_splitter.split_documents(documents)
for chunk in chunks:
print(f"块大小:{len(chunk.page_content)}, 块元数据:{chunk.metadata}")
输出内容:
块大小:251, 块元数据:{'source': './项目API文档.md', 'start_index': 0}
块大小:451, 块元数据:{'source': './项目API文档.md', 'start_index': 246}
块大小:490, 块元数据:{'source': './项目API文档.md', 'start_index': 699}
块大小:305, 块元数据:{'source': './项目API文档.md', 'start_index': 1165}
块大小:435, 块元数据:{'source': './项目API文档.md', 'start_index': 1472}
块大小:497, 块元数据:{'source': './项目API文档.md', 'start_index': 1859}
块大小:237, 块元数据:{'source': './项目API文档.md', 'start_index': 2359}
块大小:483, 块元数据:{'source': './项目API文档.md', 'start_index': 2598}
块大小:486, 块元数据:{'source': './项目API文档.md', 'start_index': 3092}
块大小:438, 块元数据:{'source': './项目API文档.md', 'start_index': 3580}
块大小:293, 块元数据:{'source': './项目API文档.md', 'start_index': 4013}
块大小:498, 块元数据:{'source': './项目API文档.md', 'start_index': 4261}
块大小:463, 块元数据:{'source': './项目API文档.md', 'start_index': 4712}
块大小:438, 块元数据:{'source': './项目API文档.md', 'start_index': 5129}
块大小:474, 块元数据:{'source': './项目API文档.md', 'start_index': 5569}
块大小:93, 块元数据:{'source': './项目API文档.md', 'start_index': 6018}
块大小:464, 块元数据:{'source': './项目API文档.md', 'start_index': 6113}
块大小:478, 块元数据:{'source': './项目API文档.md', 'start_index': 6579}
块大小:379, 块元数据:{'source': './项目API文档.md', 'start_index': 7035}
块大小:489, 块元数据:{'source': './项目API文档.md', 'start_index': 7416}
RecursiveCharacterTextSplitter
底层的运行流程其实也非常简单,可以拆分成 预分割、大文档块递归分割、小文档块合并。 运行流程图如下:
(需掌握,目前 LLM 应用开发中高频提问到的一个问题)
对比普通的字符文本分割器, 递归字符文本分割器可以传递多个分隔符,并且根据不同分隔符的优先级来执行相应的分割。在 LangChain 中通过RecursiveCharacterTextSplitter
类实现对文本的递归字符串分割
4. 衍生代码分割器
递归字符文本分割器的核心部分在于传递不同的分割符列表,通过不同的优先级的列表,可以实现一些复杂文件的拆分,例如在该分割器内部预先构建了大量的的分割列表,用于在特定的编程语言中拆分文本。
支持的编程语言类型存储在 langchain_text_splitters.Language 枚举中,涵盖如下:
class Language(str, Enum):
"""Enum of the programming languages."""
CPP = "cpp"
GO = "go"
JAVA = "java"
KOTLIN = "kotlin"
JS = "js"
TS = "ts"
PHP = "php"
PROTO = "proto"
PYTHON = "python"
RST = "rst"
RUBY = "ruby"
RUST = "rust"
SCALA = "scala"
SWIFT = "swift"
MARKDOWN = "markdown"
LATEX = "latex"
HTML = "html"
SOL = "sol"
CSHARP = "csharp"
COBOL = "cobol"
C = "c"
LUA = "lua"
PERL = "perl"
HASKELL = "haskell"
要想查看给定语言的分隔符,可以使用 RecursiveCharacterTextSplitter.get_separators_for_language() 函数获取,例如查看 Python 语言的分隔符,如下
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON)
print(separators)
# 输出
['\nclass ', '\ndef ', '\n\tdef ', '\n\n', '\n', ' ', '']
可以从分隔符列表中看到 Python 文件的分割逻辑,优先将所有类都分割出来,然后分割函数,接下来分割类方法、模块语句等内容。
要想使用这些 编程语言分隔符 其实非常简单,在构造分割器的时候传递即可,或者使用 from_language() 并传递编程语言枚举数据也可以实现,示例如下
from langchain_community.document_loaders import UnstructuredFileLoader
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter
loader = UnstructuredFileLoader("./demo.py")
text_splitter = RecursiveCharacterTextSplitter.from_language(
Language.PYTHON,
chunk_size=500,
chunk_overlap=50,
)
documents = loader.load()
chunks = text_splitter.split_documents(documents)
for chunk in chunks:
print(f"块大小: {len(chunk.page_content)}, 元数据:{chunk.metadata}")
输出内容:
块大小: 151, 元数据:{'source': './demo.py'}
块大小: 335, 元数据:{'source': './demo.py'}
块大小: 439, 元数据:{'source': './demo.py'}
块大小: 499, 元数据:{'source': './demo.py'}
块大小: 108, 元数据:{'source': './demo.py'}
块大小: 187, 元数据:{'source': './demo.py'}
块大小: 352, 元数据:{'source': './demo.py'}
块大小: 450, 元数据:{'source': './demo.py'}
块大小: 446, 元数据:{'source': './demo.py'}
块大小: 431, 元数据:{'source': './demo.py'}
块大小: 347, 元数据:{'source': './demo.py'}
块大小: 492, 元数据:{'source': './demo.py'}
块大小: 491, 元数据:{'source': './demo.py'}
块大小: 471, 元数据:{'source': './demo.py'}
块大小: 489, 元数据:{'source': './demo.py'}
块大小: 474, 元数据:{'source': './demo.py'}
块大小: 476, 元数据:{'source': './demo.py'}
块大小: 492, 元数据:{'source': './demo.py'}
块大小: 472, 元数据:{'source': './demo.py'}
块大小: 490, 元数据:{'source': './demo.py'}
块大小: 469, 元数据:{'source': './demo.py'}
块大小: 452, 元数据:{'source': './demo.py'}
块大小: 490, 元数据:{'source': './demo.py'}
块大小: 497, 元数据:{'source': './demo.py'}
块大小: 492, 元数据:{'source': './demo.py'}
块大小: 496, 元数据:{'source': './demo.py'}
块大小: 497, 元数据:{'source': './demo.py'}
块大小: 498, 元数据:{'source': './demo.py'}
块大小: 491, 元数据:{'source': './demo.py'}
块大小: 466, 元数据:{'source': './demo.py'}
块大小: 348, 元数据:{'source': './demo.py'}
递归分割会尽可能从大分块上保证数据的连续性,打印其中一个分块,示例如下
print(chunks[2].page_content)
# 输出内容:
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.
separator = (
self._separator if self._is_separator_regex else re.escape(self._separator)
)
splits = _split_text_with_regex(text, separator, self._keep_separator)
_separator = "" if self._keep_separator else self._separator
return self._merge_splits(splits, _separator)
5. 中文场景下的递归分割
RecursiveCharacterTextSplitter 默认配置的分隔符均是英文场合下的,在中文场合下,除了换行/空格,一般还有更加复杂的语句结束判断标识,例如:。、!、?等标识符,如果想更好去切割 中英文文档,可以考虑重设分隔符列表(或者继承该类进行重写)。
不同符号的优先级如下:
- \n\n:换行两次优先级最高。
- \n:普通换行符优先级其次,一般切断后都不会导致上下文语义丢失。
- 。|!|?:中文中句号、感叹号、问号一般都表示句子结束,也可以尝试切割。
- .\s|!\s|?\s:对应到英文中就是点、感叹号、问号,并且标准的英文写法在这些符号后通常需要添加空格。
- ;|;\s:其次就是中英文的分段,在英文分段后一般会添加空格;
- ,|,\s:接下来优先级是中英文中的逗号,逗号一般都表示句子语义还未结束,所以一般不切割,除非文本块仍然超过大小。
- :空格和空字符串是优先级最低的切割符号之一,特别是在英文场合中,有时候两个词才有意义,切割出来意义就不大,而空字符串则是优先级最低的切割符,空字符串会把中文切割成单个汉字,在英文场合下切割成单个字母,几乎完全丢失语义。
更改后的示例如下:
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 1.创建加载器和文本分割器
loader = UnstructuredMarkdownLoader("./项目API文档.md")
text_splitter = RecursiveCharacterTextSplitter(
separators=[
"\n\n",
"\n",
"。|!|?",
"\.\s|\!\s|\?\s", # 英文标点符号后面通常需要加空格
";|;\s",
",|,\s",
" ",
""
],
is_separator_regex=True,
chunk_size=500,
chunk_overlap=50,
add_start_index=True,
)
# 2.加载文档与分割
documents = loader.load()
chunks = text_splitter.split_documents(documents)
# 3.输出信息
for chunk in chunks:
print(f"块大小: {len(chunk.page_content)}, 元数据: {chunk.metadata}")\
输出示例
块大小: 251, 元数据: {'source': './项目API文档.md', 'start_index': 0}
块大小: 451, 元数据: {'source': './项目API文档.md', 'start_index': 246}
块大小: 490, 元数据: {'source': './项目API文档.md', 'start_index': 699}
块大小: 305, 元数据: {'source': './项目API文档.md', 'start_index': 1165}
块大小: 435, 元数据: {'source': './项目API文档.md', 'start_index': 1472}
块大小: 497, 元数据: {'source': './项目API文档.md', 'start_index': 1859}
块大小: 237, 元数据: {'source': './项目API文档.md', 'start_index': 2359}
块大小: 483, 元数据: {'source': './项目API文档.md', 'start_index': 2598}
块大小: 486, 元数据: {'source': './项目API文档.md', 'start_index': 3092}
块大小: 438, 元数据: {'source': './项目API文档.md', 'start_index': 3580}
块大小: 293, 元数据: {'source': './项目API文档.md', 'start_index': 4013}
块大小: 498, 元数据: {'source': './项目API文档.md', 'start_index': 4261}
块大小: 463, 元数据: {'source': './项目API文档.md', 'start_index': 4712}
块大小: 438, 元数据: {'source': './项目API文档.md', 'start_index': 5129}
块大小: 474, 元数据: {'source': './项目API文档.md', 'start_index': 5569}
块大小: 93, 元数据: {'source': './项目API文档.md', 'start_index': 6018}
块大小: 464, 元数据: {'source': './项目API文档.md', 'start_index': 6113}
块大小: 478, 元数据: {'source': './项目API文档.md', 'start_index': 6579}
块大小: 379, 元数据: {'source': './项目API文档.md', 'start_index': 7035}
块大小: 489, 元数据: {'source': './项目API文档.md', 'start_index': 7416}
在 LLMOps 项目中,具体的文本分割逻辑由创建知识库的用户决定,所以 分隔符、文档块大小 和 块重叠大小 均是通过外部传递,然后再生成 RecursiveCharacterTextSplitter 分割器,而文档加载器使用 扩展名 + 通用非结构化文件加载器 来实现,更新后的架构运行流程如下 :