📦 RAG 里的 Chunk 到底怎么切?

59 阅读5分钟

我把《红楼梦》扔进系统后,才意识到: Chunk 的本质不是“切文本”,而是“保护作者的思路”

如果你做 RAG,只用过技术文档或 API 说明测试分段,那你大概率会觉得:

“不就是 chunkSize + overlap 吗?”

直到有一天,你把一段真正的文学文本(比如《红楼梦》)放进去。

然后你会发现: 模型没傻,是你把文本切傻了。


先给结论:我最终稳定下来的 Chunk 切割路径

在多轮返工之后,我现在使用的是下面这条固定的文本解析路线

 ChapterParser(Markdown / TXT)
    ↓
 ChapterOverlapChunker
    ↓
 ParagraphChunker
    ↓
 CharacterOverlapSplitter   ← Unicode-safe 字符兜底

这不是“堆工具”,而是一个逐层收缩自由度的过程

  • 越往上,越尊重作者的结构
  • 越往下,越偏向机器安全兜底

下面,我们一层一层拆,为什么必须这样设计,顺序为什么不能反。


为什么不能一上来就 TextSplitter?

我们先从一个反例开始。

❌ 典型错误做法

 new TokenTextSplitter(512, 50)

然后对所有文本无差别 apply。

用《红楼梦》会发生什么?

  • 诗被切成几块
  • 人物心理被拆散
  • 情绪起伏被 flatten
  • 检索时拼不回完整语义

问题不在模型,而在这里:

Token / 字符 splitter 不知道什么是“该被保护的内容”

所以它只能做一件事: 保证长度安全,但完全不保证语义完整。


设计原则:Chunk 是“语义保护层”,不是“长度裁剪器”

所以我给自己定了一条设计原则:

越接近原始文本,越不能动刀; 越接近 embedding,越允许妥协。

这正好解释了下面这条路径的顺序。


ChapterParser:第一层“尊重作者结构”

这一层解决什么问题?

👉 找出作者明确给出的叙事边界

例如:

  • Markdown 的 # 第一回
  • 小说中的「第 X 回」「一日」「忽一日」
  • 明显的叙事转场语

在《红楼梦》中,它会识别出:

  • 丫鬟观察雨村 → 一个章节片段
  • 中秋对月 → 另一个章节片段

注意:

ChapterParser 不是为了切 chunk 而是为了 “标记大语义区块”

此时输出的是:

 Chapter A: ……
 Chapter B: ……

而不是 embedding 输入。

代码

这里以Markdown文档为例:

 public class MarkdownChapterParser {
     private static final Pattern HEADER = Pattern.compile("^(#{1,6})\s+(.*)$");
 ​
 ​
     public List<Chapter> parse(String markdown) {
         List<Chapter> chapters = new ArrayList<>();
         Chapter current = null;
         int index = 0;
 ​
 ​
         for (String line : markdown.split("\n")) {
             Matcher m = HEADER.matcher(line.trim());
             if (m.matches()) {
                 if (current != null) chapters.add(current);
                 current = new Chapter();
                 current.setIndex(index++);
                 current.setLevel(m.group(1).length());
                 current.setTitle(m.group(2).trim());
             } else if (current != null && !line.isBlank()) {
                 current.getParagraphs().add(line);
             }
         }
         if (current != null) chapters.add(current);
         return chapters;
     }
 }

ChapterOverlapChunker:章节不是断点,而是“延续点”

这是很多 RAG 系统最容易忽略的一层

为什么章节不能直接当边界?

看《红楼梦》这一段:

 未卜三生愿,频添一段愁。
 ……
 雨村吟罢,因又思及平生抱负……
  • 诗在章节尾
  • 动机变化在下一段

如果你在章节边界硬断

LLM 会把“诗”与“心理变化”当成两个无关事件


ChapterOverlapChunker 的设计目标

不是复制内容,而是复制“承接关系”

它只做一件事:

  • 把章节尾部最可能影响下一章理解的内容
  • 以 overlap 的形式带入下一章
  • 并明确标注:这是 overlap,不是正文
 {
   "overlapType": "CHAPTER_TAIL",
   "overlapWeight": 0.6
 }

这样:

  • 检索可命中
  • Rerank 可降权
  • LLM 能理解这是上下文延续

