我把《红楼梦》扔进系统后,才意识到: 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 这件事本身。
为什么是段落?
因为段落天然满足三个条件:
- 语义自洽
- 上下文密度高
- 人类不会希望被拆散
在《红楼梦》这段里:
| 内容 | 是否单独 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的最终结果有:
此时:
- 检索“雨村的诗” → 一次命中
- 检索“雨村的心境变化” → 命中诗 + 后段
- Prompt 中上下文自然衔接
一句我现在非常确信的话
Chunk 的好坏,不取决于你切得多聪明, 而取决于你有没有尽量少地打断作者。