--- title: "03-四种切块策略详解" created: 2026-05-20 aliases: - 四种切块策略详解 tags: - 项目 --- # 四种切块策略详解 上一篇我们看到,不管是父块还是子块,最终都会走到 `executePipeline()` 这个流水线执行引擎。这篇我们就深入这个引擎内部,逐个拆解结构切块、递归切块、语义切块、LLM 切块四种策略的完整实现。 先上一张四种策略的全景关系图: ## 策略全景与降级关系 ![](https://article-images.zsxq.com/FqvJwVpItAbYSCEmbauMF7HgJ03f) > 流水线串行 vs 降级 > > 这张图里有两种说明。 > > 串行:方案里配了哪些步骤,就按顺序依次执行,前一步的输出是后一步的输入。 > > 降级:某种策略在执行过程中发现自己无法产出有效结果时,会自动退回到另一种策略兜底。这两种机制是独立的,降级发生在单个策略内部,不影响流水线的整体推进。 ## executePipeline:流水线执行引擎 这是所有切块策略的调度中心。它接收一组候选块和一组有序的策略步骤,然后按顺序逐步执行,每一步的输出作为下一步的输入。 **DocumentStrategyServiceImpl.java — executePipeline()** ```java private List executePipeline(List sourceList, List orderedSteps, DocumentStrategyPipelineTypeEnum pipelineType) { // 进入流水线前先清洗一次输入,保证每一步面对的都是可用候选块。 List 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()** ```java private List applyStructureChunking(List sourceList, DocumentStrategyPipelineTypeEnum pipelineType) { List 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()** ```java private List applyStructureChunking(String parsedText, DocumentStrategyPipelineTypeEnum pipelineType, String baseSectionPath, Integer sourceType) { List candidateList = new ArrayList<>(); Deque 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` 维护当前的标题层级路径。遇到新标题时,先根据标题层级回退栈(比如当前栈是 `[第一章, 1.1节, 1.1.1小节]`,遇到一个 2 级标题,就把栈回退到 `[第一章]`,再压入新标题),然后用 `composeSectionPath()` 把栈里的标题用 `>` 连接成完整路径 - **降级兜底**:如果整段文本扫描完,一个标题都没识别出来(`candidateList` 为空),说明这段文本没有明显的结构特征,就自动降级到递归切块处理,避免整段文本丢失 ![](https://article-images.zsxq.com/Fq65iHs5VUXQ_eiORGrLpje3Pg3K) 下面用一个具体例子来说明结构切块的效果。假设输入文本是: ```text ## 用户管理 用户管理模块负责用户的增删改查。 支持批量导入和导出功能。 ### 用户注册 注册时需要验证手机号和邮箱。 密码必须包含大小写字母和数字。 ### 用户登录 支持账号密码登录和第三方登录。 登录失败超过5次会锁定账号。 ## 权限管理 权限管理基于 RBAC 模型实现。 ``` 结构切块会产出 4 个 chunk: | chunk | sectionPath | 内容 | | --- | --- | --- | | 1 | 用户管理 | `## 用户管理` + 两行正文 | | 2 | 用户管理 > 用户注册 | `### 用户注册` + 两行正文 | | 3 | 用户管理 > 用户登录 | `### 用户登录` + 两行正文 | | 4 | 权限管理 | `## 权限管理` + 一行正文 | 可以看到,每个 chunk 都精确对齐到一个章节,`sectionPath` 完整记录了层级路径。 ### DocumentLineClassifier:标题识别器 结构切块的质量完全取决于标题识别的准确性。`DocumentLineClassifier` 就是负责这件事的支撑组件,它用一组正则表达式对每一行做轻量分类。 **DocumentLineClassifier.java — classify()** ```java 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 个字符、不以句号结尾、不包含逗号/分号等句内标点,就认为更像标题;否则当作列表项。 ```java /** * 判断一段编号后的内容更像“标题”还是“正文/列表项”。 */ 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()** ```java /** * 将当前累积文本刷新成一个 chunk 候选,并清空缓冲区。 */ private void flushChunk(List 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()** ```java 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()** ```java private List applyRecursiveChunking(List sourceList, DocumentStrategyPipelineTypeEnum pipelineType) { List resultList = new ArrayList<>(); // 父块和子块的最大长度、重叠大小允许不同,这里统一按流水线类型解析参数。 int maxChars = resolveRecursiveMaxChars(pipelineType); int overlapChars = resolveRecursiveOverlap(maxChars, pipelineType); for (ChunkCandidate candidate : sourceList) { // 递归切块会尽量优先尊重段落、行、句子边界,再退化到固定窗口。 List splitTextList = recursiveSplit(candidate.getText(), maxChars, overlapChars); for (String splitText : splitTextList) { resultList.add(cloneChunkCandidate(candidate, splitText)); } } return resultList; } ``` 入口方法先根据流水线类型解析两个关键参数:`maxChars`(单块最大字符数)和 `overlapChars`(相邻块的重叠字符数)。 ```java /** * 解析递归切块的最大长度。 */ private int resolveRecursiveMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) { return pipelineType == DocumentStrategyPipelineTypeEnum.PARENT ? PARENT_BLOCK_MAX_CHARS : properties.getChunk().getRecursiveMaxChars(); } ``` textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java /** * 根据父/子流水线类型解析递归切块的 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()** ```java private List 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 paragraphList = splitByRegex(trimmed, "\\n\\s*\\n"); if (paragraphList.size() > 1) { return mergeAndSplit(paragraphList, maxChars, overlapChars); } // 没有段落边界时,退到逐行拆分。 List lineList = splitByRegex(trimmed, "\\n"); if (lineList.size() > 1) { return mergeAndSplit(lineList, maxChars, overlapChars); } // 再退一步,尝试按句号、问号、分号等句级边界拆分。 List sentenceList = splitSentences(trimmed); if (sentenceList.size() > 1) { return mergeAndSplit(sentenceList, maxChars, overlapChars); } // 如果连句子边界都不可用,就只能用固定窗口硬切。 List 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; } ``` 四级降级的优先级如下: ![](https://article-images.zsxq.com/Fh1gCiP8yhMESoaCHm_Bnxd5Lqop) 每一级拆分都不是简单地切开就完事,而是通过 `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()** ```java private List applySemanticChunking(List sourceList, DocumentStrategyPipelineTypeEnum pipelineType) { List 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()** ```java private List semanticSplit(ChunkCandidate candidate, DocumentStrategyPipelineTypeEnum pipelineType) { List resultList = new ArrayList<>(); List sentenceList = splitSentences(candidate.getText()); if (sentenceList.size() <= 1) { resultList.add(candidate); return resultList; } StringBuilder currentChunk = new StringBuilder(); Set currentTokenSet = new LinkedHashSet<>(); int semanticMinChars = resolveSemanticMinChars(pipelineType); int semanticMaxChars = resolveSemanticMaxChars(pipelineType); for (String sentence : sentenceList) { // 每个句子都会提取一组简化 token,用于估算它与当前块主题的相似度。 Set 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: ```text 句子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]` ![](https://article-images.zsxq.com/FgpsY8WzvNV_EDTIzS6ZujZXGnrR) ## 策略四:LLM 切块(applyLlmChunking) LLM 切块是四种策略中最"重"的一种——它直接调用大模型来理解文本语义,让模型决定在哪里切块。效果通常最好,但成本也最高。 ### 入口方法 **DocumentStrategyServiceImpl.java — applyLlmChunking()** ```java private List applyLlmChunking(List sourceList, DocumentStrategyPipelineTypeEnum pipelineType) { ChatModel chatModel = chatModelProvider.getIfAvailable(); if (!Boolean.TRUE.equals(properties.getChunk().getLlmEnabled()) || chatModel == null) { // LLM 能力关闭或模型缺失时,直接降级到语义切块,保证链路仍然可执行。 return applySemanticChunking(sourceList, pipelineType); } List resultList = new ArrayList<>(); for (ChunkCandidate candidate : sourceList) { if (StrUtil.isBlank(candidate.getText())) { continue; } int llmMaxChars = resolveLlmMaxChars(pipelineType); // 为了避免单次提示过长,超长文本会先按无重叠递归切开,再分别交给 LLM 处理。 List sourceTextList = candidate.getText().length() > llmMaxChars ? recursiveSplit(candidate.getText(), llmMaxChars, 0) : List.of(candidate.getText()); for (String sourceText : sourceTextList) { List 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()` 失败(模型返回空或异常),也只是这一段降级到语义切块,不影响其他段 ![](https://article-images.zsxq.com/Fq7nK1w4PQpVkJjSZlK52v2eMp4v) ### llmSplit:调用大模型切块 **DocumentStrategyServiceImpl.java — llmSplit()** ```java private List 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 resultList = objectMapper.readValue(jsonArray, new TypeReference>() { }); 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 ```text 你是 RAG 文档切块助手。 请把下面文本切成适合知识检索的若干片段,并严格返回 JSON 数组字符串。 要求: 1. 每个片段尽量语义完整。 2. 不要输出解释文字。 3. 不要丢失原文关键信息。 4. 返回格式示例:["片段1","片段2"] 文本如下: ``` `llmSplit` 的实现思路是: - **构造 prompt**:用 `document-llm-split.st` 模板中的提示词,要求模型把文本切成适合知识检索的片段,并以 JSON 数组格式返回 - **调用模型**:通过 Spring AI 的 `ChatClient` 发起同步调用,拿到模型的文本响应 - **提取 JSON**:模型返回的内容可能包含解释文字或 markdown 包裹,用 `extractJsonArray()` 从中截取最外层的 `[...]` 部分 - **反序列化**:用 Jackson 把 JSON 数组反序列化成 `List`,过滤掉空白项 - **异常兜底**:整个过程被 try-catch 包裹,任何异常都返回空列表,由上层方法降级到语义切块 > LLM 切块的成本考量 > > LLM 切块每处理一段文本都要调用一次大模型 API,成本远高于其他三种策略。所以系统默认是关闭的(`llmEnabled=false`),只有在配置显式开启、且文档内容质量较低(结构不清晰、标题缺失)时才建议使用。 ## cleanupChunkList:去重与清洗 在整个切块流程中,`cleanupChunkList()` 被反复调用——流水线入口清洗一次、每步执行后清洗一次、最终返回前再清洗一次。它是保证输出质量的最后一道防线。 **DocumentStrategyServiceImpl.java — cleanupChunkList()** ```java private List cleanupChunkList(List sourceList) { Map 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 — 参数解析方法** ```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 | --- **企业级项目导航**:⬅️ [[02-初步父子切块|02-初步父子切块]] | 03-四种切块策略详解 | ➡️ [[04-四级切块策略|04-四级切块策略]]