1 概述
您是否留意过,常见的文本切分方法大多仅依据固定字符数或特定分隔符来执行,完全忽略了语义层面的关联?实际操作中你会发现,这种“一刀切”的策略极易造成片段语义的支离破碎,为后续的文档处理与分析埋下不少隐患。

好在langchain-experimental库提供了一个名为SemanticChunker的工具,它能先将句子转换为嵌入向量,再借助向量间的相似度来判断是否需要分割。由于向量能够精准捕捉句子的语义信息,这种切分方式自然也就具备了“语义”层面的智能。
该实现参考了另一位作者的工作,原始思路来源于:RetrievalTutorials。
2 原理
SemanticChunker的基本逻辑如下:首先通过正则表达式将原始文本拆解为一个个独立的句子:
[sentence_1, sentence_2, sentence_3, sentence_4, sentence_5, sentence_6, sentence_7, sentence_8, sentence_9, ...]
原作者提出了两种实现思路:
带位置奖励的层次聚类:为每个句子计算嵌入向量,然后执行层次聚类。但某些极短的句子若单独切分可能会破坏语义——例如“But because I chose to split on sentences, there was an issue with small short sentences after a long one. You know?”,末尾的“You know?”单独计算相似度显然不合适。所谓“位置奖励”,是指让那些在相邻位置上更可能归属于同一语义的句子在切分时获得“优待”。不过作者本人也承认,这种方法调参过程缓慢且效果不理想(slow and unoptimal),奇怪的是,在实际应用中我们居然获得了不错的表现。
在连续句子中寻找切分点:这一思路非常直观——计算每个句子的嵌入向量,然后评估相邻句子之间的“距离”(1 - 余弦相似度)。一旦距离超过某个阈值,就在该位置进行分割。例如sentence_1、2、3彼此距离很近,但sentence_3与sentence_4之间的距离骤增,则在3与4之间切断,最终将1、2、3合并为一个片段。然而直接使用原始距离容易受噪声干扰,因此作者引入了滑动窗口做“平滑处理”,窗口大小设为3时效果较佳。
注意:窗口大小为3,对应后续代码中SemanticChunker构造函数的buffer_size=1,因为会向前、向后各取buffer_size个句子。
完成上述操作后,横轴为句子索引,纵轴为句子之间的距离,绘制的图表大致如下:
随后找出那些显著高于正常距离的点作为分割位置,便能得到如下的切分结果及对应的片段:
那么什么样的距离才算“显著”呢?langchain-experimental提供了四种判断方法:
percentile(分位数法):默认方法,也是原作者采用的。简单来说,将所有距离从小到大排序,取第95百分位数(默认值95)作为阈值,大于该值的点进行分割。直观且易理解。
standard_deviation(标准差法):适用于数据近似正态分布的场景。阈值 = 距离的均值 + 3 × 标准差(3为默认值)。这是统计学中常用的“异常值”判定方法。
interquartile(四分位距法):对数据分布敏感度较低,因其基于分位数。阈值 = 距离的均值 + 1.5 × 四分位距(IQR)。IQR = 上四分位(Q3) - 下四分位(Q1),即第75百分位数与第25百分位数之差。
gradient(梯度法):稍显复杂。首先计算所有距离的梯度(实际为一阶差分,因为数据离散),然后定位距离变化最剧烈的点,取所有梯度值的第95百分位作为分割点。其核心理念在于:句子之间的距离有增有减,应在“猛增”之处动手——与k-means中肘方法确定聚类数的思路相通。
3 效果
从实际对比来看,采用嵌入向量进行切分(尤其是配合层次聚类方法)的表现明显优于Baseline(基础流程),下图可以清晰地展现这种差异。
4 核心代码
本文配套代码已开源,仓库地址:GitHub - Steven-Luo/MasteringRAG。
使用嵌入向量进行切分的核心代码极为简洁——准备好嵌入模型和目标文档,两行即可完成:
# SemanticChunker
# HuggingFaceBgeEmbeddings
embeddings = HuggingFaceBgeEmbeddings(
model_name="...",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
query_instruction="..."
)
# 或者 OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
text = open(os.path.join(os.path.pardir, "data", "sample.txt")).read()
text_splitter = SemanticChunker(embeddings)
docs = text_splitter.create_documents([text])
特别注意:SemanticChunker的默认参数是针对英文优化设计的,直接用于中文文本基本无法正常工作。下面列出几个关键参数,以及在中文场景下需要留意的地方。
4.1 注意切分的正则表达式
SemanticChunker的构造器定义如下:
class SemanticChunker(BaseDocumentTransformer):
def __init__(
self,
embeddings: Embeddings,
buffer_size: int = 1,
add_start_index: bool = False,
breakpoint_threshold_type: BreakpointThresholdType = "percentile",
breakpoint_threshold_amount: Optional[float] = None,
number_of_chunks: Optional[int] = None,
sentence_split_regex: str = "(?<=[.?!])\\s+",
min_chunk_size: Optional[int] = None,
):
...
参数sentence_split_regex默认按英文句号、问号、感叹号后跟空白字符进行切分。该模式无法处理中文文本,因此使用中文时必须替换为正则模式,例如:
sentence_split_regex="(?<=[。!?])"
4.2 使用buffer_size控制窗口大小
前文原理部分提到的滑动窗口,通过buffer_size参数进行控制——它决定当前句子前后各取几个句子组成一组,用于计算嵌入向量和相似度。
4.3 注意最小切片大小
与RecursiveCharacterTextSplitter那种“从大到小”的切分策略不同,SemanticChunker采用“从小到大”合并成片段的方式。这意味着如果初始正则切分出的句子非常短,且恰好满足了切分条件,最终可能产生一些极短且无意义的片段。因此min_chunk_size参数值得重点关注。
4.4 注意观察切片后的大小
SemanticChunker有时会生成非常大的切片,远超向量模型的最大长度限制。当然,您可以选择支持更长文本的模型,但过长的句子会稀释信息密度,未必带来正向收益。原作者在相关笔记中建议对大切片进行二次切分,我的示例代码中也包含了这部分尝试——不过效果并不理想,欢迎大家一起探索优化方案。
4.5 使用层次聚类
通过为number_of_chunks赋值一个具体的整数值来启用层次聚类,而非设置一个布尔值。用法示例如下:
text_splitter = SemanticChunker(
embeddings,
number_of_chunks=10
)
docs = text_splitter.create_documents([text])