RAG 实战:文本切块(Text Chunking)从入门到精通

RAG 实战:文本切块(Text Chunking)从入门到精通

文本切块是 RAG 系统中最容易被忽视、却对最终效果影响最大的环节之一。本文从 7 个实战脚本出发,带你深入理解从基础字符分割到 AI 驱动的语义分块的完整技术路径。


目录

  • [开篇:为什么切块是 RAG 的"命门"?](#开篇:为什么切块是 RAG 的"命门"?)
  • [一、基础字符分割 --- CharacterTextSplitter](#一、基础字符分割 — CharacterTextSplitter)
  • [二、递归字符分割 --- RecursiveCharacterTextSplitter](#二、递归字符分割 — RecursiveCharacterTextSplitter)
  • 三、分块大小如何影响检索准确性
  • [四、代码分块:语言感知 vs 通用分割](#四、代码分块:语言感知 vs 通用分割)
  • [五、语义分块 --- 让 AI 自己决定在哪里切割](#五、语义分块 — 让 AI 自己决定在哪里切割)
  • [六、辅助工具:PDF 页面精准提取](#六、辅助工具:PDF 页面精准提取)
  • 七、进阶切块策略概览
  • 总结:切块策略全景图与选型指南

开篇:为什么切块是 RAG 的"命门"?

RAG 流程中切块的位置

在 RAG(检索增强生成)的完整流水线中,文本切块处于数据准备阶段的核心环节,衔接上游的"文档加载"和下游的"向量嵌入":

复制代码
文档加载  ──→  文本切块  ──→  向量嵌入  ──→  向量存储  ──→  检索  ──→  生成回答
 (第1步)        (第2步)        (第3步)        (第4步)       (第5步)      (第6步)

切块质量会向下传播影响每一个后续环节:

指标 块太大的后果 块太小的后果
检索准确性 查询命中了块,但块内大部分内容与问题无关 关键信息被切断,检索到的块无法提供完整答案
生成质量 LLM 拿到大段文本,可能被无关信息干扰 LLM 拿到碎片信息,无法理解完整语义
系统效率 向量数量少但体积大,检索和嵌入慢 向量数量爆炸,存储和检索开销剧增

换句话说,切块就是 RAG 的"食材预处理" ------ 食材切得大小不均,再好的厨师也做不出好菜。

切块需要回答的三个核心问题

任何切块策略,本质上都是在回答三个问题:

  1. 在哪里切? (分割点的选择)------ 优先在语义边界?固定位置?还是 AI 判断?
  2. 切多大? (chunk_size 的选择)------ 100 字符?500 token?还是动态大小?
  3. 相邻块要不要重叠? (chunk_overlap 的选择)------ 重叠多少?完全不需要?

本文将通过 7 个实战脚本,逐一回答这三个问题,并带你遍历从最基础的字符分割到最前沿的语义分块的全部策略。

复制代码
固定字符分割  →  递归字符分割  →  句子分割  →  语言感知分割  →  语义分割
  (简单粗暴)      (尊重边界)     (更精细)     (领域特定)       (AI 驱动)

一、基础字符分割 --- CharacterTextSplitter

1.1 它是什么?

CharacterTextSplitter 是 LangChain 提供的最基础的文本分割器。它的逻辑非常直接:用一个指定的分隔符把文本切开,然后按固定大小组装成块

它属于 LangChain 文本分割器的"基类",所有更高级的分割器(递归分割器、代码分割器等)都建立在它的基础之上。

1.2 完整代码

python 复制代码
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import CharacterTextSplitter

# 第一步:加载文档
loader = TextLoader("90-文档-Data/山西文旅/云冈石窟.txt")
documents = loader.load()

# 第二步:创建分割器
text_splitter = CharacterTextSplitter(
    chunk_size=100,       # 每个文本块的最大字符数
    chunk_overlap=10,     # 相邻块之间的重叠字符数
)

# 第三步:执行分割
chunks = text_splitter.split_documents(documents)

# 第四步:查看结果
for i, chunk in enumerate(chunks, 1):
    print(f"\n--- 第 {i} 个文档块 ---")
    print(f"内容: {chunk.page_content}")
    print(f"元数据: {chunk.metadata}")

1.3 内部算法详解

CharacterTextSplitter 的工作流程比表面看起来要复杂一些,核心分为 四步

复制代码
原文档: "A\nB\nC\nD\nE\nF\nG"
separator = "\n",  chunk_size = 10,  chunk_overlap = 3

Step 1: 分割(Split)
  用 separator 将文本切为"片段"(splits)
  → splits = ["A", "B", "C", "D", "E", "F", "G"]

Step 2: 合并(Merge)
  将 splits 逐个合并,直到总长度接近 chunk_size
  → 当前块 = "A\nB\nC"  (长度 ≤ 10)
  → 加入 "D" 后 = "A\nB\nC\nD" (长度 > 10),停止合并
  → 块1 = "A\nB\nC"

Step 3: 重叠处理(Overlap)
  下一块从前一块末尾的 overlap 长度处开始
  → 块1 末尾 3 个字符 = "B\nC"
  → 块2 从 "B\nC" 开始,继续合并新的 splits
  → 块2 = "B\nC\nD\nE"

Step 4: 生成块序列
  → 块1: "A\nB\nC"
  → 块2: "B\nC\nD\nE"
  → 块3: "D\nE\nF\nG"

关键细节

  1. 分隔符会被消耗 :分隔符 \n 在分割时被移除,在合并时被重新插入(作为分隔符使用)
  2. chunk_size 计算包含分隔符 :合并后的总长度(含分隔符)不能超过 chunk_size
  3. overlap 按字符数计算 :从前一块末尾倒取 chunk_overlap 个字符作为下一块的开头

1.4 全部参数详解

python 复制代码
CharacterTextSplitter(
    separator="\n",              # 分隔符,默认换行符
    chunk_size=100,              # 每个块的最大长度
    chunk_overlap=10,            # 相邻块重叠长度
    length_function=len,         # 长度计算函数(默认按字符数)
    is_separator_regex=False,    # separator 是否为正则表达式
    keep_separator=False,        # 是否在分割结果中保留分隔符
    add_start_index=False,       # 是否在元数据中记录每块在原文的起始位置
    strip_whitespace=True,       # 是否去除块首尾空白
)
length_function --- 自定义长度计量

默认使用 Python 内置的 len()(按字符数 计量)。但在实际场景中,你可能需要按 token 数计量:

python 复制代码
import tiktoken

def tiktoken_len(text):
    """使用 tiktoken 计算 OpenAI token 数"""
    tokenizer = tiktoken.encoding_for_model("gpt-3.5-turbo")
    return len(tokenizer.encode(text))

text_splitter = CharacterTextSplitter(
    chunk_size=200,           # 现在是 200 个 token,而非 200 个字符
    chunk_overlap=20,
    length_function=tiktoken_len
)

为什么要按 token 计量? 因为:

  • 中文一个汉字通常占 1-2 个 token,而英文一个单词约 1 个 token
  • 同样 200 个字符,中文文本可能有 200-400 个 token,英文可能只有 40-50 个 token
  • 如果按字符切块后送入 LLM,可能导致某些块超出模型的上下文窗口限制
is_separator_regex --- 正则表达式分隔符

当分隔符不是一个固定字符,而是一类模式时:

python 复制代码
# 用连续两个及以上换行符作为分隔符(过滤单个换行)
text_splitter = CharacterTextSplitter(
    separator=r"\n{2,}",
    is_separator_regex=True,
    chunk_size=500,
)
keep_separator --- 保留分隔符

默认情况下,分隔符在分割时被移除。如果你需要保留原始格式:

python 复制代码
text_splitter = CharacterTextSplitter(
    separator="\n\n",
    keep_separator=True,    # 保留分隔符
)
# 结果中每个块末尾会保留 "\n\n"
add_start_index --- 记录原文位置

在元数据中记录每个块在原始文档中的起始字符位置,便于溯源:

python 复制代码
text_splitter = CharacterTextSplitter(
    add_start_index=True,
)
# chunk.metadata 中会多一个 "start_index" 字段
# 例如 {"source": "xxx.txt", "start_index": 256}

1.5 运行效果

假设云冈石窟文档内容为:

复制代码
云冈石窟位于山西省大同市城西约16公里的武州山南麓。
石窟依山开凿,规模宏大,是中国最大的石窟群之一。
现有主要洞窟45个,大小窟龛252个,石雕造像51000余躯。

使用 chunk_size=100, chunk_overlap=10,可能被切为:

复制代码
块1: "云冈石窟位于山西省大同市城西约16公里的武州山南麓。\n石窟依山开凿,规模宏大,是中国最大的石窟群之一。\n"
块2: "之一。\n现有主要洞窟45个,大小窟龛252个,石雕造像51000余躯。\n"   ← 注意 "之一。" 是重叠部分

1.6 优缺点

  • 最简单:三行代码即可完成
  • 最快:纯字符串操作,无需调用任何模型
  • 完全确定性:相同输入永远产生相同输出,便于调试
  • 一刀切:只认一个分隔符,可能在句子/段落中间硬切
  • 不理解语义:无法区分"话题转换"和"同一句话里的换行"
  • 对分隔符依赖大:如果文档中没有你指定的分隔符,所有内容会变成一个巨大的块

1.7 常见陷阱

陷阱 1:分隔符不存在

python 复制代码
# 如果文档中没有 "\n\n",整个文档会变成一个块
CharacterTextSplitter(separator="\n\n", chunk_size=500)
# → 输出只有 1 个块(整篇文档)!

陷阱 2:中文标点 vs 英文标点

python 复制代码
# 这两个是不同的字符!
"."   # 英文句号 (U+002E)
"。"   # 中文句号 (U+3002)

# 如果中文文档用英文句号做分隔符,等于没有分隔
CharacterTextSplitter(separator=".")  # ❌ 对中文无效
CharacterTextSplitter(separator="。")  # ✅ 正确

陷阱 3:chunk_overlap 设置不当

python 复制代码
# overlap 不能大于 chunk_size 的一半,否则会产生大量冗余
CharacterTextSplitter(chunk_size=100, chunk_overlap=80)  # ❌ 80% 重叠,冗余严重
CharacterTextSplitter(chunk_size=100, chunk_overlap=10)  # ✅ 10% 重叠,合理

1.8 适用场景

快速原型验证、对切块精度要求不高的场景、纯格式化文本(如日志、CSV 导出文本)、格式规范的 Markdown 文档(用 \n\n 分段)


二、递归字符分割 --- RecursiveCharacterTextSplitter

2.1 它是什么?

如果说 CharacterTextSplitter 是一把菜刀,那 RecursiveCharacterTextSplitter 就是一套手术刀 ------ 它不满足于用一个分隔符硬切,而是准备了一个分隔符优先级列表,依次尝试,直到找到最合适的切割点。

这是 LangChain 官方推荐用于通用文本的分割器,也是大多数 RAG 项目的默认选择。

2.2 完整代码

python 复制代码
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 加载文档
loader = TextLoader("90-文档-Data/山西文旅/云冈石窟.txt")
documents = loader.load()

# 定义分隔符优先级列表
separators = ["\n\n", ".", ",", " "]   # 段落 → 句号 → 逗号 → 空格

# 创建递归分割器
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=10,
    separators=separators
)

chunks = text_splitter.split_documents(documents)

2.3 递归算法源码级剖析

RecursiveCharacterTextSplitter 继承自 CharacterTextSplitter,核心是重写了 _split_text 方法。其递归逻辑如下:

python 复制代码
# 伪代码展示核心递归逻辑
def _split_text(self, text, separators):
    """递归分割文本"""

    # 获取当前级别的分隔符
    current_separator = separators[0]
    # 剩余的分隔符(用于下一轮递归)
    next_separators = separators[1:]

    # Step 1: 用当前分隔符将文本切成片段
    splits = text.split(current_separator)

    # Step 2: 将片段合并为不超过 chunk_size 的块
    good_splits = []  # 已经合并好的片段列表
    final_chunks = [] # 最终输出的块列表

    for split in splits:
        if length_function(split) < chunk_size:
            good_splits.append(split)
        else:
            # 这个片段太大了!

            # 先把之前积攒的好片段合并成块
            if good_splits:
                merged = merge_splits(good_splits, current_separator)
                final_chunks.extend(merged)
                good_splits = []

            # Step 3: 递归!用下一个分隔符切这个超长片段
            if next_separators:
                # 还有更细的分隔符可用
                recursive_chunks = _split_text(split, next_separators)
                final_chunks.extend(recursive_chunks)
            else:
                # 没有更多分隔符了,只能硬切
                final_chunks.append(split)

    # 处理剩余的好片段
    if good_splits:
        merged = merge_splits(good_splits, current_separator)
        final_chunks.extend(merged)

    return final_chunks

关键洞察 :递归分割器并不是对整篇文档一次性用所有分隔符切割,而是对超长片段逐级降级 ------ 只有当某个片段在当前分隔符下仍然超过 chunk_size 时,才会用下一个更细粒度的分隔符递归处理它。

2.4 递归过程实例

以一段云冈石窟文本为例,完整追踪递归过程:

复制代码
原文(约 250 字符):
"云冈石窟位于山西省大同市。\n\n石窟依山开凿,规模宏大。现有洞窟45个。
\n\n石雕造像51000余躯,代表了北魏雕刻艺术的最高水平。"

配置:chunk_size=100, separators=["\n\n", "。", ",", " "]

第一轮递归 (separator = "\n\n"):

复制代码
Step 1: 按 "\n\n" 分割为 3 个片段
  片段A: "云冈石窟位于山西省大同市。"           (15 字符) ✅ < 100
  片段B: "石窟依山开凿,规模宏大。现有洞窟45个。" (18 字符) ✅ < 100
  片段C: "石雕造像51000余躯,代表了北魏雕刻艺术的最高水平。" (25 字符) ✅ < 100

结果:所有片段都小于 chunk_size,无需继续递归!

合并为块(考虑 overlap):
  块1: "云冈石窟位于山西省大同市。\n\n石窟依山开凿,规模宏大。现有洞窟45个。"
  块2: "石窟依山开凿,规模宏大。现有洞窟45个。\n\n石雕造像51000余躯,..."

但如果把 chunk_size 改为 10,就会触发更深层递归:

复制代码
第一轮(separator = "\n\n"):
  片段A (15 字符) > 10 → 递归!
  片段B (18 字符) > 10 → 递归!
  片段C (25 字符) > 10 → 递归!

第二轮(separator = "。"):
  处理片段A: "云冈石窟位于山西省大同市" (10 字符) ✅ < 10 → 合并为块
  ...

2.5 默认分隔符列表

如果不指定 separators 参数,LangChain 使用以下默认分隔符:

python 复制代码
# RecursiveCharacterTextSplitter 的默认分隔符
DEFAULT_SEPARATORS = ["\n\n", "\n", " ", ""]

# "\n\n" → 段落分隔(最粗粒度)
# "\n"   → 行分隔
# " "    → 空格(词级别)
# ""     → 按字符切割(最细粒度,兜底)

这些默认值是为英文设计的,对中文文本效果不佳。中文文本需要自定义分隔符列表。

2.6 中文文本的特殊考量与推荐配置

对于中文文本,分隔符列表的设计尤为重要。以下是三种推荐配置:

配置 A:标准中文文档(推荐)
python 复制代码
separators = [
    "\n\n",    # 段落分隔(最高优先级)
    "。",      # 中文句号
    "!",      # 中文感叹号
    "?",      # 中文问号
    ";",      # 中文分号
    ",",      # 中文逗号
    " ",       # 空格
    "",        # 兜底:按字符切割
]
配置 B:中英混合文档
python 复制代码
separators = [
    "\n\n",    # 段落
    "。",      # 中文句号
    ".",       # 英文句号
    "!",      # 中文感叹号
    "?",       # 英文问号
    ";",      # 中文分号
    ",",       # 英文逗号
    ",",      # 中文逗号
    " ",       # 空格
    "",
]
配置 C:技术文档(含 Markdown)
python 复制代码
separators = [
    "\n## ",   # 二级标题
    "\n### ",  # 三级标题
    "\n#### ", # 四级标题
    "\n\n",    # 段落
    "\n- ",    # 列表项
    "。",      # 中文句号
    ".",       # 英文句号
    ",",      # 中文逗号
    " ",       # 空格
    "",
]

⚠️ 注意 :中文句号 和英文句号 . 是不同的字符。如果处理中文文档时使用英文句号作为分隔符,将无法正确切分句子。

2.7 高级参数

python 复制代码
RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", " ", ""],
    chunk_size=500,
    chunk_overlap=50,
    length_function=len,
    keep_separator=False,         # 是否在分割结果中保留分隔符
    add_start_index=False,
    strip_whitespace=True,
    is_separator_regex=False,
)
keep_separator --- 对递归分割器的影响

在递归分割器中,keep_separator 有更微妙的作用:

python 复制代码
# keep_separator=False(默认)
# 分隔符被移除,合并时用分隔符重新连接
# 块1: "第一段内容\n\n第二段内容"

# keep_separator=True
# 分隔符被保留在分割结果中
# 块1: "第一段内容\n\n第二段内容\n\n"
# 注意末尾多了一个 "\n\n"

建议 :除非你需要精确还原原文格式,否则保持默认 False 即可。

2.8 与基础分割的对比

以同一段云冈石窟文本为例:

分割器 切割方式 结果
CharacterTextSplitter(separator="\n") \n 硬切 可能在一个长段落内硬切
RecursiveCharacterTextSplitter(separators=["\n\n",".",","," "]) 依次尝试多个分隔符 优先保持段落和句子完整

2.9 优缺点

  • 更智能:多级分隔符,尊重文本自然结构
  • 高度可定制:分隔符列表完全由用户定义
  • 通用性强:适合大多数文本类型
  • LangChain 官方推荐的通用分割器
  • 仍保持确定性:相同输入相同输出
  • 仍是规则驱动:不理解"这段话在讲另一个话题了"
  • 分隔符设计需要经验:不同语言/文档类型需要不同配置
  • 无法处理"无标点长句":如果一个句子没有标点且超过 chunk_size,只能在空格处切

2.10 适用场景

大多数 RAG 项目的默认选择 。在不确定用哪种策略时,先用 RecursiveCharacterTextSplitter 配合合理的分隔符列表,通常就能获得不错的效果。适合:通用文本、新闻文章、技术文档、Wiki 百科、产品说明等。


三、分块大小如何影响检索准确性

3.1 这是一个什么实验?

前两种策略关注的是"在哪里切 ",而这个实验关注的是"切多大 "。chunk_size 是 RAG 系统中最关键的超参数之一 ------ 它直接决定了检索的精度和召回率。

3.2 完整代码

python 复制代码
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import VectorStoreIndex, Settings
from llama_index.readers.file import PDFReader
from llama_index.core.node_parser import SentenceSplitter
from dotenv import load_dotenv

load_dotenv()

# 配置模型
embed_model = OpenAIEmbedding(model="text-embedding-3-small")
llm = OpenAI(model="gpt-3.5-turbo-0125")
Settings.embed_model = embed_model
Settings.llm = llm

# ⭐ 关键参数:chunk_size 决定切块大小
Settings.node_parser = SentenceSplitter(
    chunk_size=250,       # 🔬 试试改成 50 或 100,对比效果!
    chunk_overlap=20
)

# 加载 PDF 并构建索引
loader = PDFReader()
documents = loader.load_data(
    file="90-文档-Data/复杂PDF/uber_10q_march_2022_page26.pdf"
)
index = VectorStoreIndex.from_documents(documents)

# 查询
query_engine = index.as_query_engine(similarity_top_k=3, verbose=True)
response = query_engine.query("how much is the Loss from operations for 2022?")
print(response)

# 查看检索到的具体块
for i, source_node in enumerate(response.source_nodes):
    print(f"\nChunk {i+1}:")
    print(source_node.text)

3.3 SentenceSplitter 内部机制

LlamaIndex 的 SentenceSplitter 与 LangChain 的 CharacterTextSplitter 有一个根本区别:它按句子边界分割,而不是按字符/分隔符分割

其内部流程为:

复制代码
原文
  │
  ▼
① 分句(基于正则 / NLTK / spaCy)
  → 将文本拆分为独立句子列表
  │
  ▼
② 按 chunk_size 合并句子
  → 将连续的句子合并,直到接近 chunk_size
  → 保证不会在句子中间截断
  │
  ▼
③ 处理超长句子
  → 如果单个句子超过 chunk_size
  → 按段落 → 按词 → 按字符 逐级降级
  │
  ▼
④ 添加 overlap
  → 相邻块共享若干句子

核心优势SentenceSplitter 的最小分割单位是"句子"而非"字符",因此不会出现一句话被切成两半的情况。

按字符 vs 按 Token 计量

LlamaIndex 的 SentenceSplitter 默认使用 token 计量(而非字符计量),这对于 LLM 应用来说更加合理:

python 复制代码
SentenceSplitter(
    chunk_size=250,            # 250 个 token(不是 250 个字符)
    chunk_overlap=20,          # 20 个 token 的重叠
    separator=" ",             # 合并时的分隔符
    paragraph_separator="\n\n\n",  # 段落分隔符
    chunking_token_fn=None,    # 自定义 token 计算函数
)

为什么用 token 更好?

计量方式 中文 100 字 英文 100 字 问题
字符数 100 字符 100 字符 同样 100 字符,中英文信息量差异巨大
Token 数 ~100-200 token ~25-50 token 更准确地反映 LLM 的"理解单位"

3.4 实验设计

该脚本使用 Uber 2022 年第一季度 10-Q 财报作为数据源,查询一个具体的财务数据问题:"2022 年的运营亏损是多少?"

通过修改 chunk_size 参数,我们可以观察不同切块大小对检索结果的影响:

复制代码
chunk_size = 50    ──→  得到什么结果?
chunk_size = 100   ──→  得到什么结果?
chunk_size = 250   ──→  得到什么结果?

3.5 三种 chunk_size 的效果分析

🔹 chunk_size = 50(极小块)
复制代码
检索到的块可能像这样:

块1: "Loss from operations"
块2: "for the three months"
块3: "ended March 31, 2022"

问题

  • 关键信息被切碎,"Loss from operations" 和具体数值被分到了不同的块
  • LLM 看到的是碎片,无法理解完整含义
  • 可能检索到很多"看起来相关但实际无用"的块

优点

  • 检索精确度较高(向量化粒度细)
  • 适合定位到精确的关键词/短语
  • 每个块的向量非常聚焦,语义表达清晰

何时使用:精确查找(如"找到包含 'Loss from operations' 这个词组的所有段落")

🔹 chunk_size = 100(中间块)
复制代码
检索到的块可能像这样:

块1: "Loss from operations for the three months ended March 31, 2022 was $XXX"
块2: "Revenue increased by XX% compared to the same period..."

优点

  • 在精确度和上下文之间取得较好平衡
  • 大多数查询场景的"甜点区"
  • 单个块通常包含完整的语义单元

何时使用:大多数通用 RAG 场景的起点

🔹 chunk_size = 250(较大块)
复制代码
检索到的块可能包含完整段落或多个表格项

问题

  • 一个块里可能混合了多个不同话题
  • "Loss from operations" 的数据可能被淹没在大量无关信息中
  • 向量化时,块的向量是整块内容的"平均",可能偏离查询意图

优点

  • 上下文最完整,不会丢失跨块信息
  • 减少向量数量,检索速度更快

何时使用:需要完整段落的场景(如文档摘要、长文本问答)

3.6 chunk_size 选择的底层逻辑

为什么 chunk_size 影响这么大?核心在于向量嵌入的本质

复制代码
文本块 ──Embedding Model──→ 向量(高维空间中的一个点)
  • 大块:向量是整段文本的"语义平均",可能模糊了其中的关键细节
  • 小块:向量更聚焦,但可能丢失上下文关联

用一个比喻:chunk_size 就像相机镜头的焦距 ------

  • 广角镜头(大 chunk_size):拍到更多场景,但细节模糊
  • 长焦镜头(小 chunk_size):细节清晰,但视野狭窄
  • 关键是找到适合当前场景的焦距
向量空间中的可视化
复制代码
高维向量空间(简化为 2D)

        查询向量
           ● "Loss from operations 2022?"
          ╱
         ╱  ← 相似度
        ╱
  ●块1(小) ── "Loss from operations was $613M"
  │
  │
  ●块2(大) ── "Revenue was $XX, Loss from operations was $613M,
  │              R&D expenses were $YY, Marketing was $ZZ..."
  │
  ●块3(小) ── "operational efficiency improved..."

块1 距离查询最近 → 小块向量更聚焦
块2 距离查询较远 → 大块向量被其他信息"稀释"了
块3 距离查询较远 → 虽然包含 "operational",但语义不同

3.7 chunk_overlap 的作用与最佳实践

chunk_overlap 是防止关键信息恰好落在切割点被截断的"安全网":

复制代码
无 overlap:
  块1: [第一段完整内容]
  块2: [第二段完整内容]
  ↑ 如果关键信息恰好在段1末尾+段2开头,检索可能遗漏

有 overlap:
  块1: [============重叠============]
  块2:                 [=====重叠=====]
  块3:                              [============重叠============]
  ↑ 重叠区域确保信息不会在切割点丢失

overlap 设置的经验法则

场景 推荐 overlap 原因
通用文本 chunk_size × 10-15% 平衡冗余和完整性
法律/医疗文档 chunk_size × 20-25% 信息密度高,切断代价大
代码 chunk_size × 0-5% 代码通常不需要 overlap(语言感知分割已保证结构完整)
新闻/博客 chunk_size × 5-10% 信息密度低,小 overlap 足够

overlap 的代价

  • 存储开销增加:如果 overlap=20%,则有效存储容量增加约 20%
  • 检索时可能返回重复内容:相邻块在重叠区域完全相同
  • 向量化开销增加:重叠部分被重复计算 Embedding

3.8 chunk_size 调优方法论

不要凭感觉选 chunk_size,用以下方法科学调优:

复制代码
Step 1: 准备 10-20 个代表性查询(覆盖不同类型)
Step 2: 准备标准答案(人工标注)
Step 3: 设定 chunk_size 候选值:[100, 200, 300, 500, 800]
Step 4: 对每个 chunk_size 运行所有查询
Step 5: 评估指标:
  - 检索命中率(Hit Rate):正确答案出现在 top-k 中的比例
  - 平均倒数排名(MRR):正确答案排名的倒数均值
  - 答案质量评分:LLM 生成答案与标准答案的相似度
Step 6: 选择综合表现最好的 chunk_size

3.9 适用场景

任何 RAG 项目都需要进行 chunk_size 调优实验。建议用一组代表性查询在不同 chunk_size 下对比检索结果,找到最适合你数据的参数组合。


四、代码分块:语言感知 vs 通用分割

4.1 为什么代码需要特殊的分块策略?

代码和自然语言有着根本的区别:

维度 自然语言 代码
结构 段落、句子、词语 类、函数、控制流
分隔符 \n\n classdef、缩进
完整性 一句话通常自包含 一个函数可能依赖上下文
引用关系 隐式(语义关联) 显式(import、继承、调用)
缩进 仅格式化作用 定义代码块(Python 强制)

如果用通用的文本分割器处理代码,很可能出现这样的灾难:

python 复制代码
# ❌ 通用分割器可能在函数中间截断
块1:
    def _execute_attack(self):
        if self.stamina >= self.attack_patterns["SPECIAL"]:
            damage = 50
            self.stamina -=

块2:
            self.attack_patterns["SPECIAL"]
            return damage
        return self.attack_patterns["NORMAL"]

这样的块对检索和生成都是无用的 ------ 函数被腰斩,上下文断裂,缩进丢失。

4.2 语言感知分块:完整代码

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"]

class InventorySystem:
   def __init__(self):
       self.items = {}
       self.capacity = 20
       self.gold = 0
   def add_item(self, item_id, quantity):
       if len(self.items) < self.capacity:
           if item_id in self.items:
               self.items[item_id] += quantity
           else:
               self.items[item_id] = quantity
   def remove_item(self, item_id, quantity):
       if item_id in self.items:
           self.items[item_id] -= quantity
           if self.items[item_id] <= 0:
               del self.items[item_id]

class QuestSystem:
   def __init__(self):
       self.active_quests = {}
       self.completed_quests = set()
       self.quest_log = []
   def add_quest(self, quest_id, quest_data):
       if quest_id not in self.active_quests:
           self.active_quests[quest_id] = quest_data
           self.quest_log.append(f"Started quest: {quest_data['name']}")
   def complete_quest(self, quest_id):
       if quest_id in self.active_quests:
           self.completed_quests.add(quest_id)
           del self.active_quests[quest_id]
"""

# ⭐ 关键:指定语言为 Python
python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1000,
    chunk_overlap=0
)

python_docs = python_splitter.create_documents([GAME_CODE])

4.3 通用分块:对照组代码

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

# ❌ 不指定语言,使用默认文本分隔符
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=0,
    # 使用默认分隔符:["\n\n", "\n", " ", ""]
)

text_chunks = text_splitter.create_documents([GAME_CODE])

4.4 语言感知分割器的秘密武器

当调用 from_language(Language.PYTHON) 时,LangChain 内部会自动使用一组 Python 专用的分隔符

python 复制代码
# Language.PYTHON 的默认分隔符(按优先级)
[
    "\nclass ",      # 类定义
    "\ndef ",        # 顶层函数定义
    "\n\tdef ",      # 类方法定义(1 级缩进)
    "\n    def ",    # 类方法定义(4 空格缩进)
    "\n\n",          # 空行
    "\n",            # 换行
    " ",             # 空格
    ""               # 兜底:按字符
]

这意味着 Python 分割器会:

  1. 优先在 class 之间切割 → 每个类尽量完整
  2. 如果类太大,def 之间切割 → 每个方法尽量完整
  3. 如果方法太大,\n\n 空行处切割
  4. 依次降级......保证代码结构不被轻易破坏

4.5 效果对比

对同样的游戏代码(三个类,总计约 1500 字符),两种分割器的效果:

语言感知版(✅ 结构完整):

复制代码
块1: class CombatSystem: (完整的战斗系统类)
      包含 __init__, update, _update_stats, _handle_combat, _execute_attack

块2: class InventorySystem: (完整的背包系统类)
      包含 __init__, add_item, remove_item

块3: class QuestSystem: (完整的任务系统类)
      包含 __init__, add_quest, complete_quest, get_active_quests

通用版(❌ 可能断裂):

复制代码
块1: class CombatSystem: ... def _execute_attack(self): ... (包含 CombatSystem + 部分 InventorySystem)
      两个类混在一起!

块2: ... self.items[item_id] = quantity ... (InventorySystem 方法的后半部分 + QuestSystem 的开头)
      代码结构被完全破坏!

4.6 各语言分隔符详解

LangChain 的 Language 枚举支持多种主流语言,每种语言都有精心设计的分隔符:

Python
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON)
# ["\nclass ", "\ndef ", "\n\tdef ", "\n\n", "\n", " ", ""]
JavaScript / TypeScript
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.JS)
# ["\nfunction ", "\nconst ", "\nlet ", "\nvar ", "\nclass ", "\nif ", "\nfor ",
#  "\nwhile ", "\nswitch ", "\ncase ", "\ndefault ", "\n\n", "\n", " ", ""]
Java
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.JAVA)
# ["\npublic class ", "\nprivate class ", "\nprotected class ",
#  "\npublic void ", "\nprivate void ", "\nprotected void ",
#  "\n\n", "\n", " ", ""]
Go
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.GO)
# ["\nfunc ", "\nvar ", "\nconst ", "\ntype ", "\nimport ",
#  "\n\n", "\n", " ", ""]
Markdown
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.MARKDOWN)
# ["\n## ", "\n### ", "\n#### ", "\n##### ", "\n###### ",
#  "```\n", "\n\n", "\n", " ", ""]

Markdown 分割器的特色:优先在标题级别切割,保证每个块对应一个完整的章节。

HTML
python 复制代码
separators = RecursiveCharacterTextSplitter.get_separators_for_language(Language.HTML)
# ["<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>",
#  "<div>", "</div>", "<p>", "</p>", "<li>", "</li>",
#  "<tr>", "</tr>", "\n\n", "\n", " ", ""]

4.7 完整支持的语言列表

语言 枚举值 切割粒度
Python Language.PYTHON class → def → 方法
JavaScript Language.JS function → const/let/var → class
TypeScript Language.TS 同 JS + type/interface
Java Language.JAVA class → method(按修饰符)
C# Language.CSHARP namespace → class → method
Go Language.GO func → var/const/type
Rust Language.RUST fn → impl → struct/enum
Ruby Language.RUBY class → def → module
PHP Language.PHP class → function
Swift Language.SWIFT class → func → struct/enum
Kotlin Language.KOTLIN class → fun → object
Scala Language.SCALA class → def → object/trait
Markdown Language.MARKDOWN h1 → h2 → h3 → 段落
HTML Language.HTML h1 → div → p → li
LaTeX Language.LATEX \section → \subsection → \paragraph
SQL Language.SQL CREATE → SELECT → INSERT → UPDATE

4.8 代码分块的最佳实践

  1. chunk_size 要够大 :代码的完整结构通常比自然语言段落长,建议 chunk_size >= 1000(或按 token 计量 500+)
  2. overlap 可以设为 0:语言感知分割器已经在结构边界切割,overlap 通常不必要
  3. 考虑添加元数据:记录每个块属于哪个文件、哪个类、哪个函数
python 复制代码
python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1500,      # 代码建议用更大的 chunk_size
    chunk_overlap=0,       # 语言感知分割器不需要 overlap
)

4.9 适用场景

任何涉及代码检索的 RAG 应用:代码搜索引擎、AI 代码助手(如 GitHub Copilot 的后端)、技术文档问答、代码审查辅助工具、代码库知识图谱构建。


五、语义分块 --- 让 AI 自己决定在哪里切割

5.1 前面几种方法的根本局限

无论是基础分割、递归分割还是语言感知分割,它们都有一个共同的局限:它们都是基于"规则"的。它们不知道:

  • 这段话和下一段话是不是在讲同一个话题
  • 这里换行了,但话题并没有转换
  • 这里没有换行,但话题已经完全不同了

举个具体例子:

复制代码
"张三毕业于清华大学计算机系,在校期间成绩优异。"  ← 话题:张三的学历
"他后来加入了字节跳动,担任高级工程师。"          ← 话题:张三的工作(话题转换!)
"字节跳动成立于2012年,总部位于北京。"            ← 话题:字节跳动公司信息(又一次转换!)
"公司旗下产品包括抖音、TikTok等。"               ← 话题:字节跳动产品(同一话题的延续)

规则分割器看到的只是"三句话,用句号分隔"。它不知道第 1→2 句和第 2→3 句是两次话题转换,而第 3→4 句是同一话题的延续。

语义分块(Semantic Chunking)就是为了解决这个问题而生的 ------ 它利用 Embedding 模型理解文本的语义连续性,在话题转换的地方自然地切割。

5.2 完整代码

python 复制代码
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import (
    SentenceSplitter,
    SemanticSplitterNodeParser,
)
from llama_index.embeddings.openai import OpenAIEmbedding

# 加载文档
documents = SimpleDirectoryReader(
    input_files=["90-文档-Data/黑悟空/黑悟空wiki.txt"]
).load_data()

# ⭐ 创建语义分块器
splitter = SemanticSplitterNodeParser(
    buffer_size=3,                       # 缓冲区大小
    breakpoint_percentile_threshold=90,   # 断点百分位阈值
    embed_model=OpenAIEmbedding()         # 嵌入模型
)

# 对照组:基础句子分块器
base_splitter = SentenceSplitter()

# 语义分块
semantic_nodes = splitter.get_nodes_from_documents(documents)

# 基础分块(对照)
base_nodes = base_splitter.get_nodes_from_documents(documents)

print(f"语义分块器生成的块数:{len(semantic_nodes)}")
print(f"基础句子分块器生成的块数:{len(base_nodes)}")

5.3 算法详解:五步流程

语义分块的核心算法可以拆解为以下五个步骤:

复制代码
原始文档
  │
  ▼
① 分句:将文档拆分为独立句子
  S1, S2, S3, S4, S5, S6, S7, S8, S9, S10, ...
  │
  ▼
② 分组(buffer_size=3):将句子分为滑动窗口组
  组A: [S1, S2, S3]
  组B: [S2, S3, S4]      ← 注意:与组A有重叠
  组C: [S3, S4, S5]
  ...
  │
  ▼
③ Embedding:将每个组编码为向量
  Vec_A = Embed("S1 S2 S3")
  Vec_B = Embed("S2 S3 S4")
  Vec_C = Embed("S3 S4 S5")
  ...
  │
  ▼
④ 计算相邻组的余弦相似度:
  sim(A,B) = cosine(Vec_A, Vec_B) = 0.92  ← 高相似度
  sim(B,C) = cosine(Vec_B, Vec_C) = 0.88  ← 较高
  sim(C,D) = cosine(Vec_C, Vec_D) = 0.45  ← 低!话题转换
  sim(D,E) = cosine(Vec_D, Vec_E) = 0.91  ← 恢复高
  ...
  │
  ▼
⑤ 确定断点:
  收集所有相似度: [0.92, 0.88, 0.45, 0.91, 0.87, 0.35, 0.93]
  排序: [0.35, 0.45, 0.87, 0.88, 0.91, 0.92, 0.93]
  第 (100-90)% = 10% 分位数的值 → 阈值 ≈ 0.40

  低于阈值的位置即为断点:
  sim(C,D) = 0.45 → 不是断点(0.45 > 0.40)
  sim(F,G) = 0.35 → 是断点!(0.35 < 0.40)

  在断点处切割
余弦相似度详解

余弦相似度衡量两个向量在方向上的接近程度,取值范围 -1, 1

复制代码
cosine_similarity(A, B) = (A · B) / (|A| × |B|)

结果解读:
  1.0  → 完全相同的方向(语义完全相同)
  0.8  → 高度相似(同一话题的延续)
  0.5  → 有一定相似性(相关但不同的话题)
  0.0  → 正交(完全无关)
  -1.0 → 完全相反的方向(实际中很少出现)

语义分块中:
  相似度高 (>0.8) → 同一话题,不切割
  相似度低 (<阈值) → 话题转换,在此切割

用一个直观的例子来理解(黑悟空 Wiki):

复制代码
相似度曲线:
1.0 ┤
    │ ●────●         ●───●
0.8 ┤      \        /     \
    │       \      /       ●───●
0.6 ┤        \    /
    │         ●──●
0.4 ┤
    │
0.2 ┤
    └──┬────┬────┬────┬────┬────┬──→ 句子位置
      游戏  角色  战斗  地图  剧情  开发
      介绍  设定  系统  设计  故事  历史

      |← 同话题 →|    |← 同话题 →|    |← 同话题 →|
          不切割            不切割          不切割

话题转换处(相似度骤降)→ 切割!

5.4 两个核心参数深入理解

buffer_size --- 语义评估的"视野"

buffer_size 决定了多少个句子组成一个"评估窗口":

python 复制代码
buffer_size = 1  # 窄视野:每个句子单独评估
  评估: S1 vs S2, S2 vs S3, S3 vs S4 ...
  → 粒度细,对单句变化敏感
  → 但可能因一个"过渡句"而误判
  → 生成更多分割点

buffer_size = 3  # 宽视野:每 3 个句子一组评估(推荐)
  评估: [S1,S2,S3] vs [S4,S5,S6], [S2,S3,S4] vs [S5,S6,S7] ...
  → 粒度适中,更稳健
  → 不会因为一个过渡句而误判
  → 但可能忽略微妙的话题转换

buffer_size = 5  # 更宽视野
  评估: [S1,S2,S3,S4,S5] vs [S6,S7,S8,S9,S10] ...
  → 粒度粗,需要显著的话题转换才会触发分割
  → 适合超长文档(减少 Embedding 调用次数)

buffer_size 与 Embedding 调用次数的关系

复制代码
假设文档有 N 个句子

buffer_size = 1: 约 N-1 次相邻比较 → N-1 次 Embedding 调用
buffer_size = 3: 约 N-1 次滑动窗口比较 → N-3+1 次 Embedding 调用
buffer_size = 5: 约 N-5+1 次 Embedding 调用

注意:SemanticSplitterNodeParser 会对每个窗口调用 Embedding,
所以 buffer_size 不影响总 Embedding 调用次数(都是 N 次),
但影响分割的稳健性。
breakpoint_percentile_threshold --- 分割的"敏感度"

这个参数通过百分位数来自适应确定"多低算低":

python 复制代码
# 假设所有相邻组的相似度为:
similarities = [0.92, 0.88, 0.85, 0.45, 0.91, 0.87, 0.82, 0.35, 0.93]

# 排序后:
sorted_sim = [0.35, 0.45, 0.82, 0.85, 0.87, 0.88, 0.91, 0.92, 0.93]

threshold = 80  # 取第 20 百分位
# → 阈值 ≈ 0.63
# → 0.35 和 0.45 都低于 0.63 → 2 个断点

threshold = 90  # 取第 10 百分位
# → 阈值 ≈ 0.40
# → 仅 0.35 低于 0.40 → 1 个断点

threshold = 95  # 取第 5 百分位
# → 阈值 ≈ 0.35
# → 没有低于 0.35 的 → 0 个断点(不分块!)

为什么用百分位数而非固定阈值?

因为不同文档的相似度分布差异巨大:

  • 主题集中的文档:相似度普遍在 0.8-0.95 → 固定阈值 0.5 会导致不分割
  • 主题分散的文档:相似度普遍在 0.3-0.8 → 固定阈值 0.5 会导致过度分割
  • 百分位数自适应文档特点,始终只切割最不相似的 top-X% 处

5.5 Embedding 模型的选择

语义分块的效果很大程度上取决于 Embedding 模型的质量。常见选择:

模型 维度 特点 适用场景
text-embedding-3-small 1536 OpenAI 性价比最高 通用场景(推荐)
text-embedding-3-large 3072 精度最高,成本也高 高精度需求
text-embedding-ada-002 1536 上一代,较便宜 预算有限
BAAI/bge-small-zh 512 中文优化,可本地运行 中文场景 + 数据隐私
BAAI/bge-large-zh 1024 中文优化,精度更高 中文高精度
python 复制代码
# 使用 OpenAI Embedding(云端)
from llama_index.embeddings.openai import OpenAIEmbedding
embed_model = OpenAIEmbedding()

# 使用本地 HuggingFace 模型(免费但需要 GPU)
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh")

5.6 参数组合速查表

buffer_size threshold 生成块数 适用场景
1 75 很多 极致细分,高密度技术文档
1 85 精细分块,学术论文
1 95 粗粒度,低密度文档
3 75 中等偏多 细分但稳健
3 85 中等 信息密度适中的通用文档
3 90 中等偏少 通用推荐
3 95 长文档,大段落
3 98 很少 宽松配置,仅切割最明显的转换
5 90 超长文档(减少计算)
5 95 很少 大段落文档

5.7 计算成本分析

语义分块的最大代价是 Embedding API 调用成本。以下是一个粗略估算:

复制代码
假设:
  文档:10,000 个句子
  Embedding 模型:text-embedding-3-small
  定价:$0.02 / 1M tokens
  每句平均:20 tokens

计算:
  总 Embedding 调用次数 ≈ 10,000 次(每句/每组一次)
  总 token 数 ≈ 10,000 × 20 = 200,000 tokens
  总成本 ≈ $0.004(不到 1 分钱)

时间估算:
  OpenAI API:约 100ms/请求 → 10,000 × 100ms ≈ 17 分钟
  使用批处理或并发可以缩短到 2-3 分钟

结论 :对于大多数文档,语义分块的 Embedding 成本可以忽略不计,但时间成本可能成为瓶颈(大型文档需要几分钟)。

5.8 与基础分块的对比

维度 基础句子分割 语义分割
分割依据 句子边界(规则) 语义连续性(AI)
话题感知 ❌ 无 ✅ 自动识别话题转换
块大小 相对均匀 可能差异很大(话题长短不同)
计算成本 几乎为零 需要对每句话调用 Embedding
处理速度 毫秒级 分钟级(取决于文档大小)
可解释性 高(规则明确) 较低(依赖模型判断)
确定性 高(相同输入相同输出) 中(Embedding 可能有微小差异)
适合的文档 结构化文档 非结构化、多话题文档

5.9 语义分块的局限性

  1. 依赖 Embedding 质量:如果 Embedding 模型对中文支持不好,语义判断会出错
  2. 块大小不可控:一个话题可能跨越很长的文本,导致单个块过大
  3. "微妙转换"可能被忽略:buffer_size 较大时,轻微的话题转换可能被平滑掉
  4. 不适合结构化文档:对于已经有清晰标题/段落的文档,规则分割可能更精确
  5. 处理时间较长:大型文档需要数分钟

5.10 适用场景

高精度 RAG 场景:多话题混合文档(如 Wiki、长篇报告)、长文档问答、知识库构建、对话系统中对检索精度要求极高的场景。在计算成本可接受的前提下,语义分块通常能提供最佳的检索效果。


六、辅助工具:PDF 页面精准提取

6.1 为什么需要这个工具?

在 RAG 实践中,我们经常需要从大型 PDF(如百页的财报)中提取特定页面进行测试或分析。直接处理整个 PDF 不仅浪费时间,还可能因为内容过多而影响检索精度。

6.2 完整代码

python 复制代码
from pathlib import Path
from pypdf import PdfReader, PdfWriter

def extract_pages(pdf_path, output_path, page_numbers):
    """
    提取指定页码的 PDF 页面并保存为新的 PDF 文件

    参数:
        pdf_path: 原始 PDF 文件路径
        output_path: 输出 PDF 文件路径
        page_numbers: 要提取的页码列表(1-based,即人类可读的页码)
    """
    try:
        # 确保输出目录存在
        output_dir = Path(output_path).parent
        output_dir.mkdir(parents=True, exist_ok=True)

        # 打开原始 PDF
        reader = PdfReader(pdf_path)
        writer = PdfWriter()

        # 提取指定页码
        for page_number in page_numbers:
            if 1 <= page_number <= len(reader.pages):
                writer.add_page(reader.pages[page_number - 1])  # 1-based → 0-based
            else:
                print(f"警告:页码 {page_number} 超出范围,PDF 共有 {len(reader.pages)} 页")

        # 保存
        with open(output_path, 'wb') as output_file:
            writer.write(output_file)
        print(f"成功提取页面 {page_numbers} 到 {output_path}")

    except Exception as e:
        print(f"处理 PDF 时发生错误: {str(e)}")
        raise

# 使用示例:从 Uber 10-Q 财报中提取第 26-28 页
extract_pages(
    pdf_path="90-文档-Data/复杂PDF/uber_10q_march_2022.pdf",
    output_path="90-文档-Data/复杂PDF/uber_10q_march_2022_page1-3.pdf",
    page_numbers=[26, 27, 28]
)

6.3 代码要点

  1. 页码转换 :用户使用 1-based 页码(符合人类直觉),代码内部转为 0-based(page_number - 1
  2. 越界保护 :检查页码是否在有效范围内,避免 IndexError
  3. 目录自动创建mkdir(parents=True, exist_ok=True) 确保输出路径的父目录存在
  4. 异常处理 :捕获并报告错误,同时通过 raise 向上传播

6.4 在 RAG 流程中的位置

复制代码
原始 PDF (100+ 页)
  │
  ▼  [99-工具-PDF-切割.py]
目标页面 PDF (3 页)
  │
  ▼  [03_LlamaIndex-分块大小影响准确性.py]
文本切块 → 向量化 → 索引 → 查询

6.5 扩展用法

python 复制代码
# 提取不连续的页面
extract_pages("report.pdf", "extract.pdf", [1, 5, 12, 30])

# 提取整个章节(假设第 3 章在第 45-67 页)
extract_pages("report.pdf", "chapter3.pdf", list(range(45, 68)))

# 提取目录页
extract_pages("report.pdf", "toc.pdf", list(range(1, 6)))

七、进阶切块策略概览

除了上述 5 种核心策略,业界还有多种进阶切块方法。这些方法在本文的代码文件中没有实现,但在实际项目中经常使用,值得了解。

7.1 父-子分块(Parent-Child Chunking)

核心思想:将文档切分为"父块"和"子块"两层。检索时在子块(小块)上匹配以提高精度,返回时使用对应的父块(大块)以提供完整上下文。

复制代码
原文档
  │
  ▼
父块(大块,chunk_size=1000)
  │
  ├── 子块 A1(小块,chunk_size=200)
  ├── 子块 A2
  └── 子块 A3

检索流程:
  查询 → 匹配到子块 A2 → 返回父块 A(完整上下文)

优势:兼顾检索精度(小块)和上下文完整性(大块)

7.2 文档结构感知分块(Structure-Aware Chunking)

核心思想:解析文档的结构(标题层级、表格、列表等),按结构边界切块。

复制代码
Markdown 文档:

# 第一章                ← 切割点
## 1.1 概述            ← 切割点
正文内容...
## 1.2 方法            ← 切割点
正文内容...

按标题层级切块:
  块1: "# 第一章\n## 1.1 概述\n正文内容..."
  块2: "## 1.2 方法\n正文内容..."

优势:保持文档的逻辑结构,块的内容在语义上高度自洽

7.3 滑动窗口分块(Sliding Window)

核心思想:以固定步长在文档上滑动窗口,每个窗口就是一个块。

复制代码
chunk_size = 100, step = 50

原文: [0-------------------200-------------------400]

块1: [0--------100]
块2:     [50--------150]     ← 与块1有 50% 重叠
块3:         [100--------200]
块4:             [150--------250]

优势 :简单直观,保证任何信息都会出现在至少一个块的"中心位置"

劣势:块数量多,冗余大

7.4 Agentic 分块(Agentic Chunking)

核心思想:使用 LLM 判断每个段落/句子应该归属于哪个"命题",然后按命题组合块。

复制代码
LLM 判断:
  "这个段落讨论了三个命题:价格、质量、服务"
  → 将段落拆分为三个独立的命题
  → 每个命题单独成块

优势 :最精细的语义分块

劣势:LLM 调用成本极高,处理速度极慢

7.5 多粒度分块(Multi-Granularity)

核心思想:同时使用多种粒度切块,检索时根据查询类型选择合适的粒度。

复制代码
同一文档同时切块为:
  粗粒度(chunk_size=1000):用于需要完整上下文的查询
  中粒度(chunk_size=300):用于大多数通用查询
  细粒度(chunk_size=100):用于精确查找

查询时:
  "总结这段内容" → 使用粗粒度块
  "XXX 的定义是什么" → 使用细粒度块

7.6 策略选择建议

场景 推荐策略 原因
通用 RAG 递归字符分割 性价比最高,开箱即用
高精度 RAG 语义分块 自动识别话题转换
代码 RAG 语言感知分割 必须保持代码结构
长文档 RAG 父-子分块 兼顾精度和上下文
结构化文档 结构感知分块 利用文档自身的层次
极致精度 Agentic 分块 成本高但效果最好

总结:切块策略全景图与选型指南

策略演进全景

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        文本切块策略演进                              │
├─────────────┬─────────────┬─────────────┬─────────────┬────────────┤
│  字符分割   │  递归分割   │  句子分割   │  语言感知   │  语义分割  │
│  (01-文件)  │  (02-文件)  │  (03-文件)  │  (04-文件)  │  (05-文件) │
├─────────────┼─────────────┼─────────────┼─────────────┼────────────┤
│  单一分隔符 │  多级分隔符 │  句子边界   │  代码结构   │  AI 语义   │
│  固定大小   │  优先级列表 │  可调大小   │  类/函数级  │  话题感知  │
│  最简单     │  最通用     │  最平衡     │  最专业     │  最智能    │
└─────────────┴─────────────┴─────────────┴─────────────┴────────────┘
      ▲                                                            ▲
      │                                                            │
   成本最低                                                    效果最好

选型决策树

复制代码
你需要切什么?
│
├─ 代码 → Language-Aware Splitting(04-文件)
│          使用 RecursiveCharacterTextSplitter.from_language()
│
├─ 结构化文档(Markdown/HTML)
│   └─ Language.MARKDOWN 或 Language.HTML
│
├─ 通用文本
│   │
│   ├─ 精度要求高? → 语义分块(05-文件)
│   │                  SemanticSplitterNodeParser
│   │                  buffer_size=3, threshold=90
│   │
│   ├─ 精度要求一般? → 递归字符分割(02-文件)
│   │                    RecursiveCharacterTextSplitter
│   │                    + 自定义分隔符列表
│   │
│   └─ 快速原型? → 基础字符分割(01-文件)
│                    CharacterTextSplitter
│
└─ 需要兼顾精度和上下文? → 父-子分块(见进阶策略)

超参数调优清单

参数 推荐值范围 调优方法
chunk_size 200 ~ 1000 准备一组测试查询,对比不同值下的检索命中率
chunk_overlap chunk_size × 10% ~ 20% 从 10% 开始,逐步增大直到重叠部分不再带来收益
separators 根据语言定制 中文必须包含 ;英文包含 .,
buffer_size 1 ~ 5 信息密度高用 1,话题跨度大用 3-5
breakpoint_percentile_threshold 80 ~ 98 从 90 开始,块太多就增大,块太少就减小

涉及的核心库一览

核心组件 用途
langchain_text_splitters CharacterTextSplitter 基础字符分割
langchain_text_splitters RecursiveCharacterTextSplitter 递归字符分割 + 语言感知
langchain_text_splitters Language 编程语言枚举(20+ 种语言)
langchain_community TextLoader 文本文件加载
llama_index.core SentenceSplitter 句子级分割(按 token 计量)
llama_index.core SemanticSplitterNodeParser 语义分块
llama_index.core VectorStoreIndex 向量索引构建
llama_index.embeddings.openai OpenAIEmbedding OpenAI 嵌入模型
llama_index.llms.openai OpenAI OpenAI LLM
pypdf PdfReader / PdfWriter PDF 读写操作

十条实战经验

  1. 先跑通再优化 --- 先用 RecursiveCharacterTextSplitter 配默认参数跑通整个流程,再回来调切块
  2. chunk_size 比你想的更重要 --- 花 30 分钟做对比实验,比花 3 天调其他参数更有价值
  3. 中文文档的分隔符一定要自己配 --- 默认分隔符是为英文设计的,中文句号 和英文句号 . 是不同的字符
  4. 代码必须用语言感知分块 --- 这是没有商量余地的,通用分块对代码来说是灾难
  5. overlap 是保险,不是越多越好 --- 太大的 overlap 会显著增加存储和计算开销
  6. 语义分块是锦上添花,不是雪中送炭 --- 如果基础分块的效果就很差,语义分块也不会有质变
  7. 数据源质量 > 切块策略 --- 垃圾进,垃圾出。再好的切块策略也无法挽救质量低劣的原始文档
  8. 考虑按 token 而非字符计量 --- 特别是使用 LLM 生成回答时,token 计量更准确地反映上下文消耗
  9. 不要忘记元数据 --- 记录每个块的来源文件、页码、位置等信息,便于溯源和调试
  10. 切块策略需要跟着业务走 --- 不同业务场景的最优策略不同,不存在"一招鲜吃遍天"
相关推荐
多年小白1 小时前
【周末消息】2026年5月30日-6月1日
大数据·人工智能·深度学习·机器学习·金融
AI导出鸭PC端1 小时前
智谱清言清除符号:当LLM输出遭遇“结构性失序”,一份关于AI导出鸭的工程化测评
人工智能
Engineer邓祥浩1 小时前
宏观认知(3):AI战略与社会影响——吴恩达《AI for Everyone》Week3学习笔记
人工智能·笔记·学习
weixin_468466851 小时前
图像连通域分析新手实战指南
图像处理·人工智能·深度学习·ai·机器视觉·连通域
狒狒热知识2 小时前
中小企业品牌破局之道178软文网以轻量化传播助力软文营销从零到一
人工智能
J2虾虾2 小时前
Spring AI Alibaba - Models 模型
人工智能·spring·microsoft
万俟淋曦2 小时前
【论文速递】2026年第01周(Dec-28-Jan-03)(Robotics/Embodied AI/LLM)
人工智能·ai·机器人·大模型·论文·robotics·具身智能
不务正业的小主治2 小时前
ezygene-多种算法计算免疫评分
人工智能·r语言·简析基因·ezygene·免疫分析