Semantic Chunk 为什么需要 Embedding API

固定长度分块不需要任何外部服务,语义分块却必须调用 Embedding API——这背后的原因是什么?

先说结论

Semantic chunking 的核心思想是在语义边界处切分文本。判断”两段文字是否属于同一个话题”需要将文本转换为向量后计算相似度——这就是 Embedding API 不可或缺的原因。

传统分块 vs 语义分块

维度 固定长度 / 递归分块 语义分块
切分依据 字符数、token 数、分隔符 相邻句子的语义相似度
是否需要 Embedding ❌ 不需要 ✅ 必须
切分质量 可能在话题中间断开 在话题转换处切分,保持语义完整

固定长度分块就像用尺子量纸——不管内容写了什么,到了 500 字就剪一刀。语义分块则像一个读者,读完一段后判断”下一段是不是在说同一件事”,如果不是,就在这里切开。

两种主流语义分块策略

策略一:相邻相似度法(Kamradt 方法)

核心思路:计算相邻句子之间的语义距离,在距离突变处切分。

流程:
1. 将文本切成小句子
2. 为每个句子拼接前后 buffer_size 个句子作为上下文
3. 调用 Embedding API 获取每个组合句子的向量
4. 计算相邻句子向量的余弦距离
5. 通过二分搜索找到阈值,在距离超过阈值的位置切分

伪代码:

# 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. 用动态规划找到最优分割方案

伪代码:

# 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 时拼接多少上下文。

# 拼接逻辑示意
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 像俯瞰全文的编辑。选择哪种,取决于你的文档规模和对切分质量的要求。

Comments

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注