第二章 数据准备 (ALL-IN-RAG)
第一节、数据加载
一、文档加载器
1.1 主要功能
RAG 系统中,数据加载是整个流水线的第一步,也是不可或缺的一步。文档加载器负责将各种格式的非结构化文档(如PDF、Word、Markdown、HTML等)转换为程序可以处理的结构化数据。数据加载的质量会直接影响后续的索引构建、检索效果和最终的生成质量。
文档加载器在 RAG 的数据管道中一般需要完成三个核心任务 ,一是解析不同格式的原始文档,将 PDF、Word、Markdown 等内容提取为可处理的纯文本 ,二是在解析过程中同时抽取文档来源、页码、作者等关键信息作为元数据 ,三是把文本和元数据整理成统一的数据结构,方便后续进行切分、向量化和入库,其整体流程与传统数据工程中的抽取、转换、加载相似,目标都是把杂乱的原始文档清洗并对齐为适合检索和建模的标准化语料。
任务一:解析不同格式 → 提取纯文本
这是文档加载器的基本功。不同格式对应不同的解析策略:
格式 常用加载器 关键点
PDF PyMuPDFLoader、UnstructuredPDFLoader 提取文本层(非扫描件);若为扫描件需 OCR
Word (.docx) Docx2txtLoader 解析 XML 内容,保留段落结构
Markdown UnstructuredMarkdownLoader 解析标题层级(# → 章节语义)
HTML BSHTMLLoader 去除 HTML 标签,提取 文本;可通过 soup 参数精确定位
JSON/CSV JSONLoader/CSVLoader 结构化数据本身,但需转为文本语义块
1.2当前主流的文档加载器
| 工具名称 | 特点 | 适用场景 | 性能表现 |
|---|---|---|---|
| PyMuPDF4LLM | PDF→Markdown转换,OCR+表格识别 | 科研文献、技术手册 | 开源免费,GPU加速 |
| TextLoader | 基础文本文件加载 | 纯文本处理 | 轻量高效 |
| DirectoryLoader | 批量目录文件处理 | 混合格式文档库 | 支持多格式扩展 |
| Unstructured | 多格式文档解析 | PDF、Word、HTML等 | 统一接口,智能解析 |
| FireCrawlLoader | 网页内容抓取 | 在线文档、新闻 | 实时内容获取 |
| LlamaParse | 深度PDF结构解析 | 法律合同、学术论文 | 解析精度高,商业API |
| Docling | 模块化企业级解析 | 企业合同、报告 | IBM生态兼容 |
| Marker | PDF→Markdown,GPU加速 | 科研文献、书籍 | 专注PDF转换 |
| MinerU | 多模态集成解析 | 学术文献、财务报表 | 集成LayoutLMv3+YOLOv8 |
二、Unstructured文档处理库
2.1核心优势
Unstructured 1是一个专业的文档处理库 ,专门设计用于RAG和AI微调场景的非结构化数据预处理。提供了统一的接口来处理多种文档格式,是目前应用较广泛的文档加载解决方案之一。Unstructured 在格式支持和内容解析方面具有明显优势,它一方面支持 PDF、Word、Excel、HTML、Markdown 等多种文档格式,并通过统一的 API 接口避免为不同格式分别编写代码,另一方面可以自动识别标题、段落、表格、列表等文档结构,同时保留相应的元数据信息。

2.2支持的文档元素类型
| 元素类型 | 描述 |
|---|---|
Title |
文档标题 |
NarrativeText |
由多个完整句子组成的正文文本,不包括标题、页眉、页脚和说明文字 |
ListItem |
列表项,属于列表的正文文本元素 |
Table |
表格 |
Image |
图像元数据 |
Formula |
公式 |
Address |
物理地址 |
EmailAddress |
邮箱地址 |
FigureCaption |
图片标题/说明文字 |
Header |
文档页眉 |
Footer |
文档页脚 |
CodeSnippet |
代码片段 |
PageBreak |
页面分隔符 |
PageNumber |
页码 |
UncategorizedText |
未分类的自由文本 |
CompositeElement |
分块处理时产生的复合元素* |
三、LangChain代码封装
python
from unstructured.partition.auto import partition
# PDF文件路径
pdf_path = "../../data/C2/pdf/rag.pdf"
# 使用Unstructured加载并解析PDF文档
elements = partition(
filename=pdf_path,
content_type="application/pdf"
)
# 打印解析结果
print(f"解析完成: {len(elements)} 个元素, {sum(len(str(e)) for e in elements)} 字符")
# 统计元素类型
from collections import Counter
types = Counter(e.category for e in elements)
print(f"元素类型: {dict(types)}")
# 显示所有元素
print("\n所有元素:")
for i, element in enumerate(elements, 1):
print(f"Element {i} ({element.category}):")
print(element)
print("=" * 60)
- 导入自动分区函数(第 1 行)
from unstructured.partition.auto import partition
partition 是 Unstructured 库的核心入口。它会根据文件类型自动选择最佳解析策略:
- PDF → 调用 partition_pdf
- Word → 调用 partition_docx
- HTML → 调用 partition_html
- Markdown → 调用 partition_md
这种设计模式叫 策略模式(Strategy Pattern) ------ 同一个接口,根据输入类型自动切换底层实现。
- 指定文件路径(第 4 行)
pdf_path = ".../.../data/C2/pdf/rag.pdf"
注意这里的相对路径:脚本运行位置必须是 code/C2/ 目录,才能正确向上两级找到 data/ 文件夹。这是本项目的统一约定。- 执行解析(第 7-10 行)
elements = partition(
filename=pdf_path,
content_type="application/pdf"
)
调用 partition() 返回 ListElement。每个 Element 包含:
- .text:提取的文本内容
- .category:元素类型(如 Title、NarrativeText、ListItem、PageBreak)
- .metadata:元数据(页码、文件名等)
content_type 参数显式指定 MIME 类型,帮助 partition 确认处理策略。
- 统计与展示(第 13-25 行)
总览
print(f"解析完成: {len(elements)} 个元素, ...")
元素类型分布 --- 用 Counter 做频率统计
from collections import Counter
types = Counter(e.category for e in elements)
逐条展示
for i, element in enumerate(elements, 1):
print(f"Element {i} ({element.category})😊
print(element)
原文的练习:使用partition_pdf替换当前partition函数并分别尝试用hi_res和ocr_only进行解析,观察输出结果有何变化。
from unstructured.partition.pdf import partition_pdf
pdf_path = "../../data/C2/pdf/rag.pdf"
def analyze_with_strategy(strategy_name):
print(f"\n{'='*60}")
print(f"策略: {strategy_name}")
print('='*60)
elements = partition_pdf(
filename=pdf_path,
strategy=strategy_name
)
print(f"元素总数: {len(elements)}")
print(f"总字符数: {sum(len(str(e)) for e in elements)}")
from collections import Counter
types = Counter(e.category for e in elements)
print(f"元素类型分布: {dict(types)}")
print("\n前 5 个元素:")
for i, element in enumerate(elements[:5], 1):
text_preview = str(element)[:80]
print(f" [{element.category}] {text_preview}...")
analyze_with_strategy("hi_res")
analyze_with_strategy("ocr_only")
原函数
解析完成: 279 个元素, 7500 字符
元素类型: {'Header': 22, 'Title': 184, 'NarrativeText': 14, 'UncategorizedText': 41, 'Footer': 15, 'ListItem': 3}
所有元素:
Element 1 (Header):
网页
============================================================
Element 2 (Header):
新闻
============================================================
Element 3 (Header):
贴吧
============================================================
Element 4 (Header):
知道
============================================================
Element 5 (Header):
网盘
============================================================
Element 6 (Header):
图片
第二节 文本分块
一、理解文本分块
文本分块(Text Chunking)是构建 RAG 流程的关键步骤。它的原理是将加载后的长篇文档,切分成更小、更易于处理的单元。这些被切分出的文本块,是后续向量检索和模型处理的基本单位。

二、文本分块的重要性
2.1满足模型上下文限制
将文本分块的首要原因,是为了适应 RAG 系统中两个核心组件的硬性限制:
- 嵌入模型 (Embedding Model) : 负责将文本块转换为向量。这类模型有严格的输入长度上限。例如,许多常用的嵌入模型(如
bge-base-zh-v1.5)的上下文窗口为512个token。任何超出此限制的文本块在输入时都会被截断,导致信息丢失,生成的向量也无法完整代表原文的语义。因此,文本块的大小必须小于等于嵌入模型的上下文窗口。 - 大语言模型 (LLM): 负责根据检索到的上下文生成答案。LLM同样有上下文窗口限制(尽管通常比嵌入模型大得多,从几千到上百万token不等)。检索到的所有文本块,连同用户问题和提示词,都必须能被放入这个窗口中。如果单个块过大,可能会导致只能容纳少数几个相关的块,限制了LLM回答问题时可参考的信息广度。
因此,分块是确保文本能够被两个模型完整、有效处理的基础。
文本分块的核心原因总结
分块的根本动因是 适配两个组件的硬性长度限制:
- 适配嵌入模型(Embedding Model)的上下文窗口
- 限制:小型嵌入模型(如本项目默认的 BAAI/bge-small-zh-v1.5)窗口通常只有 512 tokens
- 问题:超出即被截断 → 信息丢失 → 向量无法完整表达语义
- 对策:块大小 ≤ 嵌入模型窗口
- 适配大语言模型(LLM)的上下文窗口
- 限制:虽然 LLM 窗口更大(几千~百万 tokens),但并非无限
- 问题:单块过大 → 能塞进的块数量减少 → LLM 可参考的信息广度受限
- 对策:块大小适中,让固定窗口内容纳更多相关片段
2.2 为何"块"不要越大越好
假设嵌入模型最多能处理 8192 个 token,是否应该把块切得尽可能大(比如8000个token)呢?答案是否定的。块的大小并非越大越好,过大的块会严重影响RAG系统的性能。
2.2.1嵌入过程的信息损失
大多数嵌入模型都基于 Transformer 编码器。其工作流程大致如下:
-
分词 (Tokenization): 将输入的文本块分解成一个个 token。
-
向量化 (Vectorization) : Transformer 为每个 token 生成一个高维向量表示。
-
池化 (Pooling) : 通过某种方法(如取
[CLS]位的向量、对所有token向量求平均mean pooling等),将所有 token 的向量压缩 成一个单一的向量,这个向量代表了整个文本块的语义。在这个
压缩过程中,信息损失是不可避免的。一个768维的向量需要概括整个文本块的所有信息。文本块越长,包含的语义点越多,这个单一向量所承载的信息就越稀释,导致其表示变得笼统,关键细节被模糊化,从而降低了检索的精度。
2.2.2 Lost in the Middle
即使将检索到的多个大块文本都塞进LLM的长上下文窗口中,也会出现关键信息被"淹没"在大量无关内容里的问题。有研究表明 1,当LLM处理非常长的、充满大量信息的上下文时,它倾向于更好地记住开头和结尾的信息,而忽略中间部分的内容。
如果提供给LLM的上下文块又大又杂,充满了与问题无关的噪音,模型就很难从中提取出最关键的信息来形成答案,从而导致回答质量下降或产生幻觉。
2.2.3 主题稀释导致检索失败
一个好的文本块应该聚焦于一个明确、单一的主题。如果一个块包含太多不相关的主题,它的语义就会被稀释,导致在检索时无法被精确匹配。
示例场景:用户提问 "Python 的列表推导式怎么用?"
❌ 主题稀释的文本块(合并到一个大块中)
标题: Python 编程指南
Python 是一种广泛使用的解释型高级编程语言。它的设计哲学强调代码可读性,
使用缩进来定义代码块。列表推导式是 Python 中一种简洁的创建列表的方式,
语法为 表达式 for 变量 in 可迭代对象 if 条件。除此之外,Python
还支持装饰器、生成器、上下文管理器等高级特性。在数据科学领域,
Python 拥有 NumPy、Pandas 和 Matplotlib 等强大的第三方库。
Django 和 Flask 则是 Web 开发中最常用的两个框架。
检索时的问题:这个块包含"Python语言特性 → 列表推导式 → 高级特性 → 数据科学 → Web框架"多个主题。用户向量("列表推导式的用法")与这个块的语义相似度被稀释,可能排在多个更相关但包含无关内容的块之后。
三、基础分块策略
LangChain 提供了丰富且易于使用的文本分割器(Text Splitters),下面将介绍几种最核心的策略。
3.1 固定分块大小
这是最简单直接的分块方法。根据LangChain源码,这种方法的工作原理分为两个主要阶段:
(1)按段落分割 :CharacterTextSplitter 采用默认分隔符 "\n\n",使用正则表达式将文本按段落进行分割,通过 _split_text_with_regex 函数处理。
(2)智能合并 :调用继承自父类的 _merge_splits 方法,将分割后的段落依次合并。该方法会监控累积长度,当超过 chunk_size 时形成新块,并通过重叠机制(chunk_overlap)保持上下文连续性,同时在必要时发出超长块的警告。
需要注意,CharacterTextSplitter 实际实现的并非严格的固定大小分块。根据 _merge_splits 源码逻辑,这种方法会:
- 优先保持段落完整性 :只有当添加新段落会导致总长度超过
chunk_size时,才会结束当前块 - 处理超长段落 :如果单个段落超过
chunk_size,系统会发出警告但仍将其作为完整块保留 - 应用重叠机制 :通过
chunk_overlap参数在块之间保持内容重叠,确保上下文连续性
所以,LangChain 的实现更准确地应该称为"段落感知的自适应分块",块大小会根据段落边界动态调整。
python
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
loader = TextLoader("../../data/C2/txt/蜂医.txt")
docs = loader.load()
text_splitter = CharacterTextSplitter(
chunk_size=200, # 每个块的目标大小为100个字符
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}"')
文本被切分为 14 个块。
--- 前5个块内容示例 ---
============================================================
块 1 (长度: 72): "# 蜂医
游戏《三角洲行动》中的支援型干员
蜂医是2024年琳琅天上发行的《三角洲行动》中的支援型干员之一,在早期版本是唯一一个支援型干员。"
============================================================
块 2 (长度: 201): "蜂医在游戏中能够使用战术装备"激素枪":远程治疗队友或'自我治疗',还可以使用兵种道具"烟幕无人机":释放长烟幕,和"蜂巢科技烟雾弹":产生一团白色烟雾(使用激素枪对烟雾射击换变成绿色烟雾,可起到治疗作用),干员特长为"高效救援":救援倒地队友时速度更快,在全面战场模式中约1.4秒就能救起队友,且被救起的队友能恢复更多生命值。在烽火地带中,还能够移除队友血量上限减少的负面效果。 \[1-2]****"
============================================================
块 3 (长度: 189): "* 中文名
罗伊•斯米
* 外文名
Roy smee \[2]**
* 别 名
罗伊、蜂医
* 性 别
男
- 登场作品
[三角洲行动](/item/%E4%B8%89%E8%A7%92%E6%B4%B2%E8%A1%8C%E5%8A%A8/63251542?fromModule=lemma_inlink)
- 生 日"
============================================================
块 4 (长度: 133): "- 生 日
2008年2月23日 \[3]**
- 身 高
176 cm \[3]**
- 体 重
75 kg \[3]**
## 目录
1. 1[角色设定](#1)
2. 2[角色定位](#2)
3. 3[技能](#3)"
============================================================
块 5 (长度: 189): "1) ▪[战术装备 - 激素枪](#3-1)
2) ▪[战术道具 - 烟幕无人机](#3-2)
3) ▪[战术道具 - 蜂巢科技烟雾弹](#3-3)
1. ▪[干员特长 - 高效救援](#3-4)
这种方法的主要优势在于实现简单、处理速度快且计算开销小。劣势在于可能会在语义边界处切断文本,影响内容的完整性和连贯性。实际的固定大小分块实现(如LangChain的 CharacterTextSplitter)通常会结合分隔符来减少这种问题,在段落边界处优先切分,只有在必要时才会强制按大小切断。因此,这种方法在日志分析、数据预处理等场景中仍有其应用价值。
3.2 递归字符分块
算法流程:
(1)寻找有效分隔符 : 从分隔符列表中从前到后遍历,找到第一个在当前文本中存在 的分隔符。如果都不存在,使用最后一个分隔符(通常是空字符串 "")。
(2)切分与分类处理: 使用选定的分隔符切分文本,然后遍历所有片段:
-
如果片段不超过块大小 : 暂存到
_good_splits中,准备合并 -
如果片段超过块大小
:
- 首先,将暂存的合格片段通过
_merge_splits合并成块 - 然后,检查是否还有剩余分隔符:
- 有剩余分隔符 : 递归调用
_split_text继续分割 - 无剩余分隔符: 直接保留为超长块
- 有剩余分隔符 : 递归调用
- 首先,将暂存的合格片段通过
(3)最终处理: 将剩余的暂存片段合并成最后的块
假设原始文本是:
深度学习是机器学习的一个分支。
它使用多层神经网络来学习数据的层次特征表示。
近年来,深度学习在计算机视觉领域取得了巨大成功。
卷积神经网络(CNN)是其中最常用的模型之一。
我们的配置:
- 分隔符列表(按顺序尝试):"\\n\\n", "\\n", "。", ",", " ", ""
- 块大小: 30 个字符(为了演示缩小)
- 重叠: 0
逐步骤演算
第 1 步:找第一个能用的分隔符
从前往后遍历分隔符列表:
分隔符 在文本中是否存在?
\n\n ❌ 没有连续换行
\n ❌ 文本在一行上
。 ✅ 存在!
所以选择 。 作为第一层分隔符。
第 2 步:用 。 切分
切分得到:
片段编号 内容
① 深度学习是机器学习的一个分支。
② 它使用多层神经网络来学习数据的层次特征表示。
③ 近年来,深度学习在计算机视觉领域取得了巨大成功。
④ 卷积神经网络(CNN)是其中最常用的模型之一。
(注意:因为是用 。 做分隔,切出来的片段不包含分隔符,但 LangChain 的实现会视配置决定是否保留。为了清晰,这里简化说明。)
第 3 步:遍历每个片段
片段 ①(13 字符): ≤30 ✅ → 暂存到 _good_splits 缓冲区
片段 ②(19 字符): ≤30 ✅ → 放入缓冲区
这时缓冲区有:①, ②,累计 13+19=32 > 30(块大小)
→ 触发合并溢出:- 从缓冲区取出前 n-1 个片段合并为一个块:① = 13字符 ✅
- 剩下的 ② 留在缓冲区
当前结果:已输出块 1 = 深度学习是机器学习的一个分支。
继续遍历:
缓冲区还有 ②(19 字符),累计 19 ≤ 30 ✅
片段 ③(22 字符) → 缓冲区:②, ③,累计 19+22=41 > 30
→ 触发合并溢出:- 输出 ② = 19字符 作为块 2
- 剩下 ③ 留在缓冲区
当前结果:- 块 1: 深度学习是机器学习的一个分支。
- 块 2: 它使用多层神经网络来学习数据的层次特征表示。
缓冲区:③(22 字符)
片段 ④(21 字符) → 缓冲区 ③, ④,累计 22+21=43 > 30
→ 触发合并溢出:- 输出 ③ = 22字符 作为块 3
- 剩下 ④ 留在缓冲区
第 4 步:遍历结束,处理剩余缓冲区
缓冲区还有 ④(21 字符)→ 合并输出为块 4
实现细节:
- 批处理机制 : 先收集所有合格片段(
_good_splits),遇到超长片段时才触发合并操作。 - 递归终止条件 : 关键在于
if not new_separators判断。当分隔符用尽时(new_separators为空),停止递归,直接保留超长片段。确保算法不会无限递归。
与固定大小分块的关键差异:
- 固定大小分块遇到超长段落时只能发出警告并保留。
- 递归分块会继续使用更细粒度的分隔符(句子→单词→字符)直到满足大小要求。
python
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
loader = TextLoader("../../data/C2/txt/蜂医.txt")
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", ",", " ", ""], # 分隔符优先级
chunk_size=200,
chunk_overlap=10,
)
chunks = text_splitter.split_text(docs)
分隔符配置:
- 默认分隔符 :
["\n\n", "\n", " ", ""] - 多语言支持:对于无词边界语言(中文、日文、泰文),可添加:
python
separators=[
"\n\n", "\n", " ",
".", ",", "\u200b", # 零宽空格(泰文、日文)
"\uff0c", "\u3001", # 全角逗号、表意逗号
"\uff0e", "\u3002", # 全角句号、表意句号
""
]
递归字符分块的原理是采用一组有层次结构的分隔符(如段落、句子、单词)进行递归分割,旨在有效平衡语义完整性与块大小控制。在 RecursiveCharacterTextSplitter 的实现中,该分块器首先尝试使用最高优先级的分隔符(如段落标记)来切分文本。如果切分后的块仍然过大,会继续对这个大块应用下一优先级分隔符(如句号),如此循环往复,直到块满足大小限制。这种分层处理的机制,能够在尽可能保持高级语义结构完整性的同时,有效控制块大小
3.3 语义分块
语义分块(Semantic Chunking)是一种更智能的方法,这种方法不依赖于固定的字符数或预设的分隔符 ,而是尝试根据文本的语义内涵 来切分。其核心是:在语义主题发生显著变化的地方进行切分 。这使得每个分块都具有高度的内部语义一致性。LangChain 提供了 langchain_experimental.text_splitter.SemanticChunker 来实现这一功能。
实现原理
SemanticChunker 的工作流程可以概括为以下几个步骤:
(1)句子分割 (Sentence Splitting):首先,使用标准的句子分割规则(例如,基于句号、问号、感叹号)将输入文本拆分成一个句子列表。
(2)上下文感知嵌入 (Context-Aware Embedding) :这是 SemanticChunker 的一个关键设计。该分块器不是对每个句子独立进行嵌入,而是通过 buffer_size 参数(默认为1)来捕捉上下文信息。对于列表中的每一个句子,这种方法会将其与前后各 buffer_size 个句子组合起来,然后对这个临时的、更长的组合文本进行嵌入。这样,每个句子最终得到的嵌入向量就融入了其上下文的语义。
(3)计算语义距离 (Distance Calculation)**:计算每对**相邻句子的嵌入向量之间的余弦距离。这个距离值量化了两个句子之间的语义差异------距离越大,表示语义关联越弱,跳跃越明显。
(4)识别断点 (Breakpoint Identification) :SemanticChunker 会分析所有计算出的距离值,并根据一个统计方法(默认为 percentile)来确定一个动态阈值。例如,它可能会将所有距离中第95百分位的值作为切分阈值。所有距离大于此阈值的点,都被识别为语义上的"断点"。
(5)合并成块 (Merging into Chunks):最后,根据识别出的所有断点位置,将原始的句子序列进行切分,并将每个切分后的部分内的所有句子合并起来,形成一个最终的、语义连贯的文本块。
断点识别方法 (breakpoint_threshold_type)
如何定义"显著的语义跳跃"是语义分块的关键。SemanticChunker 提供了几种基于统计的方法来识别断点:
percentile(百分位法 - 默认方法 ):- 逻辑: 计算所有相邻句子的语义差异值,并将这些差异值进行排序。当一个差异值超过某个百分位阈值时,就认为该差异值是一个断点。
- 参数 :
breakpoint_threshold_amount(默认为95),表示使用第95个百分位作为阈值。这意味着,只有最显著的5%的语义差异点会被选为切分点。
standard_deviation(标准差法):- 逻辑: 计算所有差异值的平均值和标准差。当一个差异值超过"平均值 + N * 标准差"时,被视为异常高的跳跃,即断点。
- 参数 :
breakpoint_threshold_amount(默认为3),表示使用3倍标准差作为阈值。
interquartile(四分位距法):- 逻辑 : 使用统计学中的四分位距(IQR)来识别异常值。当一个差异值超过
Q3 + N * IQR时,被视为断点。 - 参数 :
breakpoint_threshold_amount(默认为1.5),表示使用1.5倍的IQR。
- 逻辑 : 使用统计学中的四分位距(IQR)来识别异常值。当一个差异值超过
gradient(梯度法):- 逻辑: 这是一种更复杂的方法。它首先计算差异值的变化率(梯度),然后对梯度应用百分位法。对于那些句子间语义联系紧密、差异值普遍较低的文本(如法律、医疗文档)特别有效,因为这种方法能更好地捕捉到语义变化的"拐点"。
- 参数 :
breakpoint_threshold_amount(默认为95)。
准备一段模拟文本(故意混合了多个主题)
① 今天天气非常好,适合出去散步。
② 太阳暖洋洋地照在身上,让人感觉很舒服。
③ 春天是一年中最美好的季节。
④ Python 是一种非常流行的编程语言。
⑤ 它的语法简单易懂,适合初学者学习。
⑥ 列表推导式可以让代码更简洁。
⑦ 明天可能会下雨,记得带伞。
⑧ 气温也会有所下降,注意添衣保暖。
肉眼能看到三个语义簇:天气好(①②③) → Python编程(④⑤⑥) → 天气变化(⑦⑧)
第 1 步:句子分割
按句号/问号/感叹号拆成 8 个句子(就是上面的 ①~⑧)。
第 2 步:上下文感知嵌入(最关键的一步)
不是对每个句子独立做嵌入,而是把前后邻居也带上:
buffer_size = 1 # 默认值
句子 实际送去嵌入的文本(自己 + 前后各1句)
① 今天天气非常好...(①)
② 今天天气非常好...(①) + 太阳暖洋洋...(②) + 春天是一年中...(③)
③ 太阳暖洋洋...(②) + 春天是一年中...(③) + Python 是一种...(④)
④ 春天是一年中...(③) + Python 是一种...(④) + 它的语法...(⑤)
⑤ Python 是一种...(④) + 它的语法...(⑤) + 列表推导式...(⑥)
⑥ 它的语法...(⑤) + 列表推导式...(⑥) + 明天可能会...(⑦)
⑦ 列表推导式...(⑥) + 明天可能会...(⑦) + 气温也会...(⑧)
⑧ 明天可能会...(⑦) + 气温也会...(⑧)
为什么这样做? 单看句子③"春天是一年中最好的季节",它和"天气"更相关还是和"编程"更相关?模糊。但如果把前后文(②太阳暖洋洋 + ④Python)一起嵌入,向量就能清晰反映出③更接近②------因为语义上它们同属"天气话题"。
第 3 步:计算相邻句子的语义距离
Embedding 后,计算每对相邻句子的余弦距离:
句① ↔ 句②: 距离 0.12 (语义相近,都在说天气好)
句② ↔ 句③: 距离 0.15 (语义相近)
句③ ↔ 句④: 距离 0.73 ← 语义断点!天气 → 编程 话题切换
句④ ↔ 句⑤: 距离 0.08 (语义相近,都在说Python)
句⑤ ↔ 句⑥: 距离 0.11 (语义相近)
句⑥ ↔ 句⑦: 距离 0.81 ← 语义断点!编程 → 天气 话题切换
句⑦ ↔ 句⑧: 距离 0.09 (语义相近)
距离 = 0 表示语义完全相同,距离越大表示语义差异越大
第 4 步:识别断点(以默认的 percentil 方法为例)
所有距离值排序:0.08, 0.09, 0.11, 0.12, 0.15, 0.73, 0.81
percentile 第 95 百分位的意思是:把距离最大的 5% 的点作为断点。
7 个距离 × 5% ≈ 0.35,取整 => 最大的 1 个值(或按具体实现取阈值线以上的所有值)。
- 0.81 > 阈值 → ✅ 断点(句⑥↔句⑦之间)
- 0.73 --- 取决于具体百分位计算,可能也是断点,也可能不是
两种断点识别方式的对比:
方法 阈值 识别的断点
percentile (95%) 取第95%位的值 只认最极端跳跃(本例可能只切在⑥↔⑦之间,合并③↔④)
standard_deviation (3倍) 平均值 + 3×标准差 只挑异常高的
interquartile (1.5倍) Q3 + 1.5×IQR 更敏感,可能两个都识别
gradient 对距离的梯度做百分位 适合距离值普遍偏低时找"拐点"
第 5 步:合并成块
假设两个断点都被识别,在句③↔④和句⑥↔⑦处切开:
块 1: ① + ② + ③ → "今天天气非常好...春天是一年中最美好的季节。"
块 2: ④ + ⑤ + ⑥ → "Python 是一种...列表推导式可以让代码更简洁。"
块 3: ⑦ + ⑧ → "明天可能会下雨...注意添衣保暖。"
三个块各聚焦一个主题,且块与块之间的语义边界正好对应话题转换点。
python
import os
## os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.document_loaders import TextLoader
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-zh-v1.5",
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
# 初始化 SemanticChunker
text_splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile" # 断点识别方法
)
loader = TextLoader("../../data/C2/txt/蜂医.txt")
documents = loader.load()
docs = text_splitter.split_documents(documents)
3.4 基于文档结构的分块
对于具有明确结构标记的文档格式(如Markdown、HTML、LaTex),可以利用这些标记来实现更智能、更符合逻辑的分割。
针对结构清晰的 Markdown 文档,利用其标题层级进行分块是一种高效且保留了丰富语义的方法。LangChain 提供了 MarkdownHeaderTextSplitter 来处理。
- 实现原理 : 该分块器的主要逻辑是"先按标题分组,再按需细分"。
- 定义分割规则 : 用户首先需要提供一个标题层级的映射关系,例如
[ ("#", "Header 1"), ("##", "Header 2") ],告诉分块器#是一级标题,##是二级标题。 - 内容聚合: 分块器会遍历整个文档,将每个标题下的所有内容(直到下一个同级或更高级别的标题出现前)聚合在一起。每个聚合后的内容块都会被赋予一个包含其完整标题路径的元数据。
- 定义分割规则 : 用户首先需要提供一个标题层级的映射关系,例如
- 元数据注入的优势 : 这是此方法的主要特点。例如,对于一篇关于机器学习的文章,某个段落可能位于"第三章:模型评估"下的"3.2节:评估指标"中。经过分割后,这个段落形成的文本块,其元数据就会是
{"Header 1": "第三章:模型评估", "Header 2": "3.2节:评估指标"}。这种元数据为每个块提供了精确的"地址",极大地增强了上下文的准确性,让大模型能更好地理解信息片段的来源和背景。 - 局限性与组合使用 : 单纯按标题分割可能会导致一个问题:某个章节下的内容可能非常长,远超模型能处理的上下文窗口。为了解决这个问题,
MarkdownHeaderTextSplitter可以与其它分块器(如RecursiveCharacterTextSplitter)组合使用 。具体流程是:- 第一步,使用
MarkdownHeaderTextSplitter将文档按标题分割成若干个大的、带有元数据的逻辑块。 - 第二步,对这些逻辑块再应用
RecursiveCharacterTextSplitter,将其进一步切分为符合chunk_size要求的小块。由于这个过程是在第一步之后进行的,所有最终生成的小块都会继承来自第一步的标题元数据。
- 第一步,使用
- RAG应用优势: 这种两阶段的分块方法,既保留了文档的宏观逻辑结构(通过元数据),又确保了每个块的大小适中,是处理结构化文档进行RAG的理想方案。
四、其他开源框架中的分块策略
4.1 Unstructured:基于文档元素的智能分块
(1)分区 (Partitioning) : 这是一个重要功能,负责将原始文档(如PDF、HTML)解析成一系列结构化的"元素"(Elements)。每个元素都带有语义标签,如 Title (标题)、NarrativeText (叙述文本)、ListItem (列表项) 等。这个过程本身就完成了对文档的深度理解和结构化。
(2)分块 (Chunking) : 该功能建立在分区的结果之上。分块功能不是对纯文本进行操作,而是将分区产生的"元素"列表作为输入,进行智能组合。Unstructured 提供了两种主要的分块方法:
basic: 这是默认方法。这种方法会连续地组合文档元素(如段落、列表项),直到达到max_characters上限,尽可能地填满每个块。如果单个元素超过上限,则会对其进行文本分割。by_title: 该方法在basic方法的基础上,增加了对"章节"的感知。该方法将Title元素视为一个新章节的开始,并强制在此处开始一个新的块,确保同一个块内不会包含来自不同章节的内容。这在处理报告、书籍等结构化文档时非常有用,效果类似于 LangChain 的MarkdownHeaderTextSplitter,但适用范围更广。
Unstructured 允许将分块作为分区的一个参数在单次调用中完成,也支持在分区之后作为一个独立的步骤来执行分块。这种"先理解、后分割"的策略,使得 Unstructured 能在最大程度上保留文档的原始语义结构,特别是在处理版式复杂的文档时,优势尤为明显。
4.2 LlamaIndex:面向节点的解析与转换
LlamaIndex 的分块体系有以下特点:
(1)丰富的节点解析器 (Node Parser): LlamaIndex 提供了大量针对特定数据格式和方法的节点解析器,可以大致分为几类:
-
结构感知型 : 如
MarkdownNodeParser,JSONNodeParser,CodeSplitter等,能理解并根据源文件的结构(如Markdown标题、代码函数)进行切分。 -
语义感知型
:
SemanticSplitterNodeParser: 与 LangChain 的SemanticChunker类似,这种解析器使用嵌入模型来检测句子之间的语义"断点",在语义连续性明显减弱的地方切开,从而让每个 chunk 内部尽量连贯。SentenceWindowNodeParser: 这是一种巧妙的方法。该方法将文档切分成单个的句子,但在每个句子节点(Node)的元数据中,会存储其前后相邻的N个句子(即"窗口")。这使得在检索时,可以先用单个句子的嵌入进行精确匹配,然后将包含上下文"窗口"的完整文本送给LLM,极大地提升了上下文的质量。
-
常规型 : 如
TokenTextSplitter,SentenceSplitter等,提供基于Token数量或句子边界的常规切分方法。
(2)灵活的转换流水线 : 用户可以构建一个灵活的流水线,例如先用 MarkdownNodeParser 按章节切分文档,再对每个章节节点应用 SentenceSplitter 进行更细粒度的句子级切分。每个节点都携带丰富的元数据,记录着其来源和上下文关系。
(3)良好的互操作性 : LlamaIndex 提供了 LangchainNodeParser,可以方便地将任何 LangChain 的 TextSplitter 封装成 LlamaIndex 的节点解析器,无缝集成到其处理流程中。
4.3 ChunkViz:简易的可视化分块工具
在本文开头部分展示的分块图就是通过 ChunkViz 生成的。可以将你的文档、分块配置作为输入,用不同的颜色块展示每个 chunk 的边界和重叠部分,方便快速理解分块逻辑。
巧妙的方法。该方法将文档切分成单个的句子,但在每个句子节点(Node)的元数据中,会存储其前后相邻的N个句子(即"窗口")。这使得在检索时,可以先用单个句子的嵌入进行精确匹配,然后将包含上下文"窗口"的完整文本送给LLM,极大地提升了上下文的质量。
- 常规型 : 如
TokenTextSplitter,SentenceSplitter等,提供基于Token数量或句子边界的常规切分方法。
(2)灵活的转换流水线 : 用户可以构建一个灵活的流水线,例如先用 MarkdownNodeParser 按章节切分文档,再对每个章节节点应用 SentenceSplitter 进行更细粒度的句子级切分。每个节点都携带丰富的元数据,记录着其来源和上下文关系。
(3)良好的互操作性 : LlamaIndex 提供了 LangchainNodeParser,可以方便地将任何 LangChain 的 TextSplitter 封装成 LlamaIndex 的节点解析器,无缝集成到其处理流程中。
4.3 ChunkViz:简易的可视化分块工具
在本文开头部分展示的分块图就是通过 ChunkViz 生成的。可以将你的文档、分块配置作为输入,用不同的颜色块展示每个 chunk 的边界和重叠部分,方便快速理解分块逻辑。