Skip to content

切分策略:RAG 效果的第一决定因素 ​

1. 本节产出 ​

一个中文场景下的结构感知切分器:按标题层级切、句子不切断、片段间保留重叠、表格保语义。并且能用一组可视化对比说明为什么它比固定长度切分好。

2. 前置依赖 ​

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. 生产避坑 ​

  1. 切分策略改一次就要全量重灌,重灌要重新 Embedding(要花钱)。所以不要「先随便切跑起来再说」。正确做法:先用 20~30 篇代表性文档做切分实验,肉眼检查片段质量,定下来再全量灌。
  2. 重叠不是越多越好。重叠超过 20% 会让片段高度相似,检索时返回一堆内容重复的结果,反而降低有效信息密度。10%~15% 是经验甜点。
  3. 不要把 titlePath 只存在元数据里而不放进文本。存元数据能过滤,但向量化的是文本——如果不把标题写进文本,titlePath 里的关键词对检索毫无帮助。正确做法:文本开头拼上 [第三章 > 3.2 年假],元数据里也存一份。

8. 延伸与锚点 ​

  • 思考题:一份文档里同一制度有新旧两个版本,切分后两个片段都会被执行到。检索时怎么保证用新版?(提示:元数据里带版本号,走过滤——下一课时讲元数据设计)
  • 代码锚点:git checkout ch02-04-chunking
  • 下一课时:02-05 Embedding 模型选型
  • 对应课件:L02-04 切分策略