在RAG系统中,第一步就是要对公司内部的文档进行文本分块(Chunk)处理。分块有俩个不得不做的目的和原因:
- 当用户输入问题时,根据用户的问题找到与用户问题相关的文本块内容。只把用户问题相关的文本内容提取出来。
- 向量化。目前大多数向量化Embedding模型的上下文窗口较小。如
bge-base-zh-v1.5这个模型的上下文窗口只有512个token。文档过大会导致向量化Embedding模型无法将分块的内容正常转换为向量化,所以文档必须分块。而且分块后的块大小不能超过Embedding模型的上下文窗口。
分块策略
按段落分块
在LangChain中CharacterTextSplitter类,默认采用\n\n这个段落分割符,将文本内容以段落进行分割。
Python
from langchain_text_splitters import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_core.documents import Document
loader = TextLoader("../../data/C2/txt/蜂医.txt")
docs = loader.load()
text_splitter = CharacterTextSplitter(
chunk_size=200, # 每个块的目标大小为200个字符
chunk_overlap=10 # 每个块之间重叠10个字符,以缓解语义割裂
)
chunks = text_splitter.split_documents(docs)
print(f"文本被切分为 {len(chunks)} 个块。\n")
print("--- 前5个块内容示例 ---")
for i, chunk in enumerate(chunks[:5]):
print("=" * 60)
# chunk 是一个 Document 对象,需要访问它的 .page_content 属性来获取文本
print(f'块 {i+1} (长度: {len(chunk.page_content)}): "{chunk.page_content}"')
CharacterTextSplitter默认的按段落分割有一个潜在问题就是当文档中的某一个单独段落 特别大、超过我们通过chunk_size参数指定的大小时,CharacterTextSplitter也不会对这个段落再次切割。如果这个段落超过了Embedding模型的上下文窗口时,Embedding转换出来的向量可能并不准确。此时系统会发出警告日志。
CharacterTextSplitter还有一个特性就是自动合并。当按照默认的段落分割时,如果某一个段落的内容特别短,远远没有达到chun_size参数指定的大小时,为了保持分块(Chunk)的上下文连续性,CharacterTextSplitter会自动合并下一个段落,直到合并后的段落超过chunk_size参数指定的大小时结束合并。
递归字符分块
递归字符分块,可以有效解决上面的单个段落特别大的问题。如果某个段落特别大,此时会继续递归使用\n换行分割符进行切分,如果还是特别大继续使用后面的分割符号进行切分,直到后面的分割符都使用完毕,直到满足chunk_size参数指定的大小。默认的分割符为:["\n\n", "\n", " ", ""]。对于中文可以在separators添加正则表达式来指定分割符。
默认情况下 (keep_separator=True),分隔符本身会被保留在分割后的文本块中。
使用其基类 TextSplitter 中定义的默认参数 chunk_size=4000(块大小)和 chunk_overlap=200(块重叠)。这些参数确保文本块符合预定的大小限制,并通过重叠来减少上下文信息的丢失。
Python
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_core.documents import Document
loader = TextLoader("../../data/C2/txt/蜂医.txt")
docs = loader.load()
# 递归字符分割,对docs优先按照段落分割符"\n\n"进行分割,如果没有段落分割符,再按照换行分割符"\n"进行分割,如果没有换行分割符,再按照空格分割。
# 最后的""表示按照chunk_size的大小进行分割。
# 递归分割符是LangChain中默认的分割器。绝大多数的通用文本的首选策略。
text_splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", ",", " ", ""], # 分隔符优先级
chunk_size=200,
chunk_overlap=10,
)
chunks = text_splitter.split_documents(docs)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i + 1} ---")
print(chunk.page_content)
编程语言支持:
Python
# 针对代码文档的优化分隔符
splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON, # 支持Python、Java、C++等
chunk_size=500,
chunk_overlap=50
)
结构化文本分块
对于具有明确结构标记的文档格式(如Markdown、HTML、LaTex),可以利用这些标记来实现更智能、更符合逻辑的分割。
LangChain 提供了 MarkdownHeaderTextSplitter 来处理。 MarkdownHeaderTextSplitter结构化分割同样存在分割后的块可能过大,不过MarkdownHeaderTextSplitter结构化分割可以配合递归字符分块RecursiveCharacterTextSplitter组合使用。
Python
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_core.documents import Document
# 定义要分割的标题层级
headers_to_split_on = [
('#', "主标题"), # 菜品名称
('##', "二级标题"), # 必备原料、计算、操作等
("###", "三级标题") # 简易版本、复杂版本等
]
# 创建Markdown分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on = headers_to_split_on,
strip_headers = False # 保留标题、便于大模型理解上下文
)
# 对每个文档进行Markdown分割
md_chunks = markdown_splitter.split_text(markdown_document)
for i, chunk in enumerate(md_chunks):
print(f"元数据Metadata: {chunk.metadata}")
print(chunk.page_content)
语义分块(Semantic Chunking)
语义分块(Semantic Chunking)不依赖特定的分割符。而是依赖Embedding模型进行语义判断,默认基于句子进行分割。然后判断这一句与下一句的语义是否相似。原理是:在语义主题发生显著变化的地方进行切分。
语义分块和CharacterTextSplitter按段落分割都存在同样的问题,分割后的块可能过大,大到超过Embedding模型的上下文窗口。
开源框架Unstructured
Unstructured是一个强大的文档处理工具,同样提供了实用的分块功能。
ChunkViz:简易的可视化分块工具
分块的缺点
分块有一个悖论:分块小的时候,使用用户输入的问题进行搜索时可以更加精确的匹配到小块的内容。但是容易丢失上下文信息,造成语义不完整。分块大搜索不精确,但是上下文不容易丢失,语义完整。
你可以想象一下,用户搜索"苹果"时,如果你的分块内容有俩种,一种是小块内容只有500个字,500个字里面包含"苹果"这个关键字。和大块内容5万字,5万字里面包含"苹果"这个关键字。这两个分块,哪个更容易被搜到,或者哪个块的精确度更高?显然是小块的内容精确度高。
但是小块文本,没有足够多的上下文。很可能你搜出来这个小块的内容,你看完之后并不知道这个500个字在描述关于"苹果"的什么事情?如果你都不知道,你把这个内容发送给大语言模型(豆包),大语言模型(豆包)肯定也不知道了。
解决这个问题可以通过:句子窗口检索(Sentence Window Retrieval) 的方式。原理:为检索精确性而索引小块,为上下文丰富性而检索大块。
在LangChain中分割后的文本块,是一个Document对象,这个Document对象有一个元数据属性metadata,类型为dict字典(也就是Map-键值对)。我们可以在这个元数据中将这个Chunk的元信息存入进去。比如,这个Chunk是从哪个文档中分割出来的,这个Chunk的前面几句是什么后面几句是什么。当根据用户问题搜索到这个块的时候,我们根据这个Chunk的元信息,把这个Chunk对应的文档或者前后的句子都发送给大模型,此时就能获取足够多的上下文信息了。
Python
from langchain_core.documents import Document
from langchain_text_splitters import MarkdownHeaderTextSplitter
from typing import List, Dict, Any
# 定义要分割的标题层级
headers_to_split_on = [
('#', "主标题"), # 菜品名称
('##', "二级标题"), # 必备原料、计算、操作等
("###", "三级标题") # 简易版本、复杂版本等
]
# 创建Markdown分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on = headers_to_split_on,
strip_headers = False # 保留标题、便于大模型理解上下文
)
# documents完整的文档
for doc in self.documents:
# 对每个文档进行Markdown分割
md_chunks = markdown_splitter.split_text(doc.page_content)
# 循环给分割后的每个chunk块增加元信息metadata
for i, chunk in enumerate(md_chunks):
chunk.metadata.update({
"chunk_id": child_id,
"parent_id": parent_id,
"doc_type": "child", # 标记为子文档
"chunk_index": i # 在父文档中的位置
})
结构化索引
利用上面的元信息metadata,我们可以把文档的更多信息放到这个metadata元数据中,比如文件名、文档创建日期、作者、业务线等等。搜索时,我们可以先根据元数据进行精准过滤,比如只在元数据中业务线等于电商的数据块中进行搜索。
这种"先过滤,再搜索"的策略,能够极大地缩小检索范围,显著提升大规模知识库场景下RAG应用的检索效率和准确性。
大模型
在发送给大模型内容时,如果需要让大模型在语义上区分独立的段落,需要使用 "\n\n" (双换行符) 而不是 "\n" (单换行符) 。主要是为了在传递给大型语言模型(LLM)时,能够更清晰地在语义上区分这些独立的文本片段。双换行符通常代表段落的结束和新段落的开始,这种格式有助于LLM将每个块视为一个独立的上下文来源,从而更好地理解和利用这些信息来生成回答。