RAG学习-文本分块
技术原理
主要分为检索、生成两个阶段
(1)检索阶段:寻找“非参数化知识”
- 知识向量化:嵌入模型(Embedding Model) 充当了“连接器”的角色。它将外部知识库编码为向量索引(Index),存入向量数据库。
- 语义召回:当用户发起查询时,检索模块利用同样的嵌入模型将问题向量化,并通过相似度搜索(Similarity Search),从海量数据中精准锁定与问题最相关的文档片段。
(2)生成阶段:融合两种知识
- 上下文整合:生成模块接收检索阶段送来的相关文档片段以及用户的原始问题。
- 指令引导生成:该模块会遵循预设的 Prompt 指令,将上下文与问题有效整合,并引导 LLM(如 DeepSeek)进行可控的、有理有据的文本生成。

技术演进
大致分为原生RAG、高级RAG、模块化RAG三个阶段。

具体对比如下表:
| 初级 RAG(Naive RAG) | 高级 RAG(Advanced RAG) | 模块化 RAG(Modular RAG) | |
|---|---|---|---|
| 流程 | 离线: 索引 在线: 检索 → 生成 |
离线: 索引 在线: ...→ 检索前 → ... → 检索后 → ... |
积木式可编排流程 |
| 特点 | 基础线性流程 | 增加检索前后的优化步骤 | 模块化、可组合、可动态调整 |
| 关键技术 | 基础向量检索 | 查询重写(Query Rewrite) 结果重排(Rerank) | 动态路由(Routing) 查询转换(Query Transformation) 多路融合(Fusion) |
| 局限性 | 效果不稳定,难以优化 | 流程相对固定,优化点有限 | 系统复杂性高 |
使用原因
在选择具体的技术路径时,一个重要的考量是成本与效益的平衡。通常,我们应优先选择对模型改动最小、成本最低的方案,所以技术选型路径往往遵循的顺序是提示词工程(Prompt Engineering) -> 检索增强生成 -> 微调(Fine-tuning)。
我们可以从两个维度来理解这些技术的区别。如图 1-3 所示,横轴代表“LLM 优化”,即对模型本身进行多大程度的修改。从左到右,优化的程度越来越深,其中提示工程和 RAG 完全不改变模型权重,而微调则直接修改模型参数。纵轴代表“上下文优化”,是对输入给模型的信息进行多大程度的增强。从下到上,增强的程度越来越高,其中提示工程只是优化提问方式,而 RAG 则通过引入外部知识库,极大地丰富了上下文信息。
RAG 的出现填补了通用模型与专业领域之间的鸿沟,它在解决如表 1-2 所示 LLM 局限时尤其有效:
| 问题 | RAG的解决方案 |
|---|---|
| 静态知识局限 | 实时检索外部知识库,支持动态更新 |
| 幻觉(Hallucination) | 基于检索内容生成,错误率降低 |
| 领域专业性不足 | 引入领域特定知识库(如医疗/法律) |
| 数据隐私风险 | 本地化部署知识库,避免敏感数据泄露 |
文本分块
重要性
满足模型上下文限制
将文本分块的首要原因,是为了适应 RAG 系统中两个核心组件的硬性限制:
- 嵌入模型 (Embedding Model): 负责将文本块转换为向量。这类模型有严格的输入长度上限。例如,许多常用的嵌入模型(如
bge-base-zh-v1.5)的上下文窗口为512个token。任何超出此限制的文本块在输入时都会被截断,导致信息丢失,生成的向量也无法完整代表原文的语义。因此,文本块的大小必须小于等于嵌入模型的上下文窗口。 - 大语言模型 (LLM): 负责根据检索到的上下文生成答案。LLM同样有上下文窗口限制(尽管通常比嵌入模型大得多,从几千到上百万token不等)。检索到的所有文本块,连同用户问题和提示词,都必须能被放入这个窗口中。如果单个块过大,可能会导致只能容纳少数几个相关的块,限制了LLM回答问题时可参考的信息广度。
因此,分块是确保文本能够被两个模型完整、有效处理的基础。
为何“块”不是越大越好
假设嵌入模型最多能处理 8192 个 token,是否应该把块切得尽可能大(比如8000个token)呢?答案是否定的。块的大小并非越大越好,过大的块会严重影响RAG系统的性能。
嵌入过程中的信息损失
大多数嵌入模型都基于 Transformer 编码器。其工作流程大致如下:
- 分词 (Tokenization): 将输入的文本块分解成一个个 token。
- 向量化 (Vectorization): Transformer 为每个 token 生成一个高维向量表示。
- 池化 (Pooling): 通过某种方法(如取
[CLS]位的向量、对所有token向量求平均mean pooling等),将所有 token 的向量压缩成一个单一的向量,这个向量代表了整个文本块的语义。
[CLS]是BERT等Transformer模型在输入文本开头添加的特殊标记,它通过自注意力机制动态聚合整个序列的上下文信息,其最终向量被训练用作代表全局语义的嵌入。
在这个压缩过程中,信息损失是不可避免的。一个768维的向量需要概括整个文本块的所有信息。文本块越长,包含的语义点越多,这个单一向量所承载的信息就越稀释,导致其表示变得笼统,关键细节被模糊化,从而降低了检索的精度。
生成过程的“大海捞针” (Lost in the Middle)
即使将检索到的多个大块文本都塞进LLM的长上下文窗口中,也会出现关键信息被“淹没”在大量无关内容里的问题。有研究表明 1,当LLM处理非常长的、充满大量信息的上下文时,它倾向于更好地记住开头和结尾的信息,而忽略中间部分的内容。
如果提供给LLM的上下文块又大又杂,充满了与问题无关的噪音,模型就很难从中提取出最关键的信息来形成答案,从而导致回答质量下降或产生幻觉。
主题稀释导致检索失败
一个好的文本块应该聚焦于一个明确、单一的主题。如果一个块包含太多不相关的主题,它的语义就会被稀释,导致在检索时无法被精确匹配。
假设有一个关于《王者荣耀》英雄鲁班七号的攻略文档。
- 糟糕的分块策略:将“技能介绍”、“推荐出装”和“背景故事”这三个完全不同主题的内容,全部放在一个巨大的文本块里。
- 当玩家查询“鲁班七号怎么出装?”时,这个大块虽然包含了出装信息,但由于被技能说明和英雄故事等无关主题严重稀释,其整体的检索相关性得分可能会很低,导致无法被召回。
- 优秀的分块策略:将“技能”、“出装”和“故事”分别切分为三个独立的、主题聚焦的块。
- 当玩家再次查询时,“推荐出装”这个块会因为与查询高度相关而获得极高的分数,从而被精准地检索出来。
通过合理分块,可以有效提升检索的信噪比,确保了后续生成环节能得到最优质、最相关的上下文。
基础分块策略
LangChain 提供了丰富且易于使用的文本分割器(Text Splitters),下面将介绍几种最核心的策略。
固定大小分块、递归字符分块、基于语义的分块、基于文档结构的分块
固定大小分块
这是最简单直接的分块方法。根据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 的实现更准确地应该称为”段落感知的自适应分块”,块大小会根据段落边界动态调整。
递归字符分块
现在让我们深入了解 RecursiveCharacterTextSplitter 的实现。这种分块器通过分隔符层级递归处理,相对与固定大小分块,改善了超长文本的处理效果。
算法流程:
(1)寻找有效分隔符: 从分隔符列表中从前到后遍历,找到第一个在当前文本中存在的分隔符。如果都不存在,使用最后一个分隔符(通常是空字符串 "")。
(2)切分与分类处理: 使用选定的分隔符切分文本,然后遍历所有片段:
- 如果片段不超过块大小: 暂存到
_good_splits中,准备合并 - 如果片段超过块大小:
- 首先,将暂存的合格片段通过
_merge_splits合并成块 - 然后,检查是否还有剩余分隔符:
- 有剩余分隔符: 递归调用
_split_text继续分割 - 无剩余分隔符: 直接保留为超长块
- 有剩余分隔符: 递归调用
- 首先,将暂存的合格片段通过
(3)最终处理: 将剩余的暂存片段合并成最后的块
实现细节:
- 批处理机制: 先收集所有合格片段(
_good_splits),遇到超长片段时才触发合并操作。 - 递归终止条件: 关键在于
if not new_separators判断。当分隔符用尽时(new_separators为空),停止递归,直接保留超长片段。确保算法不会无限递归。
与固定大小分块的关键差异:
- 固定大小分块遇到超长段落时只能发出警告并保留。
- 递归分块会继续使用更细粒度的分隔符(句子→单词→字符)直到满足大小要求。
分隔符配置:
- 默认分隔符:
["\n\n", "\n", " ", ""] - 多语言支持:对于无词边界语言(中文、日文、泰文),可添加:
编程语言特化支持:
RecursiveCharacterTextSplitter 能够针对特定的编程语言(如Python, Java等)使用预设的、更符合代码结构的分隔符。它们通常包含语言的顶级语法结构(如类、函数定义)和次级结构(如控制流语句),以实现更符合代码逻辑的分割。
语义分块
语义分块(Semantic Chunking)是一种更智能的方法,这种方法不依赖于固定的字符数或预设的分隔符,而是尝试根据文本的语义内涵来切分。其核心是:在语义主题发生显著变化的地方进行切分。这使得每个分块都具有高度的内部语义一致性。LangChain 提供了 langchain_experimental.text_splitter.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)。
基于文档结构的分块
对于具有明确结构标记的文档格式(如Markdown、HTML、LaTex),可以利用这些标记来实现更智能、更符合逻辑的分割。
以Markdown 结构分块为例
针对结构清晰的 Markdown 文档,利用其标题层级进行分块是一种高效且保留了丰富语义的方法。LangChain 提供了 MarkdownHeaderTextSplitter 来处理。
- 实现原理: 该分块器的主要逻辑是“先按标题分组,再按需细分”。
- 定义分割规则: 用户首先需要提供一个标题层级的映射关系,例如
[ ("#", "Header 1"), ("##", "Header 2") ],告诉分块器#是一级标题,##是二级标题。 - 内容聚合: 分块器会遍历整个文档,将每个标题下的所有内容(直到下一个同级或更高级别的标题出现前)聚合在一起。每个聚合后的内容块都会被赋予一个包含其完整标题路径的元数据。
- 定义分割规则: 用户首先需要提供一个标题层级的映射关系,例如
- 元数据注入的优势: 这是此方法的主要特点。例如,对于一篇关于机器学习的文章,某个段落可能位于“第三章:模型评估”下的“3.2节:评估指标”中。经过分割后,这个段落形成的文本块,其元数据就会是
{"Header 1": "第三章:模型评估", "Header 2": "3.2节:评估指标"}。这种元数据为每个块提供了精确的“地址”,极大地增强了上下文的准确性,让大模型能更好地理解信息片段的来源和背景。 - 局限性与组合使用: 单纯按标题分割可能会导致一个问题:某个章节下的内容可能非常长,远超模型能处理的上下文窗口。为了解决这个问题,
MarkdownHeaderTextSplitter可以与其它分块器(如RecursiveCharacterTextSplitter)组合使用。具体流程是:- 第一步,使用
MarkdownHeaderTextSplitter将文档按标题分割成若干个大的、带有元数据的逻辑块。 - 第二步,对这些逻辑块再应用
RecursiveCharacterTextSplitter,将其进一步切分为符合chunk_size要求的小块。由于这个过程是在第一步之后进行的,所有最终生成的小块都会继承来自第一步的标题元数据。
- 第一步,使用
- RAG应用优势: 这种两阶段的分块方法,既保留了文档的宏观逻辑结构(通过元数据),又确保了每个块的大小适中,是处理结构化文档进行RAG的理想方案。
