固定长度分块不需要任何外部服务,语义分块却必须调用 Embedding API------这背后的原因是什么?
先说结论
Semantic chunking 的核心思想是在语义边界处切分文本。判断"两段文字是否属于同一个话题"需要将文本转换为向量后计算相似度------这就是 Embedding API 不可或缺的原因。
传统分块 vs 语义分块
| 维度 | 固定长度 / 递归分块 | 语义分块 |
|---|---|---|
| 切分依据 | 字符数、token 数、分隔符 | 相邻句子的语义相似度 |
| 是否需要 Embedding | ❌ 不需要 | ✅ 必须 |
| 切分质量 | 可能在话题中间断开 | 在话题转换处切分,保持语义完整 |
固定长度分块就像用尺子量纸------不管内容写了什么,到了 500 字就剪一刀。语义分块则像一个读者,读完一段后判断"下一段是不是在说同一件事",如果不是,就在这里切开。
两种主流语义分块策略
策略一:相邻相似度法(Kamradt 方法)
核心思路:计算相邻句子之间的语义距离,在距离突变处切分。
流程:
1. 将文本切成小句子
2. 为每个句子拼接前后 buffer_size 个句子作为上下文
3. 调用 Embedding API 获取每个组合句子的向量
4. 计算相邻句子向量的余弦距离
5. 通过二分搜索找到阈值,在距离超过阈值的位置切分
伪代码:
python
# Step 1: 拼接上下文窗口
for i, sentence in enumerate(sentences):
combined = ""
for j in range(max(0, i - buffer_size), i):
combined += sentences[j] + " "
combined += sentence
for j in range(i + 1, min(n, i + 1 + buffer_size)):
combined += " " + sentences[j]
combined_texts.append(combined)
# Step 2: 获取所有组合句子的 embedding(一次批量调用)
embeddings = embedding_client.embed_texts(combined_texts)
embedding_matrix = np.array(embeddings)
# Step 3: 只计算相邻句子间的余弦距离
distances = []
for i in range(len(sentences) - 1):
similarity = dot(embedding_matrix[i], embedding_matrix[i + 1])
distances.append(1 - similarity) # 距离越大 = 话题差异越大
# Step 4: 二分搜索找阈值,使切分数量接近 total_size / avg_chunk_size
threshold = binary_search_threshold(distances, target_cuts)
# Step 5: 在距离超过阈值的位置切分
breakpoints = [i for i, d in enumerate(distances) if d > threshold]
直觉理解:想象你在读一篇文章,每读完一句就问自己"这句和下一句是不是在说同一件事?"。如果突然觉得话题跳了,就在这里切一刀。
关键特征:只看相邻关系。 它只计算 sentence[i] 和 sentence[i+1] 之间的距离,是一种局部贪心策略。
策略二:聚类最优分割法(动态规划方法)
核心思路:构建所有句子对之间的相似度矩阵,用动态规划找到使簇内相似度总和最大的最优分割。
流程:
1. 将文本切成小句子
2. 调用 Embedding API 获取所有句子的向量
3. 构建 N×N 相似度矩阵
4. 对矩阵做均值归一化(防止退化为单一大簇)
5. 用动态规划找到最优分割方案
伪代码:
python
# Step 1: 获取所有句子的 embedding(注意:没有 buffer 拼接)
embeddings = embedding_client.embed_texts(sentences)
embedding_matrix = np.array(embeddings)
# Step 2: 构建 N×N 相似度矩阵
similarity_matrix = dot(embedding_matrix, embedding_matrix.T)
# Step 3: 均值归一化,防止 DP 退化为"全部放一个簇"
mean_sim = mean(upper_triangle(similarity_matrix))
similarity_matrix -= mean_sim
fill_diagonal(similarity_matrix, 0)
# Step 4: 动态规划寻找最优切分
# dp[i] = 前 i+1 个句子的最大簇内相似度总和
for i in range(n):
for size in range(1, i + 2):
start = i - size + 1
if cluster_size(start, i) > max_chunk_size and size > 1:
break
reward = sum(similarity_matrix[start:i+1, start:i+1])
if start > 0:
reward += dp[start - 1]
dp[i] = max(dp[i], reward)
# Step 5: 回溯得到最优分割
clusters = backtrack(segmentation)
关键特征:全局最优。 它考虑所有句子对之间的关系,通过 DP 找到整体最优的分割方案。
两种策略的深度对比
算法本质差异
| 维度 | Kamradt(相邻相似度) | Cluster(动态规划) |
|---|---|---|
| 视野 | 局部------只看相邻句子 | 全局------看所有句子对 |
| 决策方式 | 贪心:距离超阈值就切 | 最优化:最大化簇内总相似度 |
| 阈值确定 | 二分搜索目标切分数 | 无需阈值,DP 自动决定 |
| 上下文增强 | ✅ 有 buffer_size 拼接 | ❌ 直接用原始句子 |
| 大小约束 | avg_chunk_size + max_chunk_size 双重约束 | max_chunk_size 硬约束 |
核心区别用一句话概括:
- Kamradt 问的是:"这两个相邻句子之间是否存在话题跳转?"
- Cluster 问的是:"哪种分组方式能让每组内部的句子最相似?"
一个直观的例子
假设有 6 个句子,话题分布如下:
句子1: 讨论苹果公司的财报
句子2: 讨论苹果公司的新产品
句子3: 讨论天气预报
句子4: 讨论明天的气温
句子5: 讨论苹果公司的股价
句子6: 讨论苹果公司的竞争对手
Kamradt 的切法: 逐对比较相邻距离 - 句子2→3:话题跳转(苹果→天气),切! - 句子4→5:话题跳转(天气→苹果),切! - 结果:[1,2] [3,4] [5,6]
Cluster 的切法: 全局相似度矩阵显示 1,2,5,6 彼此高度相似 - 但由于 DP 要求连续分割(不能跳着分组),它仍然只能切连续片段 - 结果可能也是 [1,2] [3,4] [5,6],但决策依据不同
关键差异出现在边界模糊的情况:
考虑一篇从"电动车技术"渐变到"能源政策"的文章:
句子1: 特斯拉发布了新一代电池技术
句子2: 新电池的能量密度提升了 50%
句子3: 更高的能量密度意味着更长的续航里程
句子4: 续航焦虑一直是消费者购买电动车的障碍
句子5: 政府为缓解这一问题推出了充电桩补贴政策
句子6: 补贴政策同时覆盖了家用和商用充电设施
句子7: 商用充电设施的电价采用峰谷分时定价
句子8: 分时电价机制是电力市场化改革的重要组成部分
Kamradt 看到的(相邻距离):
1→2: 0.08 (都在说电池)
2→3: 0.10 (电池→续航,很近)
3→4: 0.12 (续航→续航焦虑,很近)
4→5: 0.15 (消费者→政府政策,稍远但不突出)
5→6: 0.09 (都在说补贴)
6→7: 0.13 (补贴→电价,有点远)
7→8: 0.11 (都在说电价)
没有任何一个距离明显"跳起来"------话题是一步步滑过去的。Kamradt 的二分搜索很难找到一个合理的阈值,可能切出 [1-4][5-8] 或 [1-3][4-6][7-8] 这样不太理想的结果。
Cluster 看到的(全局相似度矩阵摘要):
句1 句2 句3 句4 句5 句6 句7 句8
句1 -- 0.9 0.7 0.4 0.2 0.1 0.1 0.05
句2 -- 0.8 0.5 0.2 0.15 0.1 0.05
句3 -- 0.6 0.3 0.2 0.15 0.1
句4 -- 0.5 0.4 0.3 0.2
句5 -- 0.8 0.6 0.4
句6 -- 0.7 0.5
句7 -- 0.8
句8 --
全局视角清晰地显示:句子 1-3 彼此高度相似(电池/续航技术),句子 5-8 彼此高度相似(政策/电价),句子 4 是过渡句。DP 优化会发现 [1-3][4-8] 或 [1-4][5-8] 的簇内总相似度最大,从而做出更合理的切分。
本质区别: Kamradt 只看"相邻两句之间的落差",渐变过渡中每一步落差都很小,就像温水煮青蛙;Cluster 看"整组句子之间的整体相似度",即使过渡平滑,它也能发现句子 1 和句子 8 之间其实已经毫无关系了。
Embedding 开销对比
这是两种策略最重要的实际差异之一:
| 维度 | Kamradt | Cluster |
|---|---|---|
| Embedding 输入 | combined_sentence(含 buffer 上下文) | 原始句子(无 buffer) |
| Embedding 调用次数 | N 个文本,1 次批量调用 | N 个文本,1 次批量调用 |
| 每个文本的平均长度 | 较长(~7 句,buffer_size=3) | 较短(1 句) |
| 总 token 消耗 | 较高(buffer 导致输入膨胀) | 较低(无冗余) |
| 后续计算开销 | O(N)------只算相邻距离 | O(N²)------构建完整相似度矩阵 |
| DP 计算开销 | 无 | O(N × max_cluster_size) |
具体数字对比(假设 1000 个句子,平均每句 30 tokens)
Kamradt: - Embedding 输入:1000 个 combined_sentence,每个约 7×30 = 210 tokens - 总 token 消耗:1000 × 210 = 210,000 tokens - 距离计算:999 次点积 → 可忽略 - 内存:1000 × embedding_dim 的矩阵
Cluster: - Embedding 输入:1000 个原始句子,每个约 30 tokens - 总 token 消耗:1000 × 30 = 30,000 tokens - 相似度矩阵:1000 × 1000 = 100 万个浮点数(约 8MB) - DP 计算:O(1000 × max_cluster_size) 次循环
结论: - Embedding API 费用 :Kamradt 消耗约 7 倍 token(因为 buffer 拼接),API 成本更高 - 计算资源 :Cluster 的 O(N²) 矩阵和 DP 在本地 CPU/内存上开销更大 - 网络延迟:两者相同(都是 1 次批量调用,或按 batch_size 分多次)
大规模场景(10 万句子)
| 指标 | Kamradt | Cluster |
|---|---|---|
| Embedding token 总量 | ~2100 万 tokens | ~300 万 tokens |
| API 调用次数(batch_size=500) | 200 次 | 200 次 |
| 相似度计算 | 99,999 次点积 | 100 亿次点积(N²矩阵) |
| 内存占用 | ~400MB(embedding 矩阵) | ~40GB(N²相似度矩阵)⚠️ |
10 万句子时 Cluster 策略的 N² 矩阵会爆内存,这是它的硬伤。实际使用中,Cluster 策略更适合中等长度文档(几百到几千句子),而 Kamradt 可以处理任意长度。
切分质量对比
| 场景 | Kamradt 表现 | Cluster 表现 |
|---|---|---|
| 话题边界清晰 | ✅ 优秀,距离突变明显 | ✅ 优秀 |
| 话题渐变过渡 | ⚠️ 可能找不到切点 | ✅ 全局优化仍能找到最佳分割 |
| 短文档(<50 句) | ✅ 快速 | ✅ 质量更高 |
| 长文档(>1 万句) | ✅ 线性扩展 | ❌ 内存爆炸 |
| 句子很短 | ⚠️ 需要 buffer 补充上下文 | ⚠️ 短句 embedding 质量差 |
如何选择?
| 你的场景 | 推荐策略 | 原因 |
|---|---|---|
| 文档长度不确定,需要通用方案 | Kamradt | 线性复杂度,不会爆内存 |
| 文档较短(<2000 句),追求最优切分 | Cluster | 全局最优,质量更高 |
| Embedding API 按 token 计费 | Cluster | 无 buffer 膨胀,token 消耗低 7 倍 |
| 本地计算资源有限 | Kamradt | O(N) 计算,内存友好 |
| 话题边界模糊,需要精确切分 | Cluster | DP 全局优化更鲁棒 |
为什么不能用其他方法替代 Embedding?
| 替代方案 | 问题 |
|---|---|
| 关键词重叠 / TF-IDF | 无法捕捉同义词和上下文语义("汽车"和"车辆"会被认为不相关) |
| 规则分隔符(段落、句号) | 同一段落可能包含多个话题,不同段落可能讨论同一话题 |
| LLM 直接判断 | 成本过高,延迟大,不适合批量处理数万句子 |
Embedding 将文本映射到高维语义空间,语义相近的文本向量距离小,语义不同的文本向量距离大。这是目前在成本、速度、质量之间最优的语义相似度度量方式。
buffer_size:上下文窗口的作用
语义分块中有一个关键参数 buffer_size(默认值 3),它决定了为每个句子生成 embedding 时拼接多少上下文。
python
# 拼接逻辑示意
for each sentence[i]:
combined = sentence[i-3] + sentence[i-2] + sentence[i-1] # 前 3 句
+ sentence[i] # 当前句
+ sentence[i+1] + sentence[i+2] + sentence[i+3] # 后 3 句
关键点:buffer_size 不影响 Embedding 调用次数,只影响每次输入的文本长度。
以 10 个句子为例,无论 buffer_size 是 1 还是 10,都是对 10 个 combined_sentence 做 embedding。区别在于每个文本包含的上下文多少:
| buffer_size | 每个文本平均包含句子数 | 效果 |
|---|---|---|
| 1 | ~3 句 | 上下文少,可能误判 |
| 3(默认) | ~7 句 | 平衡点 |
| 10 | ~21 句 | 上下文丰富,但可能超出模型 token 限制 |
注意:Embedding 模型有输入长度上限(如 BGE-M3 最大 8192 tokens)。buffer_size 太大会导致文本被截断,反而丢失当前句子的信息。
大规模场景下的性能考量
假设一篇长文档被切成 10 万个句子:
- 需要 embed 的文本数 = 100,000 个
- 如果 batch_size 配置为 500,实际 API 调用次数 = 100,000 ÷ 500 = 200 次 HTTP 请求
性能瓶颈在 API 调用次数(由句子总数和 batch_size 决定),与 buffer_size 无关。
降级策略:Embedding 不可用时怎么办?
好的系统设计应该考虑 Embedding 服务不可用的情况。常见做法是:当 Embedding 调用失败时,自动降级为递归分块策略(纯规则分块,不需要 Embedding)。
这意味着语义分块是一种增强 而非依赖------没有 Embedding 服务时系统仍然可以工作,只是切分质量会下降。
总结
| 问题 | 答案 |
|---|---|
| 为什么需要 Embedding? | 判断语义相似度需要向量表示 |
| 能否用规则替代? | 不能,规则无法捕捉语义 |
| 能否用 LLM 替代? | 理论上可以,但成本和延迟不可接受 |
| Kamradt vs Cluster 核心区别? | 局部相邻比较 vs 全局最优分割 |
| 哪个 Embedding 开销更大? | Kamradt token 消耗高(buffer 膨胀),Cluster 计算开销高(N²矩阵) |
| 大规模文档选哪个? | Kamradt------线性复杂度,不会爆内存 |
| 追求最优切分选哪个? | Cluster------全局 DP 优化,但限中等长度文档 |
| 服务不可用怎么办? | 两者都降级为规则分块 |
Embedding API 是语义分块的"眼睛"------没有它,分块算法就是一个盲人在切蛋糕。两种策略用不同的方式"看"文本:Kamradt 像逐行扫描的阅读器,Cluster 像俯瞰全文的编辑。选择哪种,取决于你的文档规模和对切分质量的要求。