跳到主要内容

语义切块与 LLM 切块

本章讲两个「聪明但贵」的切块器。它们不靠文本表面的分隔符,而是靠语义信号找边界:一个用嵌入相似度,一个直接问 LLM。

3.1 SemanticChunker:在相似度低谷处切

它要解决的小问题

递归切块只看符号(段落、句号),看不懂意思。一篇文章可能连续两段讲「哲学」、第三段突然转「菜谱」,我们想正好在话题转折处切一刀。怎么知道「话题变了」?看相邻文本的嵌入相似度——相似度突然变低的地方,就是语义边界。

思路 / 直觉

传统语义切块用「相似度低于某阈值就切」。Chonkie 做得更细:它把相似度序列当成一条曲线,找曲线的局部低谷(local minima)做切点,而且先用滤波器把曲线平滑掉噪声毛刺(src/chonkie/chunker/semantic.py:1-6 的模块说明)。

相似度
高 │ ●─●─● ●─●
│ ╲ ╱
低 │ ●──────● ← 这个低谷 = 语义边界,在这切
└──────────────────────► 句子序号

原理演示

# 示意,非源码(提炼 semantic.py 的 chunk 流程)
sentences = split_into_sentences(text) # 1. 先切句
sims = [similarity(window_emb[i], sent_emb[i]) # 2. 算「窗口 vs 下一句」相似度
for i in range(len(sentences) - W)]
smoothed = savitzky_golay(sims) # 3. 平滑曲线
split_points = local_minima(smoothed) # 4. 找低谷做切点
groups = group_by(sentences, split_points) # 5. 按切点分组成块

真实实现:四步

第一步,切句并嵌入。 _get_similarity(semantic.py:220-228)算的是「窗口嵌入」与「单句嵌入」的相似度。窗口嵌入 _get_window_embeddings(semantic.py:213-218)把连续 similarity_window 个句子拼起来整体嵌入——比把单句嵌入平均更准:

# 真实源码 semantic.py:215-218
for i in range(len(sentences) - self.similarity_window):
paragraphs.append("".join([s.text for s in sentences[i : i + self.similarity_window]]))
return self.embedding_model.embed_batch(paragraphs)

第二步,找低谷(下沉 Rust)。 _get_split_indices(semantic.py:230-269)把 Savitzky-Golay 平滑 + 找局部极小值的数值活儿全交给 chonkie_core:

# 真实源码 semantic.py:244-261(节选)
minima_indices, minima_values = chonkie_core.find_local_minima_interpolated(
similarities, window_size=self.filter_window,
poly_order=self.filter_polyorder, tolerance=self.filter_tolerance)
filtered_indices, _ = chonkie_core.filter_split_indices(
minima_indices, minima_values, self.threshold, self.min_sentences_per_chunk)

两个 Rust 函数分工(inferred,据签名与上下文):find_local_minima_interpolated 平滑曲线并定位极小值;filter_split_indices 按相似度阈值(百分位)和「每块最少句数」过滤掉太密/太浅的切点。数据点不足一个滤波窗口时直接返回空(不切,semantic.py:239-241)。

第三步,分组并控大小。 按切点把句子分组(_group_sentences,semantic.py:365-396),再用 _split_groups(semantic.py:398-433)把超过 chunk_size 的组贪心二次切——语义边界优先,但硬上限不破

第四步,可选的跳跃合并。 skip_window > 0 时,_skip_and_merge(semantic.py:311-363)会尝试把非相邻但语义相似的组合并(比如「A 话题—B 插叙—A 话题」把两段 A 并起来)。默认关闭。

关键细节 / 坑

  • 默认嵌入模型是 minishlab/potion-base-32M(semantic.py:36),一个轻量静态嵌入,符合 Chonkie「轻」的取向;tokenizer 直接取嵌入模型自带的(semantic.py:95)。
  • 句子太少直接整篇成一块:句数 ≤ similarity_window 时不做相似度分析(semantic.py:467-481)。
  • threshold 必须严格在 (0,1) 开区间(semantic.py:76-77),它在这里当百分位过滤用,不是简单的相似度截断。

3.2 SlumberChunker:让 LLM 判断在哪切

它要解决的小问题

