四种切块策略详解
上一篇我们看到,不管是父块还是子块,最终都会走到 executePipeline() 这个流水线执行引擎。这篇我们就深入这个引擎内部,逐个拆解结构切块、递归切块、语义切块、LLM 切块四种策略的完整实现。
先上一张四种策略的全景关系图:
策略全景与降级关系
流水线串行 vs 降级
这张图里有两种说明。
串行:方案里配了哪些步骤,就按顺序依次执行,前一步的输出是后一步的输入。
降级:某种策略在执行过程中发现自己无法产出有效结果时,会自动退回到另一种策略兜底。这两种机制是独立的,降级发生在单个策略内部,不影响流水线的整体推进。
executePipeline:流水线执行引擎
这是所有切块策略的调度中心。它接收一组候选块和一组有序的策略步骤,然后按顺序逐步执行,每一步的输出作为下一步的输入。
DocumentStrategyServiceImpl.java — executePipeline()
private List<ChunkCandidate> executePipeline(List<ChunkCandidate> sourceList,
List<SuperAgentDocumentStrategyStep> orderedSteps,
DocumentStrategyPipelineTypeEnum pipelineType) {
// 进入流水线前先清洗一次输入,保证每一步面对的都是可用候选块。
List<ChunkCandidate> currentChunks = cleanupChunkList(sourceList);
for (SuperAgentDocumentStrategyStep step : orderedSteps) {
DocumentStrategyTypeEnum strategyType = DocumentStrategyTypeEnum.getRc(step.getStrategyType());
if (strategyType == null) {
continue;
}
// 根据策略类型分派到对应切块器:结构、递归、语义、LLM。
currentChunks = switch (strategyType) {
case STRUCTURE -> applyStructureChunking(currentChunks, pipelineType);
case RECURSIVE -> applyRecursiveChunking(currentChunks, pipelineType);
case SEMANTIC -> applySemanticChunking(currentChunks, pipelineType);
case LLM -> applyLlmChunking(currentChunks, pipelineType);
};
// 每一步执行完立刻清洗,保证下游步骤不会处理无效或重复块。
currentChunks = cleanupChunkList(currentChunks);
}
return cleanupChunkList(currentChunks);
}
这个方法的设计非常简洁,但有几个关键点:
- 三次清洗:进入前清洗一次、每步执行后清洗一次、最终返回前再清洗一次。
cleanupChunkList()会去掉空文本块和重复块,保证每一步拿到的都是干净数据 - switch 分派:用 Java 17 的 switch 表达式按策略类型分派,四种策略各自独立实现,互不耦合
- 流水线语义:前一步的输出就是后一步的输入。比如方案配了"结构切块 → 递归切块",那结构切块产出的章节块会作为递归切块的输入,递归切块再把超长的章节块切成更小的片段
- pipelineType 透传:父块流水线和子块流水线共用同一个引擎,通过
pipelineType参数区分,各策略内部会根据这个参数选择不同的阈值(比如父块的 maxChars 通常比子块大)
策略一:结构切块(applyStructureChunking)
结构切块是四种策略中最"聪明"的一种——它不是机械地按长度切,而是利用文档的天然层级结构(标题、章节)来确定切块边界。
入口方法
DocumentStrategyServiceImpl.java — applyStructureChunking()
private List<ChunkCandidate> applyStructureChunking(List<ChunkCandidate> sourceList,
DocumentStrategyPipelineTypeEnum pipelineType) {
List<ChunkCandidate> resultList = new ArrayList<>();
for (ChunkCandidate candidate : sourceList) {
if (candidate == null || StrUtil.isBlank(candidate.getText())) {
continue;
}
// 每个输入块会保留自己的 sectionPath / sourceType 上下文,再拆成更细的结构块。
resultList.addAll(applyStructureChunking(
candidate.getText(),
pipelineType,
candidate.getSectionPath(),
candidate.getSourceType()
));
}
return resultList;
}
入口方法很简单,就是遍历每个候选块,把它的文本和上下文元数据传给真正的切块逻辑。
核心实现:按行扫描 + 标题识别
DocumentStrategyServiceImpl.java — applyStructureChunking()
private List<ChunkCandidate> applyStructureChunking(String parsedText,
DocumentStrategyPipelineTypeEnum pipelineType,
String baseSectionPath,
Integer sourceType) {
List<ChunkCandidate> candidateList = new ArrayList<>();
Deque<String> headingStack = new ArrayDeque<>();
StringBuilder currentChunk = new StringBuilder();
String currentSectionPath = StrUtil.blankToDefault(baseSectionPath, "");
for (String line : parsedText.split("\n")) {
String trimmed = line.trim();
DocumentLineClassifier.LineClassification classification = documentLineClassifier.classify(trimmed);
if (classification.isHeading()) {
// 新标题出现时,先把上一段正文落成一个 chunk,避免跨章节串块。
flushChunk(candidateList, currentSectionPath, sourceType, currentChunk);
// 根据标题层级回退标题栈,确保像 1 -> 1.1 -> 1.1.1 这样的路径始终正确闭合。
while (headingStack.size() >= classification.level()) {
headingStack.removeLast();
}
headingStack.addLast(classification.title());
// 重新计算当前块所属的完整 sectionPath,供后续检索和回显使用。
currentSectionPath = composeSectionPath(baseSectionPath, String.join(" > ", headingStack));
currentChunk.append(trimmed).append('\n');
continue;
}
// 非标题行继续并入当前章节块。
currentChunk.append(line).append('\n');
}
// 循环结束后别忘了把最后一个累积块冲刷出来。
flushChunk(candidateList, currentSectionPath, sourceType, currentChunk);
if (candidateList.isEmpty()) {
// 如果结构切块完全没有识别出有效边界,就退回递归切块兜底,避免整段文本丢失。
return applyRecursiveChunking(
List.of(new ChunkCandidate(baseSectionPath, parsedText, sourceType)),
pipelineType
);
}
return candidateList;
}
这个方法的核心思路是逐行扫描 + 标题栈维护,具体分为以下几步:
- 逐行扫描:把整段文本按换行符拆成行,逐行送入
DocumentLineClassifier做分类。分类器会判断每一行是标题、列表项还是普通正文 - 标题触发切块:每当遇到一个标题行,先调用
flushChunk()把之前累积的正文内容输出为一个 chunk,然后更新标题栈。这样就保证了每个 chunk 的内容都属于同一个章节,不会跨章节串块 - 标题栈维护:用一个
Deque<String>维护当前的标题层级路径。遇到新标题时,先根据标题层级回退栈(比如当前栈是[第一章, 1.1节, 1.1.1小节],遇到一个 2 级标题,就把栈回退到[第一章],再压入新标题),然后用composeSectionPath()把栈里的标题用>连接成完整路径 - 降级兜底:如果整段文本扫描完,一个标题都没识别出来(
candidateList为空),说明这段文本没有明显的结构特征,就自动降级到递归切块处理,避免整段文本丢失
下面用一个具体例子来说明结构切块的效果。假设输入文本是:
## 用户管理
用户管理模块负责用户的增删改查。
支持批量导入和导出功能。
### 用户注册
注册时需要验证手机号和邮箱。
密码必须包含大小写字母和数字。
### 用户登录
支持账号密码登录和第三方登录。
登录失败超过5次会锁定账号。
## 权限管理
权限管理基于 RBAC 模型实现。
结构切块会产出 4 个 chunk:
| chunk | sectionPath | 内容 |
|---|---|---|
| 1 | 用户管理 | ## 用户管理 + 两行正文 |
| 2 | 用户管理 > 用户注册 | ### 用户注册 + 两行正文 |
| 3 | 用户管理 > 用户登录 | ### 用户登录 + 两行正文 |
| 4 | 权限管理 | ## 权限管理 + 一行正文 |
可以看到,每个 chunk 都精确对齐到一个章节,sectionPath 完整记录了层级路径。
DocumentLineClassifier:标题识别器
结构切块的质量完全取决于标题识别的准确性。DocumentLineClassifier 就是负责这件事的支撑组件,它用一组正则表达式对每一行做轻量分类。
DocumentLineClassifier.java — classify()
public LineClassification classify(String line) {
String normalized = safeText(line);
if (normalized.isBlank()) {
return new LineClassification(LineKind.BODY, 0, normalized, normalized);
}
// 先识别 Markdown 风格标题,例如 "# 一级标题"、"## 二级标题"。
Matcher markdownMatcher = MARKDOWN_HEADING_PATTERN.matcher(normalized);
if (markdownMatcher.matches()) {
int level = markdownMatcher.group(1).length();
return heading(level, markdownMatcher.group(2).trim(), normalized);
}
// 再识别"附录 A / 附录一"这类常见附录标题,统一按一级标题处理。
Matcher appendixMatcher = APPENDIX_PATTERN.matcher(normalized);
if (appendixMatcher.matches()) {
return heading(1, normalized, normalized);
}
// "第 1 步 / 步骤 2"更适合作为步骤型列表项,而不是章节标题。
Matcher explicitStepMatcher = EXPLICIT_STEP_PATTERN.matcher(normalized);
if (explicitStepMatcher.matches()) {
return listItem(normalized);
}
// "第一章 / 第三节 / 第五条"这类中文章节标记,一般就是明确的结构标题。
Matcher chapterMatcher = CHINESE_CHAPTER_PATTERN.matcher(normalized);
if (chapterMatcher.matches()) {
return heading(2, normalized, normalized);
}
// "1.2 / 2.3.4" 这样的多级数字编号天然带层级,层级数直接由点号层数决定。
Matcher multiLevelDigitMatcher = MULTI_LEVEL_DIGIT_HEADING_PATTERN.matcher(normalized);
if (multiLevelDigitMatcher.matches()) {
String prefix = multiLevelDigitMatcher.group(1);
return heading(prefix.split("\\.").length, normalized, normalized);
}
// "一、xxx" 既可能是标题,也可能是列表项,所以还要看内容是否像标题。
Matcher chineseOutlineMatcher = CHINESE_OUTLINE_PATTERN.matcher(normalized);
if (chineseOutlineMatcher.matches()) {
String content = chineseOutlineMatcher.group(2).trim();
if (looksLikeHeadingContent(content)) {
return heading(1, normalized, normalized);
}
return listItem(normalized);
}
// "1、xxx / 1. xxx" 同样可能是标题或列表项,继续用启发式规则区分。
Matcher singleLevelDigitMatcher = SINGLE_LEVEL_DIGIT_LINE_PATTERN.matcher(normalized);
if (singleLevelDigitMatcher.matches()) {
String content = singleLevelDigitMatcher.group(2).trim();
if (looksLikeHeadingContent(content)) {
return heading(1, normalized, normalized);
}
return listItem(normalized);
}
// 常规无序列表前缀统一视为列表项。
if (normalized.startsWith("- ")
|| normalized.startsWith("* ")
|| normalized.startsWith("+ ")
|| normalized.startsWith("- [")
|| normalized.startsWith("* [")
|| normalized.startsWith("+ [")) {
return listItem(normalized);
}
// 以上规则都不命中时,默认当作普通正文。
return new LineClassification(LineKind.BODY, 0, normalized, normalized);
}
分类器的识别优先级从高到低依次是:
| 优先级 | 模式 | 示例 | 分类结果 |
|---|---|---|---|
| 1 | Markdown 标题 | ## 用户管理 |
HEADING (level=2) |
| 2 | 附录标题 | 附录 A 术语表 |
HEADING (level=1) |
| 3 | 明确步骤 | 第 1 步:安装依赖 |
LIST_ITEM |
| 4 | 中文章节 | 第三章 系统设计 |
HEADING (level=2) |
| 5 | 多级数字编号 | 2.3.1 接口定义 |
HEADING (level=3) |
| 6 | 中文大纲 | 一、项目背景 |
HEADING 或 LIST_ITEM |
| 7 | 单级数字编号 | 1、概述 |
HEADING 或 LIST_ITEM |
| 8 | 无序列表 | - 支持批量导入 |
LIST_ITEM |
| 9 | 默认 | 其他所有文本 | BODY |
其中第 6、7 两种模式比较特殊——"一、xxx"和"1、xxx"既可能是标题也可能是列表项。分类器用 looksLikeHeadingContent() 做进一步判断:如果编号后面的内容不超过 24 个字符、不以句号结尾、不包含逗号/分号等句内标点,就认为更像标题;否则当作列表项。
/**
* 判断一段编号后的内容更像“标题”还是“正文/列表项”。
*/
private boolean looksLikeHeadingContent(String content) {
String normalized = safeText(content);
if (normalized.isBlank()) {
return false;
}
// 标题通常不会以完整句号收尾;一旦像完整句子,就更像正文。
if (endsWithSentencePunctuation(normalized)) {
return false;
}
// 太长的内容一般不是标题,更可能是正文段落或带编号说明。
if (normalized.length() > 24) {
return false;
}
// 标题通常不会包含过多句内标点;这里用简单启发式过滤掉明显正文。
return !normalized.contains(",")
&& !normalized.contains(";")
&& !normalized.contains("。")
&& !normalized.contains(":");
}
flushChunk 与 composeSectionPath
结构切块过程中用到了两个工具方法:
DocumentStrategyServiceImpl.java — flushChunk()
/**
* 将当前累积文本刷新成一个 chunk 候选,并清空缓冲区。
*/
private void flushChunk(List<ChunkCandidate> candidateList,
String currentSectionPath,
Integer sourceType,
StringBuilder currentChunk) {
String text = currentChunk.toString().trim();
if (StrUtil.isNotBlank(text)) {
// 结构切块过程中生成的是“还未绑定结构节点 ID 的文本块”,
// 这里只保留 sectionPath 和 sourceType,后续再由上游逻辑决定如何使用。
candidateList.add(new ChunkCandidate(
currentSectionPath,
null,
null,
"",
null,
text,
sourceType == null ? DocumentChunkSourceTypeEnum.ORIGINAL.getCode() : sourceType
));
}
currentChunk.setLength(0);
}
flushChunk 的作用很简单:把 StringBuilder 里累积的文本输出为一个 ChunkCandidate,然后清空缓冲区。每次遇到新标题或文本扫描结束时都会调用它。
DocumentStrategyServiceImpl.java — composeSectionPath()
private String composeSectionPath(String baseSectionPath, String currentSectionPath) {
String normalizedBase = StrUtil.blankToDefault(baseSectionPath, "").trim();
String normalizedCurrent = StrUtil.blankToDefault(currentSectionPath, "").trim();
if (StrUtil.isBlank(normalizedBase)) {
return normalizedCurrent;
}
if (StrUtil.isBlank(normalizedCurrent)) {
return normalizedBase;
}
return normalizedBase + " > " + normalizedCurrent;
}
composeSectionPath 负责把基础路径和当前层级路径拼接起来。比如基础路径是 用户管理,当前标题栈拼出来的是 用户注册,最终就得到 用户管理 > 用户注册。
策略二:递归切块(applyRecursiveChunking)
递归切块是最"务实"的策略——它不关心文档的语义结构,只关心一件事:把超长文本切成不超过阈值的小块,同时尽量保留自然边界。
入口方法
DocumentStrategyServiceImpl.java — applyRecursiveChunking()
private List<ChunkCandidate> applyRecursiveChunking(List<ChunkCandidate> sourceList,
DocumentStrategyPipelineTypeEnum pipelineType) {
List<ChunkCandidate> resultList = new ArrayList<>();
// 父块和子块的最大长度、重叠大小允许不同,这里统一按流水线类型解析参数。
int maxChars = resolveRecursiveMaxChars(pipelineType);
int overlapChars = resolveRecursiveOverlap(maxChars, pipelineType);
for (ChunkCandidate candidate : sourceList) {
// 递归切块会尽量优先尊重段落、行、句子边界,再退化到固定窗口。
List<String> splitTextList = recursiveSplit(candidate.getText(), maxChars, overlapChars);
for (String splitText : splitTextList) {
resultList.add(cloneChunkCandidate(candidate, splitText));
}
}
return resultList;
}
入口方法先根据流水线类型解析两个关键参数:maxChars(单块最大字符数)和 overlapChars(相邻块的重叠字符数)。
/**
* 解析递归切块的最大长度。
*/
private int resolveRecursiveMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) {
return pipelineType == DocumentStrategyPipelineTypeEnum.PARENT
? PARENT_BLOCK_MAX_CHARS
: properties.getChunk().getRecursiveMaxChars();
}
textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy
/**
* 根据父/子流水线类型解析递归切块的 overlap 参数。
*/
private int resolveRecursiveOverlap(int maxChars, DocumentStrategyPipelineTypeEnum pipelineType) {
if (pipelineType == DocumentStrategyPipelineTypeEnum.PARENT) {
// 父块使用更保守的固定 overlap,避免父块之间上下文割裂太强。
return Math.min(PARENT_BLOCK_OVERLAP_CHARS, Math.max(0, maxChars - 1));
}
Integer configuredOverlap = properties.getChunk().getRecursiveOverlapChars();
if (configuredOverlap == null || configuredOverlap <= 0) {
return 0;
}
return Math.min(configuredOverlap, Math.max(0, maxChars - 1));
}
父块流水线用固定的大阈值(PARENT_BLOCK_MAX_CHARS)
子块流水线从配置文件读取。然后对每个候选块调用 recursiveSplit() 做实际拆分。
recursiveSplit:四级降级拆分
这是递归切块的核心算法,它的设计思路是:优先在最自然的边界处切开,切不开就退到下一级边界,直到最后用固定窗口硬切。
DocumentStrategyServiceImpl.java — recursiveSplit()
private List<String> recursiveSplit(String text, int maxChars, int overlapChars) {
String trimmed = text == null ? "" : text.trim();
if (StrUtil.isBlank(trimmed)) {
return List.of();
}
if (trimmed.length() <= maxChars) {
// 本身已经不超过阈值时,不需要继续拆。
return List.of(trimmed);
}
// 优先按空行分段,尽量保留自然段边界。
List<String> paragraphList = splitByRegex(trimmed, "\\n\\s*\\n");
if (paragraphList.size() > 1) {
return mergeAndSplit(paragraphList, maxChars, overlapChars);
}
// 没有段落边界时,退到逐行拆分。
List<String> lineList = splitByRegex(trimmed, "\\n");
if (lineList.size() > 1) {
return mergeAndSplit(lineList, maxChars, overlapChars);
}
// 再退一步,尝试按句号、问号、分号等句级边界拆分。
List<String> sentenceList = splitSentences(trimmed);
if (sentenceList.size() > 1) {
return mergeAndSplit(sentenceList, maxChars, overlapChars);
}
// 如果连句子边界都不可用,就只能用固定窗口硬切。
List<String> fixedWindowList = new ArrayList<>();
int start = 0;
int step = Math.max(1, maxChars - overlapChars);
while (start < trimmed.length()) {
int end = Math.min(trimmed.length(), start + maxChars);
fixedWindowList.add(trimmed.substring(start, end).trim());
if (end >= trimmed.length()) {
break;
}
start += step;
}
return fixedWindowList;
}
四级降级的优先级如下:
每一级拆分都不是简单地切开就完事,而是通过 mergeAndSplit() 做"先拆后合"——把文本按边界拆成小段后,再把相邻的小段合并到不超过 maxChars 为止。如果某个小段自身就超过 maxChars,就对它递归调用 recursiveSplit(),这就是"递归"名字的由来。
合并完成后,还会通过 applyOverlap() 在相邻块之间补充重叠前缀,减少边界处的信息损失。
用一个具体例子来说明。假设 maxChars=100,输入文本有 3 个自然段,分别是 80 字、150 字、60 字:
- 第一级尝试按空行分段,得到 3 个段落
mergeAndSplit处理:第 1 段(80 字)不超限,直接输出;第 2 段(150 字)超限,递归进入下一级- 第 2 段递归时,按行拆分,假设拆成 5 行,再合并到不超过 100 字
- 第 3 段(60 字)不超限,直接输出
overlap 的作用
重叠前缀的设计是为了解决"边界信息丢失"问题。比如一段话被切成两块,前一块的最后一句和后一块的第一句可能在语义上紧密相关。通过在后一块前面补上前一块尾部的一小段文本,检索时就能保留这种跨边界的上下文。
策略三:语义切块(applySemanticChunking)
语义切块比递归切块更"智能"一些——它不是按固定长度切,而是按主题相似度切。当连续的句子在讨论同一个话题时,它们会被合并到同一个块里;一旦话题发生跳变,就在跳变点切开。
入口方法
DocumentStrategyServiceImpl.java — applySemanticChunking()
private List<ChunkCandidate> applySemanticChunking(List<ChunkCandidate> sourceList,
DocumentStrategyPipelineTypeEnum pipelineType) {
List<ChunkCandidate> resultList = new ArrayList<>();
int semanticMinChars = resolveSemanticMinChars(pipelineType);
for (ChunkCandidate candidate : sourceList) {
if (StrUtil.isBlank(candidate.getText())
|| candidate.getText().length() <= semanticMinChars) {
// 文本太短时没有继续做语义切分的必要,直接原样保留。
resultList.add(candidate);
continue;
}
resultList.addAll(semanticSplit(candidate, pipelineType));
}
return resultList;
}
入口方法有个短路判断:如果候选块的文本长度不超过 semanticMinChars,就直接保留不切。这是因为太短的文本做语义切分没有意义,反而可能切得过碎。
semanticSplit:基于 Jaccard 相似度的句子累积
DocumentStrategyServiceImpl.java — semanticSplit()
private List<ChunkCandidate> semanticSplit(ChunkCandidate candidate,
DocumentStrategyPipelineTypeEnum pipelineType) {
List<ChunkCandidate> resultList = new ArrayList<>();
List<String> sentenceList = splitSentences(candidate.getText());
if (sentenceList.size() <= 1) {
resultList.add(candidate);
return resultList;
}
StringBuilder currentChunk = new StringBuilder();
Set<String> currentTokenSet = new LinkedHashSet<>();
int semanticMinChars = resolveSemanticMinChars(pipelineType);
int semanticMaxChars = resolveSemanticMaxChars(pipelineType);
for (String sentence : sentenceList) {
// 每个句子都会提取一组简化 token,用于估算它与当前块主题的相似度。
Set<String> sentenceTokenSet = extractTokens(sentence);
boolean exceedMaxChars = currentChunk.length() + sentence.length() > semanticMaxChars;
double similarity = currentTokenSet.isEmpty() ? 1D : jaccard(currentTokenSet, sentenceTokenSet);
// 只有在当前块已经达到最小长度后,才允许因为主题跳变而切块,
// 避免把前几句切得过碎。
boolean semanticBreak = currentChunk.length() >= semanticMinChars
&& similarity < properties.getChunk().getSemanticSimilarityThreshold();
if (currentChunk.length() > 0 && (exceedMaxChars || semanticBreak)) {
// 一旦命中"超长"或"主题跳变",就输出当前块并重置累计状态。
resultList.add(cloneChunkCandidate(candidate, currentChunk.toString().trim()));
currentChunk.setLength(0);
currentTokenSet.clear();
}
// 当前句子总会并入新的或已有的语义块中。
currentChunk.append(sentence);
currentTokenSet.addAll(sentenceTokenSet);
}
if (currentChunk.length() > 0) {
resultList.add(cloneChunkCandidate(candidate, currentChunk.toString().trim()));
}
return resultList;
}
这个方法的核心逻辑是逐句累积 + 双条件触发切块:
- 逐句扫描:先用
splitSentences()按句级标点(。!?!?;;.)把文本拆成句子列表 - 提取 token:对每个句子调用
extractTokens()提取简化 token 集合——英文按单词提取并转小写,中文按单字提取。这个 token 集合用来计算主题相似度 - Jaccard 相似度:用
jaccard()计算当前句子的 token 集合与当前累积块的 token 集合之间的 Jaccard 相似度。Jaccard 相似度 = 交集大小 / 并集大小,值域 [0, 1],越接近 1 说明两组 token 越相似 - 双条件触发:满足以下任一条件就切块——(a) 累积长度超过
semanticMaxChars(硬上限);(b) 累积长度已达到semanticMinChars且相似度低于阈值(主题跳变)。注意条件 (b) 有个前提:当前块必须先达到最小长度,这是为了避免前几句就因为微小的主题波动被切得过碎
用一个例子来说明。假设有 5 个句子,主题分别是 A、A、A、B、B:
句子1(主题A): "Spring Boot 是一个快速开发框架。"
句子2(主题A): "它简化了 Spring 应用的配置过程。"
句子3(主题A): "内置了 Tomcat 服务器,开箱即用。"
句子4(主题B): "MySQL 是最流行的关系型数据库。"
句子5(主题B): "它支持事务和索引优化。"
- 句子 1-3 的 token 集合相似度较高(都包含 Spring、框架等词),会被累积到同一个块
- 句子 4 的 token 集合与前面差异很大(MySQL、数据库 vs Spring、框架),Jaccard 相似度骤降,触发切块
- 最终产出两个块:
[句子1+2+3]和[句子4+5]
策略四:LLM 切块(applyLlmChunking)
LLM 切块是四种策略中最"重"的一种——它直接调用大模型来理解文本语义,让模型决定在哪里切块。效果通常最好,但成本也最高。
入口方法
DocumentStrategyServiceImpl.java — applyLlmChunking()
private List<ChunkCandidate> applyLlmChunking(List<ChunkCandidate> sourceList,
DocumentStrategyPipelineTypeEnum pipelineType) {
ChatModel chatModel = chatModelProvider.getIfAvailable();
if (!Boolean.TRUE.equals(properties.getChunk().getLlmEnabled()) || chatModel == null) {
// LLM 能力关闭或模型缺失时,直接降级到语义切块,保证链路仍然可执行。
return applySemanticChunking(sourceList, pipelineType);
}
List<ChunkCandidate> resultList = new ArrayList<>();
for (ChunkCandidate candidate : sourceList) {
if (StrUtil.isBlank(candidate.getText())) {
continue;
}
int llmMaxChars = resolveLlmMaxChars(pipelineType);
// 为了避免单次提示过长,超长文本会先按无重叠递归切开,再分别交给 LLM 处理。
List<String> sourceTextList = candidate.getText().length() > llmMaxChars
? recursiveSplit(candidate.getText(), llmMaxChars, 0)
: List.of(candidate.getText());
for (String sourceText : sourceTextList) {
List<String> llmChunkList = llmSplit(chatModel, sourceText);
if (llmChunkList.isEmpty()) {
// LLM 没给出可用结果时,单段文本回退到语义切块,避免整段失败。
resultList.addAll(semanticSplit(cloneChunkCandidate(candidate, sourceText), pipelineType));
continue;
}
// LLM 返回的每个文本片段都继承原候选块的上下文元数据。
for (String llmChunk : llmChunkList) {
resultList.add(cloneChunkCandidate(candidate, llmChunk));
}
}
}
return resultList;
}
这个方法有三层防护机制:
- 全局降级:方法一进来就检查 LLM 是否可用(配置开关 + ChatModel 实例)。如果不可用,整个方法直接降级到语义切块,不会报错
- 预切分:如果候选块文本超过
llmMaxChars,先用recursiveSplit()按无重叠方式切成小段,再逐段交给 LLM。这是为了避免单次 prompt 过长导致模型截断或质量下降 - 单段降级:即使 LLM 整体可用,某一段文本调用
llmSplit()失败(模型返回空或异常),也只是这一段降级到语义切块,不影响其他段
llmSplit:调用大模型切块
DocumentStrategyServiceImpl.java — llmSplit()
private List<String> llmSplit(ChatModel chatModel, String sourceText) {
String prompt = promptTemplateService.render(PromptTemplateNames.DOCUMENT_LLM_SPLIT, Map.of(
"sourceText", StrUtil.blankToDefault(sourceText, "")
));
try {
// 这里只接受纯内容返回值,后面还会进一步从中提取 JSON 数组。
String content = ChatClient.builder(chatModel)
.build()
.prompt()
.user(prompt)
.call()
.content();
if (StrUtil.isBlank(content)) {
return List.of();
}
// 有些模型会包裹解释文字或 markdown,这里只截取最外层 JSON 数组片段。
String jsonArray = extractJsonArray(content);
if (StrUtil.isBlank(jsonArray)) {
return List.of();
}
List<String> resultList = objectMapper.readValue(jsonArray, new TypeReference<List<String>>() {
});
return resultList.stream().filter(StrUtil::isNotBlank).map(String::trim).toList();
}
catch (Exception exception) {
log.warn("大模型智能切块失败,回退到语义切块", exception);
return List.of();
}
}
document-llm-split.st 模板提示词内容:
textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy
你是 RAG 文档切块助手。
请把下面文本切成适合知识检索的若干片段,并严格返回 JSON 数组字符串。
要求:
1. 每个片段尽量语义完整。
2. 不要输出解释文字。
3. 不要丢失原文关键信息。
4. 返回格式示例:["片段1","片段2"]
文本如下:
<sourceText>
llmSplit 的实现思路是:
- 构造 prompt:用
document-llm-split.st模板中的提示词,要求模型把文本切成适合知识检索的片段,并以 JSON 数组格式返回 - 调用模型:通过 Spring AI 的
ChatClient发起同步调用,拿到模型的文本响应 - 提取 JSON:模型返回的内容可能包含解释文字或 markdown 包裹,用
extractJsonArray()从中截取最外层的[...]部分 - 反序列化:用 Jackson 把 JSON 数组反序列化成
List<String>,过滤掉空白项 - 异常兜底:整个过程被 try-catch 包裹,任何异常都返回空列表,由上层方法降级到语义切块
LLM 切块的成本考量
LLM 切块每处理一段文本都要调用一次大模型 API,成本远高于其他三种策略。所以系统默认是关闭的(
llmEnabled=false),只有在配置显式开启、且文档内容质量较低(结构不清晰、标题缺失)时才建议使用。
cleanupChunkList:去重与清洗
在整个切块流程中,cleanupChunkList() 被反复调用——流水线入口清洗一次、每步执行后清洗一次、最终返回前再清洗一次。它是保证输出质量的最后一道防线。
DocumentStrategyServiceImpl.java — cleanupChunkList()
private List<ChunkCandidate> cleanupChunkList(List<ChunkCandidate> sourceList) {
Map<String, ChunkCandidate> uniqueMap = new LinkedHashMap<>();
for (ChunkCandidate candidate : sourceList) {
if (candidate == null || StrUtil.isBlank(candidate.getText())) {
continue;
}
String normalizedText = candidate.getText().trim();
// 用路径 + itemIndex + 标准化文本构造去重键,尽量保留同一位置的唯一语义块。
String uniqueKey = StrUtil.blankToDefault(candidate.getCanonicalPath(), candidate.getSectionPath())
+ "||" + candidate.getItemIndex()
+ "||" + normalizedText;
uniqueMap.putIfAbsent(uniqueKey, cloneChunkCandidate(candidate, normalizedText));
}
return new ArrayList<>(uniqueMap.values());
}
这个方法做了三件事:
- 去空:跳过
null和文本为空的候选块 - trim 标准化:对文本做
trim(),消除首尾空白差异 - 去重:用
LinkedHashMap按"路径 + 位置索引 + 标准化文本"构造去重键。LinkedHashMap保证了去重后仍然保持原始插入顺序,putIfAbsent保证同一个键只保留第一次出现的候选块
去重键的设计很讲究——不是简单地按文本去重,而是加上了路径和位置信息。这样即使两个不同章节恰好有相同的文本内容(比如都有一句"详见下文"),也不会被误去重。
参数解析方法
四种策略的阈值参数都通过一组 resolve* 方法统一管理,根据父块/子块流水线类型返回不同的值:
DocumentStrategyServiceImpl.java — 参数解析方法
private int resolveRecursiveMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) {
return pipelineType == DocumentStrategyPipelineTypeEnum.PARENT
? PARENT_BLOCK_MAX_CHARS
: properties.getChunk().getRecursiveMaxChars();
}
private int resolveRecursiveOverlap(int maxChars, DocumentStrategyPipelineTypeEnum pipelineType) {
if (pipelineType == DocumentStrategyPipelineTypeEnum.PARENT) {
return Math.min(PARENT_BLOCK_OVERLAP_CHARS, Math.max(0, maxChars - 1));
}
Integer configuredOverlap = properties.getChunk().getRecursiveOverlapChars();
if (configuredOverlap == null || configuredOverlap <= 0) {
return 0;
}
return Math.min(configuredOverlap, Math.max(0, maxChars - 1));
}
private int resolveSemanticMinChars(DocumentStrategyPipelineTypeEnum pipelineType) {
return pipelineType == DocumentStrategyPipelineTypeEnum.PARENT
? Math.max(PARENT_SEMANTIC_MIN_CHARS, properties.getChunk().getSemanticMinChars())
: properties.getChunk().getSemanticMinChars();
}
private int resolveLlmMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) {
return pipelineType == DocumentStrategyPipelineTypeEnum.PARENT
? Math.max(properties.getChunk().getLlmMaxChars(), PARENT_BLOCK_MAX_CHARS)
: properties.getChunk().getLlmMaxChars();
}
设计思路是:父块流水线用更大的阈值,子块流水线用配置文件里的标准值。这是因为父块本身就是"大块",需要更宽松的长度限制;而子块是最终用于检索的单元,需要更精细的控制。
四种策略对比总结
| 维度 | 结构切块 | 递归切块 | 语义切块 | LLM 切块 |
|---|---|---|---|---|
| 切块依据 | 标题/章节边界 | 文本长度 + 自然边界 | 主题相似度 | 大模型语义理解 |
| 适用场景 | 有清晰标题结构的文档 | 通用,任何文本 | 段落较多、主题明确 | 结构不清晰、质量较低 |
| 切块质量 | 高(对齐章节) | 中(保留自然边界) | 较高(语义连贯) | 最高(语义完整) |
| 执行成本 | 低(正则匹配) | 低(字符串操作) | 低(集合运算) | 高(API 调用) |
| 降级目标 | → 递归切块 | 无(自身是兜底) | 无 | → 语义切块 |
| 核心依赖 | DocumentLineClassifier | splitByRegex / splitSentences | Jaccard 相似度 | ChatModel |
💬 评论