代码示例

 /**
  * 章节 overlap chunker
  */
 public class ChapterOverlapChunker {
     private final ChapterOverlapRule rule;
 ​
     public ChapterOverlapChunker(ChapterOverlapRule rule) {
         this.rule = rule;
     }
 ​
 ​
     public List<Document> chunk(List<Chapter> chapters) {
         List<Document> results = new ArrayList<>();
         if (!rule.isEnabled()) {
             return results;
         }
         for (int i = 0; i < chapters.size() - 1; i++) {
             Chapter current = chapters.get(i);
             Chapter next = chapters.get(i + 1);
 ​
 ​
             if (current.totalChars() < rule.getMinChapterChars()) {
                 continue;
             }
 ​
             String overlapText = buildOverlapText(current, next);
             if (overlapText.isBlank()) {
                 continue;
             }
 ​
             Metadata meta = new Metadata();
             meta.setChunkRole(ChunkRole.CONTEXT.name());
             meta.setOverlap(true);
             meta.setOverlapType(OverlapType.CHAPTER_TAIL.name());
             meta.setOverlapWeight(rule.getOverlapWeight());
             meta.setFromChapter(current.getIndex());
             meta.setToChapter(next.getIndex());
             meta.setChapterTitle(current.getTitle() + " → " + next.getTitle());
             meta.setLength(overlapText.length());
             meta.setTokenSize(EmbeddingUtils.countToken(overlapText));
 ​
             results.add(new Document(overlapText, meta.getMap()));
         }
 ​
 ​
         return results;
     }
 ​
     private String buildOverlapText(Chapter current, Chapter next) {
         StringBuilder sb = new StringBuilder();
         appendTail(sb, current.getParagraphs(), rule.getTailParagraphCount());
         appendHead(sb, next.getParagraphs(), rule.getHeadParagraphCount());
 ​
         if (sb.length() > rule.getMaxChars()) {
             return sb.substring(0, rule.getMaxChars());
         }
         return sb.toString();
     }
 ​
     private void appendTail(StringBuilder sb, List<String> paragraphs, int count) {
         int start = Math.max(0, paragraphs.size() - count);
         for (int i = start; i < paragraphs.size(); i++) {
             sb.append(paragraphs.get(i)).append("\n");
         }
     }
 ​
     private void appendHead(StringBuilder sb, List<String> paragraphs, int count) {
         for (int i = 0; i < Math.min(count, paragraphs.size()); i++) {
             sb.append(paragraphs.get(i)).append("\n");
         }
     }
 }

ParagraphChunker:人类写作的最小自然单位

现在,才真正轮到chunk 这件事本身

为什么是段落?

因为段落天然满足三个条件:

  1. 语义自洽
  2. 上下文密度高
  3. 人类不会希望被拆散

在《红楼梦》这段里:

内容是否单独 chunk
丫鬟观察雨村
中秋对月
诗前引语
诗后心理

此时的 Chunk 已经是“可被理解的单位”

而不是“凑 token 的单位”。


CharacterOverlapSplitter:Unicode-safe 的最后一道防线

即便前面都做对了,仍然会遇到一个现实问题:

有些段落就是太长

这时才允许进入字符级兜底

为什么一定是 Unicode-safe?

因为:

  • 中文一个字 = 多字节
  • Token 切割可能切在字符中间
  • 你已经踩过「字被切成乱码」的坑

这一层的定位非常明确:

不理解语义 只保证:字不碎、文不断

它不是主力,只是保险丝

代码示例

 import org.springframework.ai.document.Document;
 ​
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 ​
 /**
  * Unicode-safe 字符级 Overlap Splitter
  */
 public class CharacterOverlapSplitter {
     private final int chunkSize;
     private final int overlap;
     private final int step;
 ​
     public CharacterOverlapSplitter(int chunkSize, int overlap) {
         if (overlap >= chunkSize) {
             throw new IllegalArgumentException(
                     "overlap must be < chunkSize, but got overlap="
                             + overlap + ", chunkSize=" + chunkSize
             );
         }
         this.chunkSize = chunkSize;
         this.overlap = overlap;
         this.step = chunkSize - overlap; // 关键:保证推进
     }
     /**
      * 拆分文本
      */
     public List<String> split(String text) {
         List<String> result = new ArrayList<>();
         int len = text.length();
         for (int start = 0; start < len; start += step) {
             int end = Math.min(start + chunkSize, len);
             int thisLen = end - start;
             if (end == len && thisLen < overlap) {
                 break;
             }
             result.add(text.substring(start, end));
             if (end == len) {
                 break;
             }
         }
         return result;
     }
     public int getChunkSize() {
         return chunkSize;
     }
     public int getOverlap() {
         return overlap;
     }
     public List<Document> split(Document doc) {
         String text = doc.getText();
         List<Document> result = new ArrayList<>();
         int len = text.length();
         for (int start = 0; start < len; start += step) {
             int end = Math.min(start + chunkSize, len);
             int thisLen = end - start;
             if (end == len && thisLen < overlap) {
                 break;
             }
             String substring = text.substring(start, end);
             Map<String, Object> metadata = doc.getMetadata();
             metadata.put("length", substring.length());
             result.add(new Document(substring, metadata));
             if (end == len) {
                 break;
             }
         }
         return result;
     }
 }
 ​

为什么这条路径不能反过来?

如果你把顺序反了,比如:

  • 先 Token 切
  • 再试图识别段落 / 章节

那结果只有一个:

作者的结构已经被你破坏了,永远拼不回来。

这也是为什么:

Chunk 设计,一定是自上而下的“约束收紧”,而不是自下而上的“事后补救”。


最终效果:RAG 能“像人一样”理解文本结构

走完整条链路后,chunck的最终结果有:

image.png

此时:

  • 检索“雨村的诗” → 一次命中
  • 检索“雨村的心境变化” → 命中诗 + 后段
  • Prompt 中上下文自然衔接

一句我现在非常确信的话

Chunk 的好坏,不取决于你切得多聪明, 而取决于你有没有尽量少地打断作者。