在导入非结构化数据之后,我们需要进行文本分块,也称为文本切分。这个过程是将长文本划分为大小合适的片段,以便于嵌入、索引和存储,并提升检索准确率。
图 2.1:带有流程图的文本分块优化方法
Lewis:为什么我们需要做这一步?这并不难理解,对吧?
Alex:确实。检索结果是由一个个独立单元组成的,我们把这些单元称为 chunks。这些单元的大小非常重要。以《西游记》为例,这部作品接近百万字。如果用户问“八十一难中的最后一难是什么”,而我们只是把整门课程都作为参考传给大模型,虽然这不一定算检索失败,但提供的信息范围太宽泛,不够精准。
为什么分块非常重要
Lewis:我非常喜欢维基百科上对 chunking 的定义,因为它不仅适用于 RAG,也用于认知心理学。维基百科提到:“chunking 是将小的信息片段绑定成一个组的过程。这些 chunk 的目的,是提升材料在短期记忆中的保持效果,从而绕过工作记忆容量有限的问题,并让工作记忆更加高效。”
将大型数据集划分为更小、更有意义的信息片段,是为了更有效地利用大模型的非参数化记忆。首先,这可以提升检索准确率,因为系统能够更精确地匹配关键词和语义,从而优化搜索结果。其次,它也可以优化模型性能,因为大模型在处理过长文本时可能遇到瓶颈,而分块有助于模型理解并提高响应效率,使其回答更加准确。
图 2.2:性能增长和野心的超线性回报
因此,分块的主要原因,是为了确保我们进行嵌入的内容在保持语义相关性的同时,尽可能不包含噪声。这是在为 RAG 系统准备数据时不可或缺的预处理步骤。
此外,分块之所以必要,还有另一个原因:大模型存在上下文窗口限制。
上下文窗口限制了最大 chunk 长度
RAG 系统在运行过程中依赖两类模型:嵌入模型和生成模型。这两类模型都有各自的上下文窗口限制。
每个文本片段都必须经过嵌入模型处理,以生成嵌入向量,因此需要考虑嵌入模型的上下文窗口限制。这个限制会因具体模型而异。对于商业模型,相关信息通常会在官方文档中提供;对于开源模型,这些信息通常可以在 Hugging Face Hub 等平台的模型卡中找到。
下图展示了 BGEM3 模型的 Hugging Face 页面,并显示了其特性和说明概览:
图 2.3:BGEM3 模型的 Hugging Face 页面
如果模型卡没有包含这些信息,你可以选择 Files and versions 标签页,然后在 tokenizer_config.json 或 config.json 等文件中查找相关字段。在这些文件中,BERT 模型中的 model_max_length 字段指定了模型能够容纳的最大输入 token 数。
下面是 Hugging Face 页面中可以找到 tokenizer_config.json 字段的位置:
图 2.4:bert-base-uncased 的 Hugging Face 页面
一旦确定了用于生成 embedding 的嵌入模型,就可以确定最大 chunk 长度。这个长度是以 token 为单位衡量的,而不是字符或单词。通常,嵌入模型的上下文窗口限制小于 8000 个 token。
检索到的 chunks 会作为上下文被送入生成模型的 prompt 中,用于生成回答。这意味着所有检索到的 chunks 的总长度不能超过生成模型的上下文窗口限制。小模型可能只能处理少量信息。在实际应用中,你还需要为其他内容预留空间,例如详细指令、角色定义,或者少量上下文示例。因此,检索到的知识上下文应该尽可能简洁。有时,为了满足这一限制,我们需要对 prompt 进行压缩,后续课程会介绍 prompt 压缩的方法。
虽然随着大模型的发展,这个问题正在变得不那么突出,许多现代大模型已经提供了巨大的上下文窗口。例如,GPT-4o 可以接受最高 128,000 token 的上下文。然而,即使 chunk 容量不再是问题,使用非常大的 chunk 时仍然需要谨慎,因为过多冗余信息可能会对对话结果的相关性产生负面影响。
chunk 大小对检索准确率的影响
虽然嵌入模型会指定每个 chunk 的最大 token 数,但这并不意味着你的 chunk 需要达到这个最大长度。事实上,为了提升检索准确率,我们往往更倾向于将文本切分成更小的 chunk。
分块和嵌入过程
让我们先“剧透”一下,当为一段文本生成嵌入向量时会发生什么。大多数嵌入模型都基于 encoder-only Transformer 架构,通过一个 pooling 层将输入文本映射为固定维度的向量,例如 768 维。
下面的例子展示了短文本和长文本如何被分词,并通过平均得到一个 768 维向量:
图 2.5:短文本与长文本的对比
首先,输入文本会被 tokenize,并在开头加入一个特殊的 [CLS] token。然后,Transformer 会为每个 token 生成对应的向量表示。接下来,所有 token 向量会通过以下某种 pooling 方法被压缩成一个单一向量,这可以看作是一种压缩形式,最终得到特定维度的文本嵌入向量:
CLS pooling:使用特殊 [CLS] token 的向量来表示整个序列的含义。
Mean pooling:对所有 token 的向量表示求平均。
Max pooling:从 token 向量中选择最大值,作为整个序列的表示。
因此,无论输入是一句 10 个单词的话,还是一段 1000 个单词的段落,生成的嵌入向量维度都是一样的。
这个过程天然是有损的。对于更大的 chunk,向量表示可能过于笼统,导致一些关键细节被模糊掉。这就是为什么不应该直接把嵌入模型支持的最大 token 数作为 chunk size。
分块与主题稀释
一个较大的文本 chunk 可能覆盖多个主题,其中一些与查询相关,另一些则无关。在这种情况下,单个向量试图表示多个主题,可能会导致主题稀释,从而影响检索准确率。
更小的文本 chunk 可以保持更集中的上下文,这有助于更精确地匹配和检索相关信息。通过将文档拆分为有意义的小片段,检索器可以更准确地定位具体片段或事实,从而增强 RAG 系统的表现。
例如,假设山西文旅建立了一个知识库,包含山西省各地的旅游景点和活动。如果一个文本 chunk 中同时包含关于“五台山、云冈石窟和太原游乐园”的描述,当你询问“适合儿童的景点”时,这个混合信息块可能不会获得很高的检索分数。但是,如果每个景点的信息被拆成独立 chunk,例如“五台山:著名佛教名山”“云冈石窟:唐代石刻艺术巅峰”“太原游乐园:夏季游玩指南”,那么 RAG 系统就能更准确地定位相关信息,并避免无关内容稀释相似度检索过程。
不过,文本 chunk 也不能太小。比如,如果你问“太原游乐园几点开门”,而太原游乐园的介绍和开放时间被切分到不同 chunk 中,那么检索结果可能包含其他景点的开放时间。这是因为在没有其他元数据的情况下,时间信息与景点信息之间的连接被切断了。
chunk 大小对生成质量的影响
从生成角度看,虽然许多现代大模型提供了非常大的上下文窗口,但如果完全填满这个窗口,仍然可能导致“大海捞针”问题,也就是在大量信息中很难找到关键部分。在 RAG 领域,这种现象被称为 lost in the middle,意思是随着信息量增加,提取关键信息会变得更加困难。
在医疗问答系统中,如果 chunk 太大,当学习者询问:
What antihypertensive drugs can be taken by patients with hypertension?
也就是“高血压患者可以服用哪些降压药?”时,系统可能返回大量与高血压相关的信息,并混入无关内容,包括诊断信息、治疗方案、药物禁忌等。这不仅增加阅读负担,还可能遮蔽关键信息。
通过应用合理的分块策略,知识库中的内容可以被拆分成主题清晰的小片段,例如“高血压:常用药物”“高血压:用药禁忌”“高血压:生活方式建议”,并将每个 chunk 控制在大约 500 个字符。这样,检索过程就可以更准确地捕捉每个主题的核心信息,并将这些信息传给生成模型,从而生成精确答案。例如:“常见降压药包括药物 A 和药物 B,应在医生指导下服用。”
chunk size 可以小到什么程度,同时还能保留足够上下文?
Alex:那么,chunk size 可以小到什么程度,同时还能保留足够上下文?
Lewis:这取决于课程的性质和项目需求。如何进行 chunking,以及文本切分的逻辑和大小,都应该基于具体应用场景,通常还需要通过实验来优化。如果检索目标比较宽泛,例如只是定位哪门课程相关,那么 chunks 可以更大,甚至完全不切分;但如果目标是找到某个具体句子或段落,就需要更细粒度的分块。通常来说,500 token 左右的 chunk size 是一个合理的起点。
不同的分块策略
在处理文本数据时,选择合适的分块策略,对于保持信息完整性、提升检索和生成质量至关重要。下面是几种常见的分块方法:
按固定字符数分块:这是最直接的方法,通过设置固定字符数,将大文档拆分成更小的 chunks。虽然简单,但这种方法可能导致语义不连续,例如把一个完整句子或代码块拆开。为了解决这个问题,通常会设置分隔符,避免强行切断句子或段落,并在 chunks 之间保留一定的 chunk_overlap,以确保语义上下文的连续性。这有助于避免“太原游乐园”和“开放时间”之间的连接被切断。
Alex 插话:为什么使用字符数,而不是大模型理解的 token 数来做 chunking?
Lewis 回答:因为在文本分块时,还没有执行 embedding,所以无法确定 token 数,只能根据字符数或单词数进行估算,因此不能选择按 token 数分块。
递归分块:通过指定一组分隔符,将文本逐步切分成更小的部分,以更好地保留单个句子或段落的完整性。例如,先使用双换行符 \n\n 作为段落分隔符进行切分,然后再使用句号 .、空格等标点进一步切分。如果某个段落超过预设长度,就继续使用下一层级的分隔符进行切分,确保每个部分长度合适且语义完整。
基于格式的分块:为了识别复杂文档结构,例如段落、标题、脚注、列表或表格,可以使用基于特定格式的分块工具,例如 Markdown、LaTeX、HTML 或代码。这些工具可以根据文档结构元素,例如标题或章节,对内容进行切分,确保内容结构完整。例如,Markdown 文件可以使用 # 标记进行切分,而代码文件可以通过 class 或 def 关键字进行切分。
基于布局的分块:对于包含布局信息的文档,例如 PDF 或 PPT,Unstructured 工具提供的基于布局的分块策略非常有用。这种策略可以根据逻辑单元进行内容切分,例如段落、表格、图片、代码等,同时允许灵活调整 chunk 大小和合并策略,以满足不同需求。
语义分块:这是一种高级分块策略,它首先按句子切分文本,然后使用 embedding 技术分析句子的语义,并将相似句子组合在一起形成 chunks。这种方法可以确保每个部分内部的语义一致性,适合需要深层语义理解的场景。LlamaIndex 的 SemanticSplitterNodeParser 和 LangChain 的 SemanticChunker 都提供了实现该功能的工具。
命题分块:基于语言模型的 proposition chunking 会将长文本拆解成多个命题,每个命题表达一个完整想法。每个命题都作为一个小的语义单元,便于检索和存储,适合需要高效搜索和引用的场景。
使用 ChunkViz 工具可视化分块
ChunkViz 工具可以帮助你可视化不同的分块方法,并允许你根据不同参数优化分块结果。下面是 ChunkViz v01 界面的一个示例,其中显示了文本分块选项,以及用于 chunk size 和 overlap 统计的滑块:
图 2.6:文本分块选项
将一段文本粘贴到输入框之后,你可以选择不同的 chunkers,也就是 Splitters,即各种分块策略,设置 chunk size,并观察不同设置下的分块结果。在标记为 Splitter 的下拉菜单中,可以选择各种字符文本切分器和递归文本切分器:
图 2.7:各种字符和递归文本切分器选项
按固定字符数分块
接下来,我们将探索如何实现不同的分块策略。首先介绍最简单的方法:按照固定字符数切分课程。这种方法简单高效,也是一种常见做法。
LangChain 中的 CharacterTextSplitter 工具
LangChain 使用 CharacterTextSplitter 按固定字符数、单词数或 token 数来切分文本。让我们通过下面的流程图,可视化按大小切分和合并文本块的步骤:
图 2.8:按大小检查和合并文本块
CharacterTextSplitter 首先按照指定字符分隔符切分文本。默认分隔符是 "\n\n",即两个换行符,也就是一个换行加一个空行。然后,它会不断累积这些碎片化片段,并检查当前累积的文本是否超过预设大小限制。如果超过,就会开始一个新的 chunk。此外,CharacterTextSplitter 允许你设置 chunks 之间的重叠字符数,以便更好地捕捉信息片段之间的关联。
下面的代码示例演示了如何使用 CharacterTextSplitter 的 split_documents 方法进行文本分块:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import CharacterTextSplitter
loader = TextLoader("data/ Shanxi Cultural Tourism/Yungang Grottoes.txt")
documents = loader.load()
### 设置切分器,指定 chunk size 为 50 个字符,不设置重叠
text_splitter = CharacterTextSplitter(
chunk_size=50,
chunk_overlap=0,
)
chunks = text_splitter.split_documents(documents)
print(chunks)
Anna:split_documents 方法读取的是 LangChain 的 Document 对象。如果想切分纯文本,应该怎么处理?
Lewis:你可以使用 create_documents 方法,传入一个文本列表,也就是不是 LangChain Document 对象的文本,并将它们切分成新的 Document 对象,如下面代码片段所示:
from langchain_text_splitters import CharacterTextSplitter
text = """
The Yungang Grottoes are located at the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City in northern Shanxi Province, China. The grottoes are carved into the mountain and extend one kilometer from east to west. There are 45 main caves preserved,...... """
text_splitter = CharacterTextSplitter(
chunk_size=50,
chunk_overlap=0,
separator="\n"
)
### 使用 create_documents 方法切分文本
chunks = text_splitter.create_documents([text])
print(chunks)
Anna:如果我只是想执行分块操作,并保持文档为普通字符串格式,而不创建 LangChain 的 Document 对象,应该怎么做?
Lewis:你应该使用 split_text 方法来切分文本字符串。这个方法只会返回切分后的纯文本片段,不会生成 Document 对象。当然,这也意味着你无法保留任何元数据。见下面的代码块:
from langchain_text_splitters import CharacterTextSplitter
text = """
The Yungang Grottoes are located at the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City, Shanxi Province, northern China. The grottoes are carved into the mountain, stretching one kilometer from east to west. There are 45 main caves preserved... """
text_splitter = CharacterTextSplitter(
chunk_size=50,
chunk_overlap=0,
separator="\n"
)
### 使用 split_text 方法切分文本
chunks = text_splitter.split_text(text)
print(chunks)
输出如下:
The Yungang Grottoes are located at the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City, Shanxi Province, northern China.
The grottoes are carved into the mountain, stretching one kilometer from east to west. There are 45 main caves preserved,
需要注意的是,如果你将 chunk size 设置为 1000 个字符,使用默认分隔符 "\n\n",而某个段落实际超过了这个限制,例如 1200 个字符,那么这种切分方法不会拆开该段落,而是会直接生成一个 1200 字符的 chunk,并发出警告:“Created a chunk of size 1200, which is longer than the specified 1000.” 为了解决这个问题,建议使用 RecursiveCharacterTextSplitter 方法。它可以更智能地处理这类情况,并确保没有 chunk 超过指定的最大长度。
在 LlamaIndex 中设置 chunk size 参数
在 LlamaIndex 中,SimpleDirectoryReader 可以在加载文档时直接执行分块。你只需要提前设置与分块相关的全局参数。下面是一个代码示例,展示如何在 LlamaIndex 中配置这些参数,并使用它们处理和查询文本数据。完整代码可参考 github.com/PacktPublis…
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from llama_index.core import Settings
documents = SimpleDirectoryReader(input_files=["data/ShanxiCulture/CloudCave.txt"]).load_data()
## 设置分块参数
Settings.chunk_size = 512 # 将文本 chunk 大小设置为 512 个字符
Settings.chunk_overlap = 50 # 将文本 chunk 之间的重叠设置为 50 个字符
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine(similarity_top_k=4)
response = query_engine.query("Where is the Yungang Grottoes located?")
print(response)
输出如下:
The Yungang Grottoes are located at the southern foot of Mount Wuzhou, 17 kilometers west of Datong City, Shanxi Province, northern China.
递归分块
Lewis:在 LangChain 中,RecursiveCharacterTextSplitter,也就是 LangChain 默认的分块方法,通过递归优化固定长度字符分块。这里的递归,是指使用一组分隔符逐步切分文本。如果最初切分出的文本块太大,就进一步细化切分过程,直到每个 chunk 达到期望大小。
下图展示了文本切分过程的逻辑流程,其中包含带标签的决策路径:
图 2.9:带有标记决策路径的文本切分过程
Alex:与 CharacterTextSplitter 相比,它主要优化了什么?
Lewis:在 CharacterTextSplitter 中,只指定一个分隔符进行切分,而 RecursiveCharacterTextSplitter 会尝试使用一个字符列表作为分隔符,默认是 ["\n\n", "\n", " ", ""],并且它会优先保持段落、句子和单词完整,直到 chunk 足够小。例如,如果要求每个 chunk 包含 500 个字符,而每个段落大约 400 个字符,那么就使用段落分隔符;如果段落太大,达到几千个字符,就递归引入新的分隔符,进一步在句子层面切分。
下面的代码示例演示了如何进行递归分块:
from langchain_text_splitters import RecursiveCharacterTextSplitter
text = """
The Yungang Grottoes are located at the southern foot of Wuzhou Mountain, 17 kilometers west of Datong City, Shanxi Province, northern China. The grottoes are dug into the mountain and stretch 1 kilometer from east to west. There are 45 main caves remaining... """
## 定义一个按优先级使用的分隔符列表
separators = ["\n\n", ".", ", ", " "]
## 创建递归分块器,并传入分隔符列表
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=10,
separators=separators
)
split_texts = text_splitter.split_text(text)
print(split_texts)
基于特定格式的分块
在处理编程语言代码时,使用合适工具正确切分代码非常重要。RecursiveCharacterTextSplitter 的 from_language 方法提供了针对不同编程语言优化过的文本切分能力。
Alex:什么业务场景下需要切分代码块?
Lewis:有很多。例如,不同团队协作并行开发时,需要代码评审和版本控制系统;或者按功能模块分析性能瓶颈和内存使用优化;在调试和测试中进行单元测试粒度控制、bug 定位和修复、行为调试等。这些都可以使用 RAG 系统从海量代码库中提取所需代码块。
RecursiveCharacterTextSplitter 内置了一个分隔符列表,可用于按特定编程语言切分文本。支持的语言存储在枚举数据对象 langchain_text_splitters.Language 中。
通过向 RecursiveCharacterTextSplitter.get_separators_for_language 方法传入一个语言值,你可以查看指定编程语言的分隔符列表。见下面的代码片段:
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_text_splitters import Language
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON)
print(separators)
输出如下:
['\nclass ', '\ndef ', '\n\tdef ', '\n\n', '\n', ' ', '']
下面的代码示例演示了如何切分一段 Python 代码:
from langchain_text_splitters import (
Language,
RecursiveCharacterTextSplitter,
)
GAME_CODE = """
class CombatSystem:
def __init__(self):
self.health = 100
self.stamina = 100
self.state = "IDLE"
self.attack_patterns = {
"NORMAL": 10,
"SPECIAL": 30,
"ULTIMATE": 50
}
def update(self, delta_time):
self._update_stats(delta_time)
self._handle_combat()
def _update_stats(self, delta_time):
self.stamina = min(100, self.stamina + 5 * delta_time)
def _handle_combat(self):
if self.state == "ATTACKING":
self._execute_attack()
def _execute_attack(self):
if self.stamina > self.attack_patterns["SPECIAL"]:
damage = 50
self.stamina -= self.attack_patterns["SPECIAL"]
return damage
return self.attack_patterns["NORMAL"]
"""
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON, # 指定编程语言为 Python
chunk_size=100,
chunk_overlap=0
)
python_docs = python_splitter.create_documents([GAME_CODE])
print(python_docs)
这个切分工具使用语言特定的关键字来切分类和函数,从而保持代码块的完整性和语义。LangChain 中还有许多类似的基于格式的切分工具。需要时可以查看官方网站。
基于文档结构或语义的分块
在某些文档处理场景中,仅仅按换行符或空行切分文本,可能无法准确捕捉文档的逻辑结构。例如,在论文、报告或手册等文档中,段落、标题、表格和列表项都具有特定语义。因此,我们希望进行更智能的文档切分,不仅基于换行,还要根据文档的版面结构,例如标题、正文、表格、分页等,生成语义更丰富的文本 chunk。
使用 unstructured 工具进行基于文档结构的分块
如前所述,LangChain 通过集成 Unstructured 工具提供了 Unstructured-Loader。这使得在加载 PDF、Word、HTML 等多种文档时,可以自动提取文本并将其切分为文档元素。然后,根据指定的分块策略和最大字符限制,将这些文档元素组合或进一步切分,以生成更符合具体需求的文本 chunk。
Unstructured 工具中主要有两种分块策略:Basic 和 By Title。这两种策略都基于识别文档的语义结构,而不是简单地按空行或换行符切分文本。
Basic 策略会按顺序将文本元素合并到同一个 chunk 中,直到达到最大字符数 max_characters 或软限制 new_after_n_chars。如果单个元素,例如特别长的正文段落或大型表格,本身就超过了最大字符数,它会被进一步切分。表格元素会被视为独立 chunks;如果表格过大,也会被切分为多个 TableChunks。可以通过 overlap 和 overlap_all 参数配置 chunk overlap。
By Title 策略在保留 Basic 策略基础行为的同时,当检测到新标题,也就是 Title 元素时,会立即关闭当前 chunk 并开始一个新的 chunk。其他行为,例如合并或切分跨页片段和短片段,可以通过 multipage_sections 和 combine_text_under_n_chars 参数进一步控制。
此外,当通过 API 调用 Unstructured 工具时,还有两种额外的智能分块策略可用:
By page:确保每一页内容独立分块。
By similarity:使用嵌入模型将主题相似的元素组合成 chunks。
接下来,我们将把上面两种主要分块策略应用到同一个文件:
from langchain_unstructured import UnstructuredLoader
loader = UnstructuredLoader(
"data/black myth/The setting of Black Myth Wukong.txt",
chunking_strategy="basic", # 指定分块策略为 Basic
max_characters=1000,
include_orig_elements=False # 不保留原始元素信息,只保留合并后的文本 chunks
)
docs = loader.load()
print("Number of Documents after chunking in LangChain:", len(docs))
print("Text length of the first chunk:", len(docs[0].page_content))
输出如下:
Number of LangChain Documents after chunking: 9
Text length of the first chunk: 815
如果更改策略,会得到不同结果:
loader = UnstructuredLoader(
"data/BlackMyth/BlackMythWukongSettings.txt",
chunking_strategy="by_title", # 指定分块策略为 By Title,按标题分块
)
输出如下:
Number of LangChain Documents after chunking: 9
Text length of the first chunk: 627
你可以自行比较这两种分块策略的结果。从理论上讲,By Title 策略是一种更智能的分块方式,不再依赖传统换行和空行。相反,它基于对文档逻辑结构的精确识别,例如标题、段落、表格等,从而提升文本切分的准确性和可用性。
使用 LlamaIndex SemanticSplitterNodeParser 进行语义分块
语义分块是一种高级且复杂的文本处理技术,不同于基于固定字符数的分块方法。语义分块器会使用 embedding 相似度,在句子之间自适应地选择断点,以确保每个 chunk 在语义上连贯。在 LlamaIndex 中,文本 chunks 被称为 Nodes,分块工具被称为 NodeParser。SemanticSplitterNodeParser 是实现语义分块的工具。
下面的代码示例演示了如何使用 SemanticSplitterNodeParser 进行语义分块。完整代码可参考 github.com/PacktPublis…
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import (
SentenceSplitter,
SemanticSplitterNodeParser,
)
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh")
documents = SimpleDirectoryReader(input_files=["data/black myth/The setting of Black Myth Wukong.txt"]).load_data()
### 创建语义分块器
splitter = SemanticSplitterNodeParser(
buffer_size=1, # 缓冲区大小
breakpoint_percentile_threshold=95, # 断点百分位阈值
embed_model=embed_model # 使用的嵌入模型
)
### 创建一个基础句子分块器用于对比
base_splitter = SentenceSplitter(chunk_size=512)
### 使用语义分块器切分文档
semantic_nodes = splitter.get_nodes_from_documents(documents)
print("Number of chunks generated by the semantic splitter:", len(semantic_nodes))
### 使用基础句子切分器进行文档分块
base_nodes = base_splitter.get_nodes_from_documents(documents)
print("Number of chunks generated by the basic sentence splitter:", len(base_nodes))
输出如下:
Number of chunks generated by the semantic splitter: 4
Number of chunks generated by the basic sentence splitter: 29
由于篇幅限制,这里没有展示每个 Node 内部的具体内容。运行上述程序后,你会发现,语义切分器会尽可能组织内容,而基础句子切分器只是根据预设长度进行简单切分。
LlamaIndex 提供了更高级的语义分块工具,例如以下两种:
SemanticDoubleMergingSplitterNodeParser,即双重语义合并切分器,通过多种合并策略将文本切分为语义一致的片段。它会考虑多个阈值,以确保分块既能捕捉主题变化,又能在合适时合并相邻语义单元。
TopicNodeParser,即主题节点分块器,基于主题相似度将文档切分成语义一致的文本块。它可以使用大模型或嵌入模型计算主题相似度,从而保持文本块之间的语义一致性。
对于同一组文档,你可以尝试不同工具,并比较它们的分块结果、性能和效率。由于设计复杂,语义分块工具在处理同一批文档时需要更长时间。在一般 RAG 系统设计中,递归分块通常已经足够,并不一定需要使用语义分块方法。根据奥卡姆剃刀原则,选择简单而有效的工具,往往是更明智的选择。
与分块相关的高级索引技术
在 RAG 系统中,索引构建广义上指的是为文本 chunks 建立一种数据存储机制,其中“数据”既包括嵌入向量,也包括文本 chunks 的元数据。这种机制的设计目标,是尽可能优化检索效果,使系统能够从海量数据中快速定位与查询最相关的内容,从而生成上下文相关的回答。
Alex:在 RAG 系统的索引构建过程中,有没有一些技术和分块技术关系非常密切?
Lewis:当然有。下面这些例子会给你们的项目实践带来很多启发。
使用滑动窗口进行句子切分
LlamaIndex 提供了一个名为 SentenceWindowNodeParser 的分块工具,它可以将课程解析成单句节点,同时在每个节点的元数据中创建一个窗口,包含该节点前后两侧的句子。检索时,首先将单个句子与查询进行匹配,然后根据窗口找到周围句子,最终将整体内容传给大模型。
下面的示例展示了文档切分,句子被分组并在列中高亮显示:
图 2.10:文档切分
下面的代码示例演示了如何使用 SentenceWindowNodeParser 对句子进行分块:
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core.node_parser import SentenceSplitter
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3, # 将窗口大小设置为 3 个句子
window_metadata_key="window", # 将窗口元数据 key 设置为 "window"
original_text_metadata_key="original_text", # 将原始文本元数据 key 设置为 "original_text"
)
nodes = node_parser.get_nodes_from_documents(documents)
sentence_index = VectorStoreIndex(nodes)
这项技术对于索引大型课程非常有用,因为它不仅有助于检索更细粒度的细节,也保留了大量上下文。
分块过程中混合生成父文本块和子文本块
在课程分块过程中,平衡小数据块的准确性和较大上下文的完整性,是一个关键挑战。LangChain 提供了一个名为 ParentDocumentRetriever 的检索器,专门用于解决这个问题。
对课程进行分块时,ParentDocumentRetriever 会先将原始课程划分为较大的父文本块,然后再将这些父文本块进一步细分成较小的子文本块。每个子块都会保留一个引用其父块的 ID,从而确保信息的连贯性和可追踪性。
下图展示了父子 chunks 如何被处理和分组:
图 2.11:从原始文档到父文本块和子文本块的层级结构
在存储阶段,会为子文本块生成嵌入向量并存入向量数据库,而父文本块则单独存储。这两个存储通过父文本块的 ID 关联起来。
下图展示了一个父文档和子 chunk,以及它们如何与向量嵌入数据一起存储:
图 2.12:与向量嵌入数据一起存储的父文档和子 chunk
在检索阶段,系统会找到与查询最相关的子文本块。在生成阶段,再使用它们的 ID 定位对应的父文本块,并将二者组合后传给大模型,从而提供更丰富、更准确的回答。
下面是一个示例,展示查询如何通过子文本块关联到父文本块:
图 2.13:查询通过子文本块调用父文本块
这种方法在课程切分、嵌入和检索之间找到了平衡,既提升了检索准确率,也保留了足够的上下文信息。
Alex:Lewis,在第 1 节介绍 Unstructured 工具时,Elements 解析出的 metadata 中也包含类似的父文本块,也就是父元素信息。这个方法也可以用于扩展当前元素,相当于在生成阶段自动扩展上下文。
Lewis:完全正确!这是举一反三的好例子。
在分块过程中为文本 chunks 创建元数据
另一种提高索引准确率的有效策略,是在分块过程中为文本 chunks 添加可作为过滤条件的元数据。随后可以利用这些元数据提升检索效率,从而更精确地识别所需信息。
例如,常见的元数据类型包括示例、页码、时间戳、类型和课程标题,它们可以用来标记和描述一门课程的基本属性。
下图展示了 2024 年财报数据中的 metadata 字段,例如 file name、page 和 year:
图 2.14:元数据字段
在检索过程中,除了基于语义的检索之外,这些元数据还可以作为额外过滤条件,进一步增强生成结果的相关性。例如,可以通过时间戳过滤特定时间范围内的内容,或者将检索范围限定在某一类别文本中。
下面的代码示例演示了如何使用 Milvus 向量数据库实现条件过滤:
filter_expr = 'type == "BOSS battle" and difficulty == "Hard"'
results = collection.search(
data=[search_embedding],
anns_field="embedding",
param=search_params,
limit=5,
expr=filter_expr, # 只返回满足元数据过滤条件的结果
output_fields=["yokai", "battle_scene", "difficulty"]
)
除了使用元数据进行显式过滤之外,你还可以利用 Hypothetical Document Embeddings,即 HyDE 技术,来增强检索效果。该技术由 Gao 等人在 2022 年提出。HyDE 的核心思想是:先使用大模型,例如 GPT,根据用户查询生成一篇假设文档,然后将这篇文档转换为向量并进行检索,以找到语义最接近的真实文档。
下面的架构图展示了 GPT 生成英文和韩文段落,并与真实文档信息匹配:
图 2.15:GPT 生成英文和韩文文本
HyDE 技术的工作流程
这种方法通常比直接查询原始向量更有效,因为 HyDE 技术生成的文档能够更好地捕捉查询背后的潜在意图,并提供更相关的结果。
在分块过程中形成层级索引
延续 2.6.3 节中元数据构建的思路,接下来要介绍的策略,是在分块过程中手动构建元数据,并形成层级索引。这一策略的核心思想,是通过对语料库进行层级化组织来增强检索和生成效果。
事实上,这种方法与滑动窗口技术和父子文本块技术类似,都是通过形成文档层级结构来优化 RAG 系统。本节将介绍 Summary → Document 技术,以及 Document → Embedded Objects 技术。
传统全文搜索可能会产生太多无关文档。Summary → Document 技术的本质,是在分块过程中为每个文本 chunk 创建摘要和关联节点;检索时,优先检索文档摘要,而不是全文档,然后再扩展到关联文档。这可以更准确地捕捉用户意图,提高检索效率,并增强结果相关性。
下面的示例展示了一个搜索查询如何指向文档摘要和节点详情:
图 2.16:一个搜索查询
例如,当用户查询“latest sports news”时,系统会首先定位相关文档摘要,然后进一步检索包含这些摘要及其关联节点的完整文档,从而不仅确保检索到核心内容,也提供额外背景信息。这种方法在管理长文档和多层级文档时尤其有效。
下面的代码示例演示了 Summary → Document 技术的具体实现:
from langchain_unstructured import UnstructuredLoader
from langchain_core.documents import Document
from langchain_deepseek import ChatDeepSeek
from langchain.chains import StuffDocumentsChain
loader = UnstructuredLoader("data/black myth/q1 The settings of Wukong.txt.pdf")
documents = loader.load()
llm = ChatDeepSeek(model="deepseek-chat")
for doc in documents[:5]:
response = llm.invoke(f"Please briefly summarize the following content in Chinese:\n\n{doc.page_content}")
doc.metadata['summary'] = response.content # 将摘要添加到 metadata 中
print(documents)
在这个例子中,第一段文本会通过大语言模型进行摘要,并保存到 metadata 中。在 LangChain 和 LlamaIndex 等框架中,也有一些工具可以自动为文本块生成摘要,例如 LlamaIndex 中的 DocumentSummaryIndex。
面向复杂数据结构的 Document embedded object 技术
Document → Embedded Object 技术专门用于对包含复杂数据结构的课程进行特殊分块。当一门课程不仅包含纯文本,还包含表格、图片、数据库引用等结构化内容时,传统分块方法通常只会返回整门课程,而忽略这些关键数据。相比之下,Document → Embedded Object 技术会在分块过程中保留复杂对象的结构。该方法首先匹配课程本身,然后进一步检索其中嵌入的对象。
在检索过程中,Document → Embedded Object 技术会首先识别并检索课程中实体引用的对象,也就是主文本块,然后根据需要查询这些对象的其他相关内容,例如表格、图片信息和其他子节点。这种方法能够更好地处理复杂课程中的多样化内容,使检索结果更加精确和全面。
下面的示例展示了知识库文档如何被拆分成节点、文本块和数据库:
图 2.17:对知识库文档执行拆分操作
例如,当查询“《黑神话:悟空》中有哪些 BOSS?”时,系统不仅会找到相关游戏文档,还会进一步分析其中的表格、SQL 数据表等,以确保返回的信息更准确,也更符合查询需求。这种方法对于技术文档、游戏数据库和企业知识库等多模态信息检索场景尤其重要,可以让信息提取更全面、更准确。
在 LlamaIndex 中,你可以通过 IndexNode 对象,为主课程下的表格对象建立层级索引。关于 LlamaIndex 的更多高级示例,请参考官方文档。
上述技术本质上都是通过在文档内容内部建立层级关系来优化检索过程。它们使 RAG 系统能够更智能地理解和利用文档中的结构化信息,从而提供更加准确和全面的检索结果。
总结
文本分块在 RAG 工作流中发挥着至关重要的作用。它不仅解决了上下文窗口限制问题,也可以显著增强模型在生成和检索等任务中的表现。不同分块策略和工具在处理文本时各有特点。例如,按字符数或 token 数分块适合快速处理结构良好的文本,而语义分块可以更准确地捕捉文本内部的语义一致性。
此外,为特定场景选择合适的分块工具,并结合滑动窗口方法、元数据构建等技术,例如为每个文本 chunk 提取摘要和标签,可以进一步提升分块的效果和适用性。
随着大模型上下文窗口不断扩展,分块技术的重要性可能不像以前那么突出。然而,根据实际需求灵活选择分块策略并进行优化,仍然是提升文本处理效率和质量的重要手段。
通过在分块过程中采用多种方法,例如语义分块、层级分块、元数据生成,以及为文本 chunks 构建摘要,我们可以获得优化 RAG 系统的新视角。
在下一章中,我们将从嵌入技术的起源到最新发展,介绍 embedding 技术的各个方面。我们还会提供许多具体实践示例,帮助学习者理解和动手练习。