四种切块策略详解

上一篇我们看到,不管是父块还是子块,最终都会走到 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

企业级项目导航:⬅️ 02-初步父子切块 | 03-四种切块策略详解 | ➡️ 04-四级切块策略