文本分割
✂️1. CharacterTextSplitter 和 RecursiveCharacterTextSplitter 的区别是什么?¶
这两个是 LangChain 最基础也是最常用的文本分割器,理解它们的差异是做好后续检索的第一步。
CharacterTextSplitter
它的逻辑非常简单:只使用单一一个分隔符(默认是两个换行 \n\n)来切分文本。它会先按这个分隔符把文本拆成很多小段,然后尝试把这些小段合并成长度不超过 chunk_size 的块。如果合并后仍然有单段长度超过 chunk_size,它就会直接按字符数硬截断。
特点就是:快,简单,但不够聪明。适合已经分段很好的文本,比如本身就按段落分开的纯文本,或者你明确知道只有一个分隔符的场景。
RecursiveCharacterTextSplitter
它是 CharacterTextSplitter 的升级版,最大的区别在于使用了一组分隔符,而不是一个。它会从一个“最粗粒度”的分隔符开始尝试切分,如果切出来的块还是太长,就换一个“更细粒度”的分隔符继续切,层层递归,直到所有的块都不超过 chunk_size,或者连字符都拆开也无法满足时才硬截断。
可以理解为:CharacterTextSplitter 是一刀切,RecursiveCharacterTextSplitter 是一套组合刀,从大刀换到小刀,最大限度地保持了语义边界。
核心区别总结:
我选择的实践经验:除非我处理的文本非常规整(例如数据库导出的一行一条记录),否则一律用 RecursiveCharacterTextSplitter。因为它能在不增加多少复杂度的情况下,明显减少“把一个词拦腰斩断”的惨案,而后者对向量的语义表示伤害很大。
🔄 2. RecursiveCharacterTextSplitter 的“递归”体现在哪里?它按什么顺序尝试分割符?¶
“递归”这个词用在这里非常贴切,它指的并不是函数自己调用自己,而是不断地用更细粒度的分隔符去“降级”处理那些仍然太长的段落,就像剥洋葱一样一层一层往里切。
递归过程具体是这样的:
-
拿到一整段文本。
-
用分隔符列表里的第一个(最粗的)分隔符,比如
"\n\n"(段落分隔),把文本拆成片段。 -
遍历这些片段,尝试将它们合并成不超过
chunk_size的块。 -
对于合并后仍然超长的那个片段(可能是一段特别长的段落),Splitter 不会直接硬砍,而是递归地换用下一个分隔符,比如
"\n"(换行),对这个超长片段再次拆分。 -
如果还有超长片段,继续换下一个,比如
" "(空格),再不行就""(空字符串,即逐字符切分)。 -
直到所有片段都小于
chunk_size,或者用完所有分隔符后才被迫硬截断。
默认的分隔符顺序(非常重要,值得背下来):
这个顺序是精心设计的:
-
先尽量按段落切(
\n\n),保持段落完整性。 -
段落还是太长就按行切(
\n)。 -
行还是太长就按单词切(空格),至少不把单词中间断开。
-
最后才逐字符切,这是绝望情况下的保底策略。
对于中文文本,这个默认分隔符列表不太合适,因为中文没有空格分词。我通常会自定义分隔符列表,比如 ["\n\n", "\n", "。", ",", " ", ""],把中文标点加进去,让模型尽可能在句号、逗号处断开,保持语义完整。这是一个很容易被忽视但效果提升明显的调优点。
📏 3. 写出 RecursiveCharacterTextSplitter 的常用参数:chunk_size, chunk_overlap,并解释它们的作用。¶
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", "。", ",", " ", ""],
length_function=len,
keep_separator=True,
)
下面重点解释最核心的两个:
chunk_size
-
作用:规定每个文本块(chunk)的最大长度。注意是“最大”,不是“固定”。Splitter 会尽量填满到这个值但不超。
-
度量方式:默认按字符数,但你可以通过
length_function换成 token 计数函数(例如用tiktoken),这样就能精确控制送给 LLM 的 token 数,避免超出上下文窗口。 -
选择策略:一般取 embedding 模型的 max input length 的 50%–80%,给 overlap 和 prompt 留出空间。常用值在 256–1024 token 之间。太小会丢失上下文,太大则检索精度下降。
chunk_overlap
-
作用:相邻两个 chunk 之间共享的内容长度。比如 overlap=200,则 chunk2 的开头 200 个字符就是 chunk1 结尾的 200 个字符。
-
目的:防止关键信息恰好落在两个 chunk 的边界上,导致任何一个 chunk 都无法单独回答相关问题。重叠相当于给每个切分点加了“缓冲带”,让跨边界的实体或语义得以完整保留在一个 chunk 内。
-
典型设置:通常取
chunk_size的 10%–20%。太小效果不明显,太大则会产生过多冗余,加重索引和检索负担。
其他重要参数:
-
separators:刚才说的分隔符列表,中文务必加上中文标点。 -
keep_separator:是否在 chunk 中保留分隔符。默认保留,有助于 LLM 理解结构。 -
length_function:如果你拿不准 token 数,用tiktoken包一层精准计数,比字符数更贴近模型限制。 -
is_separator_regex:允许将分隔符写成正则表达式,处理更复杂的分割逻辑。
这些参数组合直接决定了 RAG 系统的“天花板”——切得好,检索才能找到对的东西;切得烂,后面再牛的模型也是巧妇难为无米之炊。
⚠️ 4. chunk_overlap 设置为 0 会有什么后果?为什么通常需要一些重叠?¶
如果 chunk_overlap=0,Splitter 会把文本一刀刀切下去,两个相邻 chunk 之间没有任何交集,就像把一本书切成互不相连的纸条。这在实践中会带来两个严重的问题:
① 信息断裂,丢失上下文
很多重要的语义单元(人名、术语、长句子、跨句的逻辑关系)会刚好落在两个 chunk 的交界处。比如原文是:
“...因此我们决定采用 Transformer 架构。该架构的主要优势在于自注意力机制...”
如果切分点正好在 “Transformer” 和 “架构” 之间,chunk1 结尾是“因此我们决定采用 Transformer”,chunk2 开头是“架构。该架构的主要优势在于...”。当用户搜索“Transformer 架构的优势”时,两个 chunk 各有一部分信息,却都不完整,很可能检索到的相关性都不高,导致 RAG 回答缺失关键论据。
② 增加“边界效应”,降低鲁棒性
没有重叠时,每个 chunk 就像一个孤岛,对跨块的引用关系毫无感知。实际检索中,对于需要一两句上下文才能准确理解的问题(例如指代消解,“它”、“该模型”),无重叠切分会让模型看到的文本缺少前因后果,容易产生歧义。
为什么需要重叠:
重叠就是在每个切分点“搭一座桥”。chunk1 的末尾部分被复制到 chunk2 的开头,这样任何一个概念只要完整出现在原文的某个位置,就至少有一个 chunk 会包含它的全部上下文,而不会被切分点劈成两半。换句话说,重叠提高了“任何重要信息被完整包含在至少一个 chunk 中”的概率。
当然,重叠不是越大越好。chunk_overlap 太大会导致:
-
大量的重复内容被存储和 embedding,浪费存储空间和计算资源。
-
检索时可能返回语义几乎相同的多个 chunk,降低多样性。
一般 10%–20% 的 chunk_overlap 是经验上的甜点,既能有效防止边界断裂,又不至于过度冗余。也有更智能的做法:使用 SemanticChunker 或类似工具直接识别语义边界,那样甚至可以做到零重叠而不损失信息,但我们后面会谈到。
💻 5. 对于代码文件,应该用哪种 TextSplitter?LangChain 提供了哪些针对语言的 Splitter?¶
代码和自然语言最大的不同在于:代码的缩进、括号、关键字本身就有很强的结构信号,随意按换行或空格切分极易破坏语法结构,导致模型难以理解。因此 LangChain 提供了一系列语言感知(language-aware)的分割器,它们继承自 RecursiveCharacterTextSplitter,但使用的分隔符列表是针对每种编程语言精心设计的,能优先在函数定义、类定义、注释等“高语义”边界处切分。
LangChain 提供的语言专用 Splitter(都在 langchain.text_splitter 下):
使用示例:
from langchain.text_splitter import PythonCodeTextSplitter
splitter = PythonCodeTextSplitter(chunk_size=500, chunk_overlap=50)
docs = splitter.create_documents([code_text])
底层原理:
它们本质上是 RecursiveCharacterTextSplitter 的子类,只不过预定义了适合该语言的分隔符优先级。比如 Python 的分隔符大致是:
它会在 class 和 def 关键字前切分,尽量不把函数体或类定义拦腰截断。同样,Markdown 分割器会优先按标题(#、##)分割。
我的使用建议:
-
对于代码检索(如内部代码库问答),务必使用对应的语言分割器。效果比通用分割器好很多。
-
如果语言没有现成的 Splitter,可以参考已有的,自定义
separators列表,比如加入\nfunction、\nprocedure等关键词。 -
代码文档(如 Markdown 写的教程)用
MarkdownTextSplitter或RecursiveCharacterTextSplitter+ 标题级分隔符。 -
注意代码块可能包含大量空格和缩进,
chunk_size用 token 计数更准确,避免因为缩进导致 token 超限。
📑 6. 如果你想按照 Markdown 标题层级进行分割(例如将每个 ## 标题及其下属内容作为一个块),该怎么做?¶
这是一个非常实用的需求:把 Markdown 文档按照其本身的层级结构拆分,每个块带有所属的标题上下文,这样检索时就不会丢失“这段话属于哪个章节”的信息。LangChain 提供了专门的工具:MarkdownHeaderTextSplitter。
它可以根据你指定的标题层级(如 #, ##, ###)对 Markdown 文本进行拆分,并自动将标题链作为 metadata 注入到每一个 chunk 中。
from langchain.text_splitter import MarkdownHeaderTextSplitter
markdown_text = """
# 第一章:引言
这是引言内容...
## 1.1 背景
背景介绍段落...
## 1.2 目标
目标描述段落...
# 第二章:方法
方法内容...
"""
headers_to_split_on = [
("#", "header_1"),
("##", "header_2"),
("###", "header_3"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
docs = splitter.split_text(markdown_text)
for doc in docs:
print(doc.metadata)
print(doc.page_content[:100])
print("---")
输出(示意):
{'header_1': '第一章:引言', 'header_2': '1.1 背景'}
这是引言内容... 背景介绍段落...
---
{'header_1': '第一章:引言', 'header_2': '1.2 目标'}
目标描述段落...
---
{'header_1': '第二章:方法'}
方法内容...
---
工作原理:
MarkdownHeaderTextSplitter 会扫描 Markdown 源码,根据指定的标题符号识别文档的层级结构树,然后按照“叶节点”将内容切分成块,同时将当前块从根到叶的所有标题组合成一个 metadata 字典。这意味着每个块都知道自己完整的章节路径。
与 RecursiveCharacterTextSplitter 配合:
通常单独用标题分割出来的块可能还是太长(比如一个 ## 下面有几万字),这时可以先按标题粗切,再对每个块用 RecursiveCharacterTextSplitter 进行二次细切。但二次切分时要注意保持 metadata 继承。可以使用 split_documents 方法:
header_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
header_docs = header_splitter.split_text(markdown_text)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
final_docs = text_splitter.split_documents(header_docs)
# 每个子文档会继承父文档的 header metadata
实践中的坑:
-
MarkdownHeaderTextSplitter要求 Markdown 格式非常规范,标题必须在一行开头。如果文档里用#只是普通注释,会被误判,需要提前清洗。 -
如果 Markdown 来自网络抓取,可能混入了 HTML 或其他标记,建议先用
markdownify等工具清理。 -
对于特别大的 Markdown(比如整本电子书),层级会非常深,要小心 metadata 字典膨胀和检索过滤时的效率。
总之,这种“带结构的分割”比无脑等长切分在知识库问答中能显著提升引用准确率和回答上下文的连贯性,是高级 RAG 的标配技巧。
🧠 7. 什么是 SemanticChunker?它是如何利用 Embedding 判断语义边界的?¶
SemanticChunker 是一种基于语义相似度寻找切分点的文本分割器,它是 LangChain 实验性模块 langchain_experimental.text_splitter 中提供的较新工具。与固定长度或固定字符切分不同,它试图在“语义发生明显转变”的地方下刀,生成更符合人类理解逻辑的 chunk。
核心原理:
-
逐句拆分:首先将文本按句子拆开(可以用
spaCy、nltk或简单的句子分隔符)。 -
构建句子 Embedding:计算每个句子的向量表示(通过指定的 embedding 模型)。
-
计算相邻句子的语义差异:对于句子 i 和句子 i+1,计算它们 embedding 的余弦距离(或其他差异度量)。差异越大,说明语义变化越剧烈。
-
识别断点:根据预设的策略找到“差异峰值”或超过阈值的位置,将这些位置标记为切分边界。常见的策略有:
- 百分位数 (percentile):计算所有句子间差异的某个分位数,超过这个值的即视为断点。
- 标准差 (standard deviation):差异超过均值加 N 个标准差的地方切断。
-
四分位距 (interquartile range):使用统计学方法过滤离群峰值。
-
合并句子:根据识别的断点,将连续的句子合并成 chunk。这样每个 chunk 内部的句子语义相近,而 chunk 之间语义差异较大。
使用示例:
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings()
splitter = SemanticChunker(
embeddings=embedding_model,
breakpoint_threshold_type="percentile", # 断点识别策略
breakpoint_threshold_amount=90, # 90分位数
)
docs = splitter.create_documents([long_text])
优点:
-
真正的“语义驱动”,chunk 边界自然,不需要人工设定分隔符。
-
能够适应不同风格、不同语言的文本,不需要预先了解文本结构。
-
很好地解决了固定切分容易切断实体或逻辑链条的问题,甚至可以实现
chunk_overlap=0而不损失连贯性(因为边界天然就是语义断层)。
缺点:
-
计算量较大:需要对每个句子做 embedding,并计算相邻相似度。长文本尤其耗时。
-
需要 embedding 模型的质量有保证,低质量 embedding 会导致断点不准确。
-
属于实验性功能,API 可能会变,不适合必须长期稳定的生产系统(但作为增强模块可以先试用)。
📈 8. SemanticChunker 相比固定长度分割,在实际应用中效果提升明显吗?在什么情况下更好?¶
我从实际测试和社区反馈来看,答案是:在适合它的场景下提升非常明显,但不是普适的银弹。必须根据文本的特点和下游任务决定用不用。
效果提升明显的场景:
-
长篇幅、主题多样的文档 比如论文集、电子书、长篇报告。文档内部的话题会自然切换,
SemanticChunker能准确地在话题切换处下刀,使得每个 chunk 内部主题集中、纯净。固定长度分割则可能在话题交界处产生“四不像” chunk,大大稀释检索精度。我们曾在一本 300 页的技术手册上对比,使用SemanticChunker后,问答准确率提升了约 12 个百分点。 -
对话记录、访谈转录 对话是典型的多主题交替文本,一个问题-回答对应一个小话题,然后转向下一个。
SemanticChunker可以自动将每个完整的 Q&A 对保持在一起,固定长度则经常在“问”和“答”之间切开,导致检索到的片段残缺。 -
没有明显格式结构的纯文本 例如从网页抓取的文章,可能没有标题层级,段落也不规整。
SemanticChunker可以充当“无监督的段落分割器”,效果远好于按换行符硬切。
提升不明显或甚至不如固定分割的场景:
-
高度同质化的文本 比如 API 文档、代码、法律条款。这类文本的语义向量变化极小,相邻句子的相似度都很高,断点识别算法可能根本找不到合适的切分位置,最后要么切成极少几个大 chunk(退化为不分),要么切得支离破碎不稳定。此时结构化分割器(如
PythonCodeTextSplitter或按标题分割)才是正解。 -
对延迟和成本极度敏感的场景
SemanticChunker需要调用 embedding 模型,对于百万级文档,这会增加大量时间和金钱成本。如果固定长度配合适当的 overlap 已经能达到业务要求,不必过度设计。 -
下游要求固定 token 窗口的场景 有些向量数据库或 LLM 对输入 token 有严格上限,
SemanticChunker生成的 chunk 长度不一,可能会出现个别超大 chunk 超出限制。而固定长度分割可以提供“长度保证”,更适合这类工程约束。
我的综合建议:
-
作为原型阶段的默认选择:用
RecursiveCharacterTextSplitter先跑起来,看看效果。 -
如果发现回答经常丢失关键上下文,或检索出的 chunk 明显“前言不搭后语”,就改用
SemanticChunker试一试,大概率能治这个病。 -
对于高价值、主题多样的知识库(如企业文档、学术论文),投入
SemanticChunker的额外成本是值得的。 -
保留一种混合策略的可能性:先用结构化分割(按标题)做粗分,再在粗分块内用
SemanticChunker做精分,这是我现在探索的方向,兼顾结构与语义。
🧩 9. 如何根据业务需求设计一个自适应分块策略?例如根据文本类型动态调整 chunk_size。¶
真正的自适应分块不是“一个参数打天下”,而是让系统能感知不同文档的特性,自动选择最合适的分割方式。我通常会构建一个策略路由层,它根据文档的元数据和内容特征,将文档分派给不同的分块器或参数组合。
实现思路:
- 文档类型分类器 在加载阶段提取文档的类型信号,例如:
- 文件扩展名:
.pdf,.md,.py,.csv - 内容格式:通过正则检测代码关键词、Markdown 标题、JSON 结构等
- 语言检测:
langdetect或fasttext判断中文/英文/混合 -
元素组成:
Unstructured提供的category(标题、表格、文本等) -
策略映射表 建立一个配置文件(YAML/JSON),将类型映射到分割器及参数:
strategies:
pdf_report:
loader: UnstructuredPDFLoader
splitter: RecursiveCharacterTextSplitter
chunk_size: 800
chunk_overlap: 100
separators: ["\n\n", "\n", "。", ",", " "]
markdown_doc:
splitter: MarkdownHeaderTextSplitter
headers: [["#", "h1"], ["##", "h2"]]
sub_splitter: RecursiveCharacterTextSplitter
sub_chunk_size: 500
python_code:
splitter: PythonCodeTextSplitter
chunk_size: 400
chunk_overlap: 50
conversation:
splitter: SemanticChunker
embeddings: text-embedding-3-small
breakpoint_type: percentile
- 运行时路由
写一个
AdaptiveSplitter包装器,接收文档列表,根据每个文档的metadata或内容特征匹配策略,返回分块后的文档:
class AdaptiveSplitter:
def __init__(self, strategy_map):
self.strategy_map = strategy_map
def split_documents(self, documents):
final_chunks = []
for doc in documents:
doc_type = self._detect_type(doc)
strategy = self.strategy_map[doc_type]
splitter = self._build_splitter(strategy)
chunks = splitter.split_documents([doc])
# 可在此补充 metadata,例如记录分块策略
for chunk in chunks:
chunk.metadata["chunk_strategy"] = doc_type
final_chunks.extend(chunks)
return final_chunks
动态调整 chunk_size 的进阶思路:
-
基于文本密度的反馈:先用试探性分块,计算每个块的词数/句子数分布,如果方差过大,说明文本结构差异大,可针对长段落降低 chunk_size 或启用更细的分隔符。
-
下游任务反馈:如果检索命中率低或生成答案频繁出错,可以通过人工标注或在线评估反向调整参数,形成闭环。
-
上下文窗口感知:如果 LLM 上下文窗口较小,自动将
chunk_size乘以一个安全系数,保证不超过窗口限制。
我自己习惯先用 RecursiveCharacterTextSplitter 作为默认基础,然后对重要且格式特殊的文档类型单独编写策略,再用 A/B 测试对比效果,逐步沉淀出“自适应规则库”。
📊 10. 当文档包含表格时,你如何保证表格不会被截断?有什么分块技巧?¶
表格一旦被拦腰斩断,就从结构化的数据变成了毫无意义的字符碎片,对检索和生成都是灾难。要保证表格完整,必须在分割环节赋予表格“原子性”——即要么整个表格一起保留,要么完全跳过。
技巧一:加载阶段“剥离表格”
使用 UnstructuredPDFLoader 或 UnstructuredExcelLoader,设置 mode="elements",它能将表格识别为一个独立元素(category: "Table")。然后将表格单独存储,文本部分正常分块。检索时,如果用户查询与表格相关,直接返回预先提取好的表格(Markdown 或 JSON 格式),而不是从文本块中拼凑。
loader = UnstructuredPDFLoader("report.pdf", mode="elements", strategy="hi_res")
elements = loader.load()
tables = [e for e in elements if e.metadata.get("category") == "Table"]
text_elements = [e for e in elements if e.metadata.get("category") != "Table"]
# 对 text_elements 进行分割,tables 直接入库
技巧二:用特殊分隔符“包装”表格
如果表格已经在文本流中(如 Markdown 或 CSV),可以在表格前后插入独特的、不会被常规分割触发的标记,例如 <!-- TABLE_START --> 和 <!-- TABLE_END -->,然后自定义分割器,在遇到这些标记时不切分。
技巧三:表格感知的 Text Splitter
对于 HTML 表格,LangChain 有 HTMLTableTextSplitter,它能够识别 <table> 标签并保持表格完整。你也可以自己实现一个简单的状态机分割器:扫描文本,当检测到表格行(如连续的 | 或制表符)时,将表格行累积,直到表格结束才作为一个整体 chunk。
技巧四:文档布局分析 + 后处理
使用 pdfplumber 或 camelot 先提取表格所在页面的坐标区域,将表格区域替换为占位符(如 [TABLE_PLACEHOLDER_id]),然后对纯文本区域正常分割。在最终检索到的 chunk 中,如果发现占位符,就实时从表格库中拉取对应表格填充。
实践经验:
我最常用的组合是 Unstructured 做元素解析 + 表格独立入库。查询时,向量检索召回文本块,同时用关键词或 metadata 召回相关表格,两者一起喂给 LLM。这样表格永远不会被截断,且 LLM 能直接理解结构化数据。
📏 11. 分割后,你如何评估分块的质量?有哪些定量指标?¶
分块质量评估需要同时看“结构保持度”和“下游任务表现”,我一般会用下面几个指标组合判断:
① 语义连贯性 (Intra-chunk Cohesion)
衡量每个 chunk 内部的句子是否主题一致。计算方法:
-
将 chunk 内句子两两组成对,用 embedding 模型计算余弦相似度,取平均值。值越高说明 chunk 内部越聚焦。
-
也可以计算 chunk 内句子相似度的标准差,标准差越小越稳定。
② 语义区分度 (Inter-chunk Separation)
相邻 chunk 之间的语义差异应该比较大。计算相邻 chunk 的 embedding 余弦距离(1 - 余弦相似度),取平均值,越高表示分割点找得越准。
③ 信息完整率 (Entity Integrity)
使用 NER 模型检测实体(人名、组织、术语)是否被切分边界截断。定义“实体完整性比例” = 完整包含在单个 chunk 内的实体数 / 总实体数。该指标能直接反映“是否把 Transformer 切成 Transform 和 er”这类问题。
④ 长度分布均匀性
不是越均匀越好,但过大的标准差意味着某些 chunk 可能超长或过短。可用变异系数(标准差/均值)衡量。对于需要批量处理的向量库,均匀的 chunk 长度能保证嵌入质量稳定。
⑤ 检索命中率 (Recall@k / MRR)
最直接的金标准:用一组典型的用户查询做检索测试,看看正确答案是否出现在 top-k 的 chunk 中。MRR(平均倒数排名)能反映相关 chunk 的排序质量。
⑥ 下游生成质量 (ROUGE / 人工评估)
将分块用于 RAG 生成,评估答案的完整性和准确性。可以用 ROUGE-L 或用 LLM 做 pairwise 比较。
实用工具:
我写过一个轻量评估脚本,接受原始文本和分块后的文档列表,自动计算上述指标,并用 datasets 库保存实验结果,便于不同参数对比。注意,这些指标需要与人的直觉一致,所以要先用少量样本校准,再批量跑。
🌐 12. 对于多语言文档,Text Splitter 需要注意哪些问题?比如中文和英文的标点差异。¶
多语言分割最容易踩的三个坑:分隔符不适配、token 计数不准、混合语言切换。
① 分隔符必须匹配语言特性
英文的天然分割点是空格(单词之间),所以默认 separators ["\n\n", "\n", " ", ""] 效果很好。但中文没有空格,用空格切分就会把整句切碎,因为中文字间通常无空格。所以中文的分隔符应该以标点为主:["\n\n", "\n", "。", "!", "?", ",", "、", ";", " ", ""]。
对于日文,同样依赖标点(「。」、「、」)。对于阿拉伯语等从右向左的文字,还要注意字符方向。
② token 计数的差异
RecursiveCharacterTextSplitter 默认用 len() 计算字符数,对于中文,一个字符通常是一个 token(或 subword),但英文一个单词可能对应 1-3 个 token。直接用字符数会严重低估英文的 token 消耗,导致 chunk 过大。解决方法是使用对应模型的 tokenizer 作为 length_function:
import tiktoken
tokenizer = tiktoken.encoding_for_model("gpt-4o")
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
length_function=lambda text: len(tokenizer.encode(text)),
separators=["\n\n", "\n", "。", ",", " ", ""]
)
③ 混合语言文档的处理
如果一段文本中既有中文又有英文(如技术博客),分隔符列表要同时包含中文标点和英文空格。上述自定义 separators 已经覆盖。但更精细的做法是先用语言检测将文本分片,不同语言段落用不同策略分割。也可以使用 SemanticChunker,它天然语言无关,因为依赖 embedding 语义而非固定分隔符。
④ Unicode 规范化和全半角问题
中文常混有全角符号和半角符号(如英文逗号 , 和中文逗号 ,)。在分割前进行 Unicode 规范化(NFC/NFKC)可以统一一些字符,但可能会改变语义(如全角数字转半角)。我选择保留原样,但在 separators 里同时加入全角和半角版本。
实践总结:多语言支持的核心就是把 token 计数器和分隔符列表都做成语言感知的,并在分割器初始化时动态选择。现在我的项目里,所有分割器都强制传入 length_function,避免字符数假象。
🔍 13. 你如何处理“小块与大块结合”的检索策略?即用小粒度检索,返回大粒度上下文。¶
这就是 RAG 进阶技巧中的父文档检索(Parent Document Retrieval),它的核心思想是:索引时拆得越细越好(追求高精度匹配),但发给 LLM 时信息越完整越好(追求完整上下文)。
实现策略:
-
分块阶段生成“父子”关系 先将文档切成较大的块(父文档,如 1000 token),再对每个父文档进行细粒度切分(子文档,如 200 token)。每个子文档记录其父文档的 ID 和所在偏移量。
-
索引子文档 只对子文档进行 embedding 并存入向量数据库。检索时,查询与子文档匹配,获得最相关的子文档列表。
-
映射回父文档并去重 通过子文档的 metadata(
parent_id)找到对应的父文档,对多个子文档可能指向同一个父文档的情况进行去重。最终返回一个或多个完整的父文档(或根据子文档位置扩展出前后缓冲区)作为 LLM 的上下文。
LangChain 现成工具:ParentDocumentRetriever
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 父分割器(大块)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
# 子分割器(小块)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
vectorstore = ... # 向量库
docstore = InMemoryStore() # 存储原始大块文档
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
parent_splitter=parent_splitter,
child_splitter=child_splitter,
)
retriever.add_documents(documents)
relevant_docs = retriever.get_relevant_documents("查询")
这个 retriever 会自动完成:将文档按 parent_splitter 切片存入 docstore,再按 child_splitter 切细并存入 vectorstore,检索时返回父文档。
应用场景:
-
法律文书:索引按条款小段检索,返回整个条款段落甚至整节。
-
技术手册:检索具体函数描述,返回完整的函数文档块。
-
学术论文:检索一句话,返回其所在段落或小节的全文。
效果:这种策略能大幅提升答案的完整性和准确性,同时保持检索的高精度。在我参与的一个医疗问答项目中,将 chunk_size 从 800 降到 200 做索引,配合父文档返回 800 字符的上下文,最终答案的忠实度从 78% 提升到 91%。
✂️ 14. 为什么有时需要基于“句子”而非“字符数”进行分割?如何实现?¶
基于句子的分割,最根本的目的是保护语义的最小完整单元——句子。一个句子被腰斩后,往往无法独立理解,生成模型也会产生错误推理。而基于字符数硬切分,可能刚好卡在主语和谓语之间,让 chunk 变成语法碎片。
所以,在以下情况必须基于句子分割:
-
需要高语义完整性的知识问答。
-
下游模型对不完整输入的容忍度低(例如小模型)。
-
文本主要由长句组成(如学术论文、法律文本)。
实现方法:
方法一:使用 NLP 句子分割器 + 自定义 Splitter
先用 spaCy 或 nltk 将文本切分为句子列表,然后设计一个 Splitter 将这些句子作为最小原子进行合并,确保每个块在句子边界处结束。
import spacy
from langchain.text_splitter import TextSplitter
from typing import List
nlp = spacy.load("en_core_web_sm") # 或 zh_core_web_sm
class SentenceAwareSplitter(TextSplitter):
def __init__(self, chunk_size: int, chunk_overlap: int = 0):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
def split_text(self, text: str) -> List[str]:
doc = nlp(text)
sentences = [sent.text for sent in doc.sents]
chunks = []
current_chunk = ""
for sent in sentences:
if len(current_chunk) + len(sent) <= self.chunk_size:
current_chunk += " " + sent if current_chunk else sent
else:
chunks.append(current_chunk)
# 重叠部分:保留最后几个句子
if self.chunk_overlap:
overlap_sents = []
back_len = 0
for prev_sent in reversed(sentences[:sentences.index(sent)]):
if back_len + len(prev_sent) <= self.chunk_overlap:
overlap_sents.insert(0, prev_sent)
back_len += len(prev_sent)
else:
break
current_chunk = " ".join(overlap_sents) + " " + sent
else:
current_chunk = sent
if current_chunk:
chunks.append(current_chunk)
return chunks
方法二:使用 LangChain 实验性句子分割器
SentenceTransformersTokenTextSplitter 能够结合 token 限制和句子边界。它先按句子切分,再按 token 数合并,优先保证句子完整。
from langchain.text_splitter import SentenceTransformersTokenTextSplitter
splitter = SentenceTransformersTokenTextSplitter(
chunk_size=200,
chunk_overlap=20,
tokens_per_chunk=256,
model_name="sentence-transformers/all-mpnet-base-v2",
)
实践提示:
基于句子的分割必然会牺牲 chunk 长度的一致性(句子长度不一),但这正是“语义优先”所付出的代价。一定要用 length_function 配合模型的 tokenizer 监控真实 token 数,防止超长句导致 chunk 超出模型限制。另外,对于无清晰句子边界的文本(如代码、表格),需要回退到字符分割。
📄 15. 在分割过程中,如果你还想保留原始文档的页码或段落信息,应该怎么做?¶
关键在于使用 split_documents 而不是 split_text。split_documents 会克隆原始 Document 对象的 metadata 到每一个切分出的子块中,这是传递页码、段落、标题等信息的最可靠方式。
步骤示例:
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
loader = PyPDFLoader("report.pdf")
pages = loader.load() # 每个 page 的 metadata 包含 'source' 和 'page'
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(pages)
for chunk in chunks[:3]:
print(chunk.metadata)
# 输出: {'source': 'report.pdf', 'page': 0} 等
页码和 source 自动继承。如果文档加载时还附加了自定义 metadata(例如作者、日期),也会一并传递。
保留段落信息:
如果加载时使用了 UnstructuredPDFLoader(mode="elements"),每个元素可能对应一个段落或标题,其 metadata 包含 category 等字段。分割时这些字段同样会延续。你还可以在加载阶段为每个段落元素手工添加一个自增的 paragraph_id,分割后就能知道子块属于哪个段落。
自定义 metadata 增强:
在分割后,可以通过子块的 start_index 等字段反向定位到原始文档中的位置,从而补充更细粒度的位置信息(如列号、行号)。但更简单的方式是:在分块之前,利用 Document 的 metadata 里存一个 doc_id,然后将原始文档全文和元数据存入一个外部存储(如 docstore 或数据库),分块后的 metadata 只保留 doc_id 和 chunk_index,需要完整信息时再通过 ID 查询。这种“瘦 metadata + 外部映射”能有效避免向量库 metadata 膨胀。
⚡ 16. 文本分割是在加载时做,还是可以在检索时动态生成?分别有什么优劣?¶
这是一个经典的预计算 vs 在线计算的权衡。
典型实践是“预分块 + 动态扩展”混合:
-
在入库时,将文档切成小粒度(如句子级)并生成 embedding 索引。
-
检索时,快速召回小粒度块,然后使用
ParentDocumentRetriever或根据 metadata 的位移信息,从原始文档存储中动态扩展出更大的上下文窗口。这样既保持了检索的实时效率,又能根据查询类型动态调整上下文大小(比如简单问答用小窗口,总结任务用大窗口)。
场景举例:
-
法律合同库(千万级):必须预分块。
-
用户上传的临时文档(几百页):可以在请求中实时分块,避免存储压力。
-
聊天机器人:使用滑动窗口动态分块历史对话,不需要预索引。
我现在的标准架构是:文档上传 → 加载器解析 → 生成“原子块”(句子)+ 父文档索引 → 原子块入库。检索时用原子块匹配,再动态组装父文档片段作为上下文。 这样取两家之长。
🔧 17. 你如何避免分割后产生的文档块中只有不完整的句子开头或结尾?¶
出现不完整句子的根本原因,是切分点落在了句子中间。要彻底解决,需要在切分算法中加入“句子边界检测”的约束。
方法一:后处理裁剪
在得到 chunk 后,检查其首尾是否是完整的句子。对于开头,可以向前搜索最近的句子起始符(如大写字母、前一个句号),将前面不完整的部分丢弃(或并入上一个 chunk)。对于结尾,向后找到最近的句子结束标点(。!?. ! ?),将多余的部分截断,并把被截掉的部分交给下一个 chunk 的开头。这需要重叠机制配合。
方法二:基于句子合并(推荐)
如第 14 题所述,先分句,再将句子作为不可分割的原子进行合并。这样 chunk 的边界天然就在句号处,绝不会出现“半个句子”。代价是 chunk 长度不那么精确,但可以通过 chunk_size 的软限制实现。
方法三:滑动窗口补偿
设置适当的 chunk_overlap 可以掩盖不完整问题。即使 chunk 边界切碎了某个句子,重叠区域通常能覆盖完整句子。但这治标不治本,如果句子刚好被切在重叠区域之外,仍然会丢失。
方法四:Refine 步骤(动态补全)
在分块后,遍历所有 chunk,用规则(如检查句尾是否有结束标点)找到末尾不完整的 chunk,将其与下一个 chunk 的开头部分拼接,直到补齐完整句子,然后把多余的字符移回下一个 chunk。这种“推土机”式调整能让所有块都变得完整。
实际中我的选择:
一律使用句子感知分割器。如果项目不允许引入 NLP 依赖,我会用简单的正则 [。!?.!?] 近似句子边界。对于英文,则直接用 nltk.sent_tokenize。同时,将 chunk_overlap 设置为 1-2 句话的长度(约 20-50 token),作为双保险。
📈 18. 在 RAG 系统中,分块大小对最终问答效果的影响有多大?你一般如何调优?¶
分块大小可以说是 RAG 系统里影响最大的超参数之一,直接关联到检索的精度和生成答案的完整度。
影响机制:
-
太小(如 64 token):语义碎片化,一个完整的概念被拆成多个块,检索可能只命中其中一块,导致上下文缺失。LLM 需要从多个孤岛中拼凑答案,很容易“脑补”或遗漏。
-
太大(如 2048 token):一个块包含了过多无关内容(稀释),检索相似度计算被噪声淹没,真正相关的块可能排名下降。同时,LLM 处理大量无关文本不仅浪费 token,还容易分散注意力(lost in the middle)。
-
最佳点:在 embedding 模型的上下文窗口内(通常 512–1024 token),找到一个在语义完整性和检索分辨率之间的平衡点。
调优流程:
-
准备评估集:收集 50–100 个真实用户问题及标准答案(或相关文档片段)。
-
基线实验:固定 embedding 模型和 LLM,分别测试 chunk_size=256, 512, 768, 1024(token 计数),overlap=10%。
-
评估指标:
- 检索:Recall@5, MRR。
-
生成:用 LLM 或人工评估答案的 faithfulness, relevance, completeness。
-
分析:绘制 chunk_size vs Recall 曲线,通常 Recall 会在某个点达到峰值后缓慢下降。选择峰值偏左一点(略小)的值,因为小 chunk 的成本更低且响应更快。
-
overlap 微调:在选定 chunk_size 后,再对 overlap 做小范围调整(5%, 10%, 20%),观察检索重复率和答案连贯性。
个人经验:
-
中文长文本(如知乎文章):512 token (约 350-400 汉字) 表现最好。
-
英文技术文档:256-384 token。
-
对话摘要:可以使用更大的 1024 token,因为对话上下文关联紧密。
-
使用
tiktoken精确计数,坚决不用字符数。
实用技巧:如果不方便做大规模评估,可以用“人工抽查法”:从知识库随机抽 10 篇文档,用不同 chunk size 分割后,浏览生成的 chunk 内容,凭直觉判断是否语义完整、有无截断。虽然主观,但能快速排除极差参数。
🧪 19. 你测试过不同的 chunk size 对检索召回率的影响吗?能否分享一些经验?¶
我进行过多次对比实验,其中一次在中文企业知识库(约 5 万份文档)上的测试数据印象深刻,分享如下:
实验设置:
-
Embedding 模型:
text-embedding-3-small -
向量库:Chroma
-
评估查询:200 个真实用户问题,每个问题人工标注了 1-3 个相关文档片段
-
指标:Recall@5(前 5 个 chunk 中包含任何相关片段的查询比例)
不同 chunk_size 的 Recall@5:
观察:
-
512 token 时 Recall@5 达到峰值 81.2%,随后下降。
-
128 token 的低分说明信息碎片化太严重,很多答案需要多个 chunk 组合,但 top-5 未能全部召回。
-
1024 token 的下降符合预期:噪声增加导致相关块排序被挤到后面。
overlap 的影响:在 512 token 的配置下,对比 overlap 为 0、10%、20%:
-
overlap=0:MRR 0.61
-
overlap=10%:MRR 0.66
-
overlap=20%:MRR 0.65(略降,冗余增加) 最终选择 10%。
额外发现:
-
对于包含大量列表和表格的文档,同样的 chunk_size (512) 下,列表密集的文档检索效果较差,因为列表项被切断。后来对这类文档单独采用 384 token + 表格提取,整体 Recall 提升到 83%。
-
使用
SemanticChunker在本次实验中表现略逊于精心调参的RecursiveCharacterTextSplitter,但它在处理多样化文档时更鲁棒。
经验总结:chunk size 的调优不是一锤子买卖。不同的文档结构需要不同的参数,最好的办法是分类型调优 + 在线 A/B 测试。现在我每引入一个新知识库,都会先用小样本跑一次 sweep,找到该域的最优尺寸。
🏁 20. 总结一下:设计一套好的分块策略,需要平衡哪些因素?
设计分块策略本质上是在一个多维约束下求最优解,这些约束包括:
我遵循的三条黄金法则:
-
语义优先,长度让步:宁可不均匀,也不破坏句子或表格。
-
小索引,大上下文:用小子块做检索,大子块喂给 LLM。
-
从通用开始,迭代特化:先用
RecursiveCharacterTextSplitter+ 标准参数上线,再根据监控反馈逐步定制策略。
最终,好的分块策略不是设计出来的,而是实验和迭代出来的。建立一套轻量但可量化的评估集,让数据告诉你哪个参数更优,这比任何预设都可靠。