嵌入相似度仍是「数值近似」的语义。最贴近人类直觉的判断,是直接问一个 LLM:「这一堆段落里,主题第一次明显转变是在第几段?」这就是 SlumberChunker(README 表里又把它标为 AgenticChunker 别名,README.md:190;类本身源自 LumberChunker,src/chonkie/chunker/slumber.py:62-63),思路源自 LumberChunker。

思路 / 直觉

  1. 先用递归规则把文本切成一堆候选小片(candidate splits),给每片编号 ID 0/1/2…
  2. 拿一个装得下 chunk_size 上下文的窗口(用二分定位窗口右界),把这窗口内的带编号片段拼成 prompt 喂给 LLM。
  3. LLM 回一个 split_index——「在第几片主题变了」。
  4. [当前位置, split_index) 这些片合成一块,从 split_index 继续,重复。

原理演示

# 示意,非源码(提炼 slumber.py:390-466 的主循环)
splits = recursive_split(text) # 候选小片,带 ID
pos = 0
while pos < len(splits):
end = window_right_bound(pos) # 二分:塞满 chunk_size 个 token 的窗口
prompt = TEMPLATE.format(passages=numbered(splits[pos:end]))
idx = ask_llm_for_split_index(prompt) # LLM 答:主题在第 idx 片变
chunks.append(join(splits[pos:idx])) # 合成一块
pos = idx # 从断点继续

真实实现:三处关键

窗口右界用二分定位。 不是一次把全文喂给 LLM(贵且超上下文),而是每轮只喂「累计 token 数刚好不超过 chunk_size」的一段。用前缀和 + bisect_left O(log n) 定位右界(slumber.py:386-388slumber.py:427-430):

# 真实源码 slumber.py:427-430
group_end_index = min(
bisect_left(cumulative_token_counts, current_token_count + self.chunk_size) - 1,
len(splits),
)

两种抽取模式 + 兜底。 extract_mode="json" 用结构化输出 generate_json(要 pydantic),"text" 用纯文本再正则抠数字(_extract_index_from_text,slumber.py:192-219),"auto" 自动探测 genie 能力。两者都带重试(默认 3 次,max_retries);全失败时不乱切——返回 group_end_index 让这窗口的段落整体留在一起(slumber.py:269-274slumber.py:307-312)。这是一处稳健设计:LLM 抽风时退化为「保守不切」而非崩溃。

文本从原文切,保格式。 合成块时不是把片段字符串拼回去(会丢空白),而是用片段记录的 start_index/end_index 直接从原始文本切片(slumber.py:446-452),保住所有空格与换行。

关键细节 / 坑

  • 每块至少调一次 LLM:慢、贵。candidate_size(默认 128)控候选片粒度——越小切得越细但 LLM 调用越多。
  • 默认 genie 是 GeminiGenie(slumber.py:97-99),没装/没配 key 会在构造时就报错。
  • prompt 模板内置两套(JSON 版 slumber.py:21-33、文本版 slumber.py:35-58),都用 XML 标签框 <passages>,强约束「只回数字」。

横向对比:三种「找边界」哲学

切块器边界信号成本何时用
Recursive文本符号(段/句/标点)极低默认、通用
Semantic嵌入相似度低谷中(要嵌入)检索质量优先
SlumberLLM 直接判断高(每块调 LLM)质量第一、预算足

代码地图

主题文件符号
语义切块器src/chonkie/chunker/semantic.pySemanticChunker
窗口嵌入src/chonkie/chunker/semantic.py_get_window_embeddings_get_similarity
找低谷(Rust)src/chonkie/chunker/semantic.py_get_split_indiceschonkie_core.find_local_minima_interpolated
跳跃合并src/chonkie/chunker/semantic.py_skip_and_merge
LLM 切块器src/chonkie/chunker/slumber.pySlumberChunker
二分窗口主循环src/chonkie/chunker/slumber.pySlumberChunker.chunk
LLM 抽取 + 兜底src/chonkie/chunker/slumber.py_get_split_index_json_get_split_index_text
LLM 接口src/chonkie/genie/base.pyBaseGenie.generategenerate_json
嵌入接口src/chonkie/embeddings/base.pyBaseEmbeddings.embed_batchsimilarity