递归分块和语义分块的区别?
结论:递归分块(Recursive Splitting)是规则驱动——按分隔符优先级逐级降级,切到长度达标为止,结果确定、零额外成本; 语义分块(Semantic Chunking)是数据驱动——先算相邻句子的向量相似度,在"话题突变"处下刀,更贴合内容,但要多付一轮 embedding,且对阈值敏感。 工程默认选递归,只在文档话题跳跃明显、且愿意为评估投入时才上语义分块。
1. 递归分块:按优先级降级
目标 500 字,分隔符优先级:["\n\n", "\n", "。", ";", ","]
step1 用 "\n\n" 切 → 每段 800 字,仍超长
step2 对这些段用 "\n" 切 → 得 500 字左右
step3 还超长的用 "。" 切 → 得句子
step4 句子拼到接近 500 字,装不下就开新块
最后:把过短的尾块合并回上一块
| 层级 | 分隔符 | 保住的东西 |
|---|---|---|
| 1 | 空行 | 段落边界,最接近"一个意思" |
| 2 | 换行 | 列表项、短行 |
| 3 | 句号/问号/感叹号 | 完整句子 |
| 4 | 分号/逗号 | 并列成分,最后手段 |
| 5 | 空串 | 硬切,仅用于无标点的长串 |
它的确定性是最大优点:同样输入永远同样输出,便于回归测试。
2. 语义分块:找相似度的"谷底"
句子: S1 S2 S3 S4 S5 S6
相似度: 0.81 0.79 0.42 0.77 0.35 ← 相邻句余弦
↑切点 ↑切点
结果: [S1 S2 S3] [S4 S5] [S6 ...]
做法:按句切分 → 每句向量化 → 计算相邻句余弦 → 低于阈值(如全部相似度的第 20 百分位,或均值减一个标准差)就切。 阈值取百分位而不是固定值,是因为不同文档的整体相似度水平差异很大。
| 参数 | 调大 | 调小 |
|---|---|---|
| 百分位阈值 | 切得少,块变大 | 切得多,块碎成一句一块 |
| 最小块长 | 抑制碎块 | 容易出现单句块 |
| 最大块长 | — | 必须设上限,否则会切出超长块 |
3. 正面对比
| 维度 | 递归分块 | 语义分块 |
|---|---|---|
| 额外成本 | 0 | 每句一次 embedding(批量后仍可观) |
| 结果稳定性 | 完全确定 | 依赖阈值与模型版本,会漂移 |
| 结构感知 | 强,天然保留标题与段落 | 弱,不看 ##,可能跨章节合并 |
| 话题转折 | 看不出来,可能把两个主题塞进一块 | 正是它的强项 |
| 典型失败 | 在论述中途断开;无关段落被合并 | 切出大量单句块,丢失上下文;相似度噪声导致切点乱跳 |
| 适用 | 手册、规范、FAQ、代码文档 | 访谈稿、会议纪要、长篇叙事 |
4. 代码:两种实现放一起看
import numpy as np
def recursive_split(text: str, target: int = 500,
seps: tuple[str, ...] = ("\n\n", "\n", "。", ";", ",", "")) -> list[str]:
"""按分隔符优先级降级,直到每块 <= target 的 1.5 倍。"""
if len(text) <= target * 1.5 or not seps:
return [text]
sep, rest = seps[0], seps[1:]
if sep == "":
return [text[i:i + target] for i in range(0, len(text), target)]
pieces, buf = [], ""
for part in text.split(sep):
piece = part + (sep if sep != "\n\n" else "\n\n")
if len(buf) + len(piece) <= target:
buf += piece
else:
if buf:
pieces.append(buf)
# 单段本身还超长,继续用更细的分隔符拆
pieces.extend(recursive_split(piece, target, rest) if len(piece) > target * 1.5 else [piece])
buf = ""
if buf:
pieces.append(buf)
return [p.strip() for p in pieces if p.strip()]
def semantic_split(sents: list[str], vecs: np.ndarray, pct: float = 20) -> list[list[int]]:
"""相似度低于第 pct 百分位处断开,返回句子下标分组。"""
sims = np.array([vecs[i] @ vecs[i + 1] for i in range(len(vecs) - 1)])
cuts = set(np.where(sims < np.percentile(sims, pct))[0] + 1)
groups, start = [], 0
for i in range(1, len(sents) + 1):
if i in cuts or i == len(sents):
groups.append(list(range(start, i)))
start = i
return groups
5. 实际常用的混合策略
| 组合方式 | 做法 | 好处 |
|---|---|---|
| 结构骨架 + 语义断点 | 先按 ## 切章节,章节内再跑语义切分 | 不跨主题,也不在论述中途断 |
| 递归兜底 | 语义切出的块超过上限,再递归切 | 防止单块撑爆上下文 |
| 父子结构 | 子块细(语义),父块粗(章节) | 检索用子块,喂模型用父块 |
6. 常见追问
Q:语义分块一定要调 LLM 吗? 不一定,句子向量就够。也有用 LLM 判"这里是不是话题转折"的做法,准确但成本高一个数量级,只适合离线处理少量高价值文档。
Q:为什么语义分块有时候效果更差? 因为它只优化"块内一致性",不优化"可检索性"。切成一两句一块后,块里没有主语和前提,检索到了也答不了。
Q:中文句子切分要注意什么? 别只按 。,还有 !?; 和省略号;小数点、编号(3.2 节)会误切,正则要把数字前后的点排除掉。
小结
递归分块是"工程默认值",语义分块是"特定文档的优化项"。先把结构切分做对,再考虑用相似度找断点——顺序颠倒往往白忙。