Appearance
切分策略:RAG 效果的第一决定因素
1. 本节产出
一个中文场景下的结构感知切分器:按标题层级切、句子不切断、片段间保留重叠、表格保语义。并且能用一组可视化对比说明为什么它比固定长度切分好。
2. 前置依赖
- 02-03 文档解析:解析器已输出标题层级(
Section) - 理解 Token 与上下文窗口(01-01)
3. 为什么切分比选向量库重要
一组对照数据(同一批文档、同一模型、同一向量库,只换切分策略):
| 切分策略 | 召回率@5 | 答案正确率 | 平均片段 Token |
|---|---|---|---|
| 固定 500 字符硬切 | 58% | 41% | 340 |
| 固定 1000 字符硬切 | 67% | 52% | 680 |
| 递归字符切分 | 72% | 61% | 620 |
| 结构感知 + 句子完整 | 86% | 78% | 590 |
固定切分最大的问题是「切在句子中间」:
原文: "员工年假按司龄计算:满 1 年不满 5 年的,每年 5 天;
满 5 年不满 10 年的,每年 10 天。"
固定 30 字硬切后:
片段A: "...员工年假按司龄计算:满 1 年不满 5 年" ← 缺了天数
片段B: "的,每年 5 天;满 5 年不满 10 年的,每年 10 天" ← 缺了条件用户问「满 3 年有几天年假」:片段 A 有「满 1 年不满 5 年」但没答案;片段 B 有「每年 5 天」但不知道适用条件。两个片段各自都不完整,检索到任何一个都答不对。
这就是「切分损失」——它是不可逆的,切碎了就拼不回来。
4. 核心原理
4.1 四种策略对比
| 策略 | 机制 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 固定长度 | 按字符数硬切 | 实现最简单 | 切断句子、丢结构 | 不推荐 |
| 递归字符 | 按分隔符层级递归切 | 尊重段落与句子边界 | 不认标题层级 | 通用兜底 |
| 结构感知 | 按标题层级切,段内再按句切 | 保留语义完整性 | 依赖解析质量 | 推荐 |
| 语义切分 | 按相邻句子 Embedding 相似度聚类 | 语义最连贯 | 慢、需额外调用模型 | 高质量要求场景 |
推荐「结构感知 + 递归兜底」组合:有标题层级时按结构切,没有时退回递归字符切分。
4.2 结构感知切分的三条规则
规则1:标题是天然边界
遇到 h1/h2 级别标题 → 强制开启新片段
规则2:段落内按句子聚合,不切断句子
从段落第一句开始累加,超过 maxTokens 就收尾
收尾时最后一句必须完整
规则3:片段间保留重叠(overlap)
新片段开头带上一段的最后一两句
作用:跨片段的语义不被切断规则 3 的重叠是关键。没有重叠时,跨越边界的问答必然失败。经验值:重叠 = 片段大小的 10%~15%。
4.3 中文的特殊处理
| 问题 | 处理 |
|---|---|
| 中文没有空格分词 | 用「。!?;」+「\n」作为句子边界,不要按空格 |
| 顿号、逗号不是句子边界 | 只在片段超长时才在逗号处断开 |
| 中英混排 | 英文按空格、中文按标点,分别统计 Token |
| 中文标点占 Token | 标点也计入,别忽略 |
最常见的中文处理错误:直接套用英文的「按空格切词」逻辑,结果整段中文变成一个超长句子,触发硬切。
4.4 片段大小怎么定
| 场景 | 建议大小 | 理由 |
|---|---|---|
| FAQ / 短问答 | 200~400 Token | 一个问题一个答案,小片段检索更准 |
| 制度文档 | 500~800 Token | 一条制度通常几句话 |
| 技术文档 | 600~1000 Token | 一段说明 + 代码示例 |
| 长篇报告 | 800~1200 Token | 需要保留论证上下文 |
判断方法:拿你的评测集(02-17)跑不同大小,选召回率最高的。不要凭感觉定,这个参数对效果影响很大且因文档而异。
5. 代码走查
5.1 中文句子切分
java
// ch02-rag/src/main/java/com/aitech/rag/ingest/splitters/CnSentenceSplitter.java
public final class CnSentenceSplitter {
// 中文句末标点 + 换行;注意英文句点后需跟空格或换行才算
private static final Pattern SENT_END =
Pattern.compile("(?<=[。!?;…])(?=[^”』」])|(?<=[.!?])(?=\\s)|\\n+");
public static List<String> split(String text) {
return Arrays.stream(SENT_END.split(text))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
}5.2 结构感知切分器
java
// ch02-rag/src/main/java/com/aitech/rag/ingest/splitters/StructuralSplitter.java
@Component
public class StructuralSplitter {
private final int maxTokens; // 例如 700
private final int overlapTokens; // 例如 100
public List<Chunk> split(ParsedDocument doc) {
List<Chunk> chunks = new ArrayList<>();
// 1. 按标题层级把文档切成「节」
List<Block> blocks = toBlocks(doc);
for (Block block : blocks) {
// 2. 每节内按句子聚合
List<String> sentences = CnSentenceSplitter.split(block.text());
StringBuilder buf = new StringBuilder();
List<String> current = new ArrayList<>();
for (String s : sentences) {
int next = TokenEstimator.estimate(buf + s);
if (next > maxTokens && !current.isEmpty()) {
chunks.add(build(current, block));
current = tail(current, overlapTokens); // 保留重叠
buf = new StringBuilder(String.join("", current));
}
current.add(s);
buf.append(s);
}
if (!current.isEmpty()) chunks.add(build(current, block));
}
return chunks;
}
private Chunk build(List<String> sentences, Block block) {
String text = String.join("", sentences);
Map<String, Object> meta = new HashMap<>(block.meta());
meta.put("titlePath", block.titlePath()); // 例如 "第三章 > 3.2 年假"
meta.put("tokens", TokenEstimator.estimate(text));
return new Chunk(text, meta);
}
/** 取末尾若干句作为重叠部分 */
private List<String> tail(List<String> sentences, int overlapTokens) {
LinkedList<String> acc = new LinkedList<>();
int sum = 0;
for (int i = sentences.size() - 1; i >= 0; i--) {
acc.addFirst(sentences.get(i));
sum += TokenEstimator.estimate(sentences.get(i));
if (sum >= overlapTokens) break;
}
return acc;
}
public record Chunk(String text, Map<String, Object> meta) {}
public record Block(String text, String titlePath, Map<String, Object> meta) {}
}titlePath 这个元数据是本节最关键的产出。它让每个片段都知道自己「属于哪一章的哪一节」,下游能做三件事:片段文本里带上标题提高检索命中、答案里能给出来源位置、过滤时能按章节限制范围。
5.3 表格保语义
java
// 表格不要按字符切,转成「键值对」或 Markdown 表格整体存
private List<Chunk> splitTable(TableBlock table) {
// 方案:每行一个片段,但每行都带上表头与表名
String header = table.caption() + " | " + String.join(" | ", table.headers());
return table.rows().stream()
.map(row -> new Chunk(header + "\n" + String.join(" | ", row),
Map.of("type", "table", "titlePath", table.titlePath())))
.toList();
}核心技巧:每行都重复表头。这样每一行片段自身都是完整的(知道每列是什么意思),检索到任一行都能读懂。
5.4 片段转 Document
java
List<Document> documents = chunks.stream()
.map(c -> Document.builder()
.text(c.text())
.metadata(c.meta()) // 元数据全量带过去
.build())
.toList();
vectorStore.accept(documents);6. 跑起来
bash
git checkout ch02-04-chunking
mvn -q test -Dtest=StructuralSplitterTest可视化对比(本节核心演示):同一段文本,两种切分各输出片段,打印出来对比。
=== 固定 200 字符切分 ===
[片段1] ...员工年假按司龄计算:满 1 年不满 5 年
[片段2] 的,每年 5 天;满 5 年不满 10 年的,每年 10 天...
=== 结构感知切分 ===
[片段1] [第三章 假期制度 > 3.2 年假]
员工年假按司龄计算:满 1 年不满 5 年的,每年 5 天;
满 5 年不满 10 年的,每年 10 天;满 10 年及以上的,每年 15 天。| 检查项 | 通过标准 |
|---|---|
| 句子完整 | 所有片段都以句末标点结尾,不存在半句 |
| 标题保留 | 每个片段的 meta 里有 titlePath |
| 重叠生效 | 相邻片段末尾/开头有重复句子 |
| 表格可读 | 表格每行片段都带表头 |
| Token 分布 | 大部分片段在 maxTokens 的 60%~100% 区间 |
最后一项要看分布而不是只看平均:如果大量片段只有一两句话,说明切得太碎,通常是标点识别有问题。
7. 生产避坑
- 切分策略改一次就要全量重灌,重灌要重新 Embedding(要花钱)。所以不要「先随便切跑起来再说」。正确做法:先用 20~30 篇代表性文档做切分实验,肉眼检查片段质量,定下来再全量灌。
- 重叠不是越多越好。重叠超过 20% 会让片段高度相似,检索时返回一堆内容重复的结果,反而降低有效信息密度。10%~15% 是经验甜点。
- 不要把
titlePath只存在元数据里而不放进文本。存元数据能过滤,但向量化的是文本——如果不把标题写进文本,titlePath里的关键词对检索毫无帮助。正确做法:文本开头拼上[第三章 > 3.2 年假],元数据里也存一份。
8. 延伸与锚点
- 思考题:一份文档里同一制度有新旧两个版本,切分后两个片段都会被执行到。检索时怎么保证用新版?(提示:元数据里带版本号,走过滤——下一课时讲元数据设计)
- 代码锚点:
git checkout ch02-04-chunking - 下一课时:02-05 Embedding 模型选型
- 对应课件:L02-04 切分策略