四级切块策略

我们接着上一篇 “异步索引构建:初始化与切块执行” 往下看。

上一篇的终点是:

buildParentBlocks 把工作分成两层
父块种子 → buildParentSeedList
子块种子 → buildChildSeedList
两者最终都汇入同一个引擎:executePipeline

这一篇就是把这个引擎彻底拆开,看清楚四种切块策略到底怎么做、怎么相互衔接、怎么在出问题时降级兜底。

学完这一篇,你会理解:

1. executePipeline 怎么把一串 step 串成执行链
2. 四种策略各自的输入输出契约和实现细节
3. 结构切块如何用标题栈维护章节路径
4. 递归切块的四级降级算法和 overlap 设计
5. 语义切块为什么用 Jaccard 相似度而不是 embedding
6. LLM 切块的三层防护机制
7. cleanupChunkList 的去重键为什么这么设计
8. 父块和子块为什么阈值不一样

下一篇会进入“切块结果落库 + 向量化”的阶段。


一、整体认知:这一节在做什么

可以一句话概括:

executePipeline 是切块策略的统一调度引擎,按 step 顺序串行执行,每步的输出作为下一步的输入。结构切块用标题识别 + 标题栈维护章节路径,递归切块用四级降级(段落 → 行 → 句子 → 固定窗口)保证任何文本都能切到合规长度,语义切块用 Jaccard 相似度 + 双条件触发实现按主题切分,LLM 切块用大模型理解语义但成本最高且默认关闭。每种策略内部都有自己的降级路径,加上流水线层面的 cleanupChunkList 三次清洗,保证最终输出稳定可用。

整体关系图

flowchart TD
    A[executePipeline 引擎] --> B{strategyType?}
    B -->|STRUCTURE| C[applyStructureChunking]
    B -->|RECURSIVE| D[applyRecursiveChunking]
    B -->|SEMANTIC| E[applySemanticChunking]
    B -->|LLM| F[applyLlmChunking]

    C -.无标题识别.-> D
    F -.LLM 不可用 或 单段失败.-> E

    C --> G[cleanupChunkList]
    D --> G
    E --> G
    F --> G
    G --> H[下一步 step]

注意两种关系:

实线:流水线串行(step 之间的衔接)
虚线:策略内部降级(单个 step 内的兜底)

这两种机制独立运作,降级发生在策略内部,不影响外层流水线推进


二、executePipeline:流水线引擎

这是所有策略的调度中心。

private List<ChunkCandidate> executePipeline(List<ChunkCandidate> sourceList,
                                             List<...> orderedSteps,
                                             DocumentStrategyPipelineTypeEnum pipelineType) {
    List<ChunkCandidate> currentChunks = cleanupChunkList(sourceList);
    for (...step : orderedSteps) {
        DocumentStrategyTypeEnum strategyType = ...;
        if (strategyType == null) continue;
        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);
}

代码不长,但有四个值得讲的设计点。

1. 三次清洗:每步之间的"消毒环"

入口清洗一次:防御输入数据脏
每步后清洗一次:防止脏数据传给下一步
返回前清洗一次:保证输出干净

为什么这么频繁?

因为四种策略各自的实现独立,不知道彼此会产出什么样的边界情况:

结构切块可能产出空 chunk(标题之间没正文)
递归切块可能产出 trim 后为空的 chunk(纯空白段落)
LLM 切块可能产出乱码或重复

清洗放在中间,让每个策略只关心自己的核心逻辑,不需要互相防御。这是典型的**"调度层兜底,业务层专注"**的分层设计。

2. switch 表达式的优雅

currentChunks = switch (strategyType) {
    case STRUCTURE -> applyStructureChunking(...);
    ...
};

Java 17 的 switch 表达式让分派代码非常紧凑:

传统 switch:每个 case 后要写 break,冗长
switch 表达式:每个分支返回值,直接赋值
还能编译期检查所有枚举值是否覆盖

如果将来新增策略类型,IDE 立刻提示这里要补 case,不会漏。这是编译期安全的好处。

3. 流水线串行的语义

前一步输出 = 后一步输入:

方案: STRUCTURE → RECURSIVE
结构切块切出 5 个章节块 → 递归切块拿这 5 个块作为输入
其中超过 maxChars 的章节块会被递归切块再切小

这种串行让多步策略可以层层细化

第一步划定大边界(结构)
第二步控制长度(递归)
最终既保留语义结构,又不超长

4. pipelineType 透传

applyStructureChunking(currentChunks, pipelineType)

四个 apply 方法都接收 pipelineType。这个参数决定每种策略内部的阈值

父块流水线:maxChars 大(比如 4000)
子块流水线:maxChars 小(比如 800)

为什么要透传到每个策略?因为:

父块要保留大上下文,块大些
子块要精准检索,块小些
同一种策略在父块和子块下行为不同

如果不透传,要么写两个版本的策略(冗余),要么策略内部判断状态(复杂)。透传 pipelineType 是最轻量的做法。


三、策略一:结构切块

1. 入口方法:遍历分发

for (ChunkCandidate candidate : sourceList) {
    if (candidate == null || StrUtil.isBlank(candidate.getText())) continue;
    resultList.addAll(applyStructureChunking(
        candidate.getText(),
        pipelineType,
        candidate.getSectionPath(),
        candidate.getSourceType()
    ));
}

入口方法只负责展开——把每个候选块拆开传给真正的切块逻辑。注意三个元数据字段都被保留:

sectionPath:基础章节路径(用于和新切出的路径拼接)
sourceType:来源类型(原文 / 结构切块 / ...)
text:原始文本

这样切出的子块继承父块的上下文:如果父块本身已经在 第一章 下,子块切出的 1.1 节 最终路径是 第一章 > 1.1 节,而不是只有 1.1 节

2. 核心算法:逐行扫描 + 标题栈

这是结构切块最核心的代码。

Deque<String> headingStack = new ArrayDeque<>();
StringBuilder currentChunk = new StringBuilder();
String currentSectionPath = ...;

for (String line : parsedText.split("\n")) {
    String trimmed = line.trim();
    LineClassification classification = documentLineClassifier.classify(trimmed);
    if (classification.isHeading()) {
        flushChunk(...);  // 上一段落地
        while (headingStack.size() >= classification.level()) {
            headingStack.removeLast();
        }
        headingStack.addLast(classification.title());
        currentSectionPath = composeSectionPath(...);
        currentChunk.append(trimmed).append('\n');
        continue;
    }
    currentChunk.append(line).append('\n');
}
flushChunk(...);

为什么用 Deque(双端队列)而不是 List?

Deque 提供 addLast / removeLast 两个操作
天然适合栈语义(LIFO)
List 也能模拟栈但语义不直观

这种用接口表达意图的写法让代码读起来更清晰——一看 Deque + addLast/removeLast 就知道在维护栈。

为什么遇到标题先 flushChunk?
当前在累积 "第一章" 下面的正文
遇到 "1.1 节" 标题,说明第一章的正文部分结束
必须把累积的正文落成一个 chunk,清空缓冲区
然后才开始累积 "1.1 节" 下面的正文

如果不 flush,两个章节的正文会被串到一起,后续的 sectionPath 也会错乱。

标题栈回退算法
while (headingStack.size() >= classification.level()) {
    headingStack.removeLast();
}
headingStack.addLast(classification.title());

这是栈维护的核心逻辑。举例:

当前栈:[第一章, 1.1节, 1.1.1小节]  栈大小 3
遇到 "1.2 节" 二级标题,level=2

while 循环:
    栈大小 3 >= 2 → 弹出 1.1.1小节,栈变 [第一章, 1.1节]
    栈大小 2 >= 2 → 弹出 1.1节,栈变 [第一章]
    栈大小 1 < 2 → 退出循环
压入 1.2节,栈变 [第一章, 1.2节]

为什么是 >= 而不是 >

遇到同级标题(比如另一个二级标题),也要弹出当前同级的标题再压入新的
否则同级标题会被嵌套成父子关系,路径错乱

这个细节非常关键,少一个 = 整个路径就乱套。

栈 + 路径的优雅之处

普通的"按章节切块"很多人会写成:

正则匹配所有标题
按标题位置切片
拼接路径

但这种写法处理不好层级关系。栈算法的优雅在于:

栈本身就编码了层级:栈底是顶层标题,栈顶是当前最深层
弹出 + 压入两个操作就能维护任意复杂的嵌套结构
路径 = String.join(" > ", headingStack)

这是用合适的数据结构让算法变简单的典型案例。

3. 降级兜底:无标题时退到递归切块

if (candidateList.isEmpty()) {
    return applyRecursiveChunking(
        List.of(new ChunkCandidate(baseSectionPath, parsedText, sourceType)),
        pipelineType
    );
}

什么时候会进入这个分支?

整段文本扫描完一个标题都没识别出来
比如纯散文、会议纪要、聊天记录




为什么降级到递归而不是直接返回原文?

直接返回原文:整段文本变一个超大块,可能超 maxChars
降级到递归:至少能保证长度合规

这种单策略内部的降级是这套切块系统的核心特征。每种策略都有"自己搞不定时怎么办"的预案。

4. DocumentLineClassifier:9 级正则识别

标题识别的质量决定了结构切块的质量。DocumentLineClassifier 用一组正则按优先级匹配。

优先级设计的考量
1. Markdown 标题(##)优先:最明确
2. 附录:特殊格式,容易识别
3. "第 1 步":先排除步骤型,避免误判为标题
4. 中文章节(第 X 章):非常明确
5. 多级数字编号(1.2.3):层级天然
6. 中文大纲(一、):需要启发式判断
7. 单级数字(1、):需要启发式判断
8. 无序列表(- 开头):明确不是标题
9. 默认:正文

为什么"第 1 步"要先于"第 X 章"识别?

"第 1 步" 也匹配 "第 X 章" 的部分模式
如果先识别为章节,会把步骤误标为标题
所以必须先排除步骤,再考虑章节

正则优先级的顺序在这种模糊匹配场景下极其重要。

looksLikeHeadingContent:启发式判断
if (endsWithSentencePunctuation(normalized)) return false;
if (normalized.length() > 24) return false;
return !contains(",;。:");

一、xxx 这种格式既可能是标题也可能是列表项,怎么区分?

"一、项目背景" → 短、无标点 → 标题
"一、本项目的主要目标是,通过引入新框架..." → 长、有逗号 → 列表项

三个启发式条件:

1. 不以句末标点结尾(标题不是完整句子)
2. 长度 ≤ 24 字符(标题简短)
3. 不含逗号/分号/句号/冒号(标题不复杂)

这是经验主义工程:没有完美算法,但 95% 场景下能区分对。剩下 5% 错误代价低(最多多切几块或少切几块),可以接受。

正则模式的取舍

为什么不用 NLP 模型识别标题?

轻量级:正则毫秒级,模型百毫秒级
确定性:正则结果可复现,模型有概率性
可调试:正则规则一目了然,模型黑盒

对于"标题识别"这种结构化模式任务,正则的性价比远高于模型。模型适合做语义理解,正则适合做模式匹配,各司其职。

5. flushChunk 和 composeSectionPath

private void flushChunk(...) {
    String text = currentChunk.toString().trim();
    if (StrUtil.isNotBlank(text)) {
        candidateList.add(new ChunkCandidate(...));
    }
    currentChunk.setLength(0);
}
trim 的位置

trim 在 flushChunk 里做,而不是每次 append 时做。原因:

append 时每次 trim 浪费 CPU(StringBuilder 反复操作)
flushChunk 是边界事件,频率低,trim 一次代价小

这是热路径优化的常见思路:把昂贵操作放在低频路径。

setLength(0) 而不是 new StringBuilder()
currentChunk.setLength(0);
new StringBuilder():创建新对象,GC 压力
setLength(0):复用底层 char[] 数组,几乎零开销

对于循环里反复创建的 StringBuilder,setLength 是标准优化。

6. 实战例子

输入:
## 用户管理
用户管理模块负责用户的增删改查。

### 用户注册
注册时需要验证手机号。

## 权限管理
权限管理基于 RBAC 模型。

执行轨迹:
行 1:## 用户管理 → heading(level=2)
    flushChunk:无内容,跳过
    栈[]+用户管理→栈[用户管理]
    sectionPath=用户管理
    chunk缓冲:## 用户管理\n
行 2:用户管理模块... → body
    chunk缓冲追加
行 3:空行 → body
    chunk缓冲追加(实际是空白)
行 4:### 用户注册 → heading(level=3)
    flushChunk:产出 chunk1(sectionPath=用户管理, 内容=## 用户管理\n用户管理模块...\n)
    栈[用户管理]→栈[用户管理, 用户注册]
    sectionPath=用户管理 > 用户注册
行 5:注册时... → body
    chunk缓冲追加
行 6:## 权限管理 → heading(level=2)
    flushChunk:产出 chunk2(sectionPath=用户管理 > 用户注册, 内容=...)
    栈[用户管理, 用户注册] 弹出到栈[]→栈[权限管理]
    sectionPath=权限管理
行 7:权限管理基于... → body
末尾 flushChunk:产出 chunk3

最终产出 3 个 chunk,每个都精确对齐章节边界。


四、策略二:递归切块

递归切块是最不智能但最可靠的策略。它放弃理解语义,只保证一件事:把文本切到不超过阈值,同时尽量保留自然边界

1. 入口和参数解析

int maxChars = resolveRecursiveMaxChars(pipelineType);
int overlapChars = resolveRecursiveOverlap(maxChars, pipelineType);
父子块阈值差异
return pipelineType == PARENT
    ? PARENT_BLOCK_MAX_CHARS
    : properties.getChunk().getRecursiveMaxChars();
父块:用代码常量(通常较大,比如 4000)
子块:用配置文件(通常较小,比如 800-1000)
overlap 参数设计

父块 overlap 用固定值,子块从配置读:

父块 overlap:保守,因为父块本身大,过多重叠浪费空间
子块 overlap:可配置,产品可以根据召回效果调

maxChars - 1 的边界保护

return Math.min(configuredOverlap, Math.max(0, maxChars - 1));

防止 overlap >= maxChars 的配置错误:

overlap = maxChars → step = 0 → 死循环
overlap > maxChars → 逻辑混乱
所以强制 overlap <= maxChars - 1

这是防御性编程的体现——对配置不信任。

2. recursiveSplit:四级降级

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;
为什么要四级降级?

不同的边界对人类阅读的友好程度不同:

段落:最自然(空行天然分隔语义单元)
行:中等(代码、列表场景下常用)
句子:较自然(句号是语义边界)
固定窗口:最差(可能切在词中间)

优先用最自然的边界,实在不行才硬切。这种思想保证了切块结果对用户的可读性

每一级的判断:size > 1
if (paragraphList.size() > 1) ...

为什么是 > 1 而不是 >= 1

size = 1 表示这种边界拆不开(整段文本只有一段)
size > 1 才说明拆分有效,可以进入合并阶段
splitByRegex("整段无空行的文本", "\n\\s*\n") → ["整段无空行的文本"]  size=1
此时段落级拆不开,要降到行级




mergeAndSplit:先拆后合

虽然代码没贴,但根据语义能推出:

1. 把拆出的小段按顺序遍历
2. 累积到 currentBuffer 直到接近 maxChars
3. 输出 currentBuffer,开始新一轮
4. 单个小段超 maxChars → 递归调用 recursiveSplit 处理

为什么不直接返回拆出的小段?

按段落拆出的段落可能太短(几个字)
需要合并到 maxChars 附近,提高 chunk 利用率

这是逆向思维:先按边界拆碎,再合并到合适大小。比直接按 maxChars 切更能保留自然边界。

递归发生在哪一步?
某段超过 maxChars → 递归 recursiveSplit
递归时这段文本作为新输入,从段落级开始重新降级
最终一定能切到合规长度

这就是"递归切块"名字的来源。

3. 固定窗口的 step 计算

int step = Math.max(1, maxChars - overlapChars);
maxChars=100, overlapChars=20
step = 80
窗口 1:[0, 100)
窗口 2:[80, 180)
窗口 3:[160, 260)
...

相邻窗口重叠 20 字符。这就是 overlap 的实现。

Math.max(1, ...) 的保护
万一配置错误导致 step 为 0 或负数 → 死循环
强制 step >= 1 保证一定能推进

4. overlap 的工程意义

考虑场景:

原文:"...为了实现这个目标,我们需要一个高性能的索引系统。该系统主要包含三个模块..."
切块在 "我们需要一个高性能的索引系统。" 后
块 A 结束于 "...索引系统。"
块 B 开始于 "该系统主要包含三个模块..."

如果用户搜 "高性能索引系统包含哪些模块":

块 A 包含 "高性能索引系统" 但缺 "包含哪些模块"
块 B 包含 "包含三个模块" 但缺 "高性能索引系统"
两块单独看都不够全

加 overlap 后:

块 B 开头加上 "...索引系统。" 这段重叠
块 B = "我们需要一个高性能的索引系统。该系统主要包含三个模块..."
现在能完整匹配查询

overlap 是用空间换召回率的经典手段。代价是向量库存储多 ~20%,回报是边界附近的内容不会丢。


五、策略三:语义切块

1. 入口短路

if (StrUtil.isBlank(candidate.getText())
    || candidate.getText().length() <= semanticMinChars) {
    resultList.add(candidate);
    continue;
}

文本太短直接保留不切。原因:

语义切块的精度建立在"有足够多的句子"上
太短的文本只有几句,切了会过碎
不如保留原貌让递归切块兜底

这是该策略的"我搞不定"提前返回

2. semanticSplit:Jaccard + 双条件

核心思路
逐句扫描
每句提取 token(英文按词,中文按字)
计算当前句子和累积块的 Jaccard 相似度
满足切块条件就切
Jaccard 相似度
Jaccard(A, B) = |A ∩ B| / |A ∪ B|
A = {Spring, Boot, 框架}  (来自累积块)
B = {Spring, 简化, 配置}  (来自新句子)
交集 = {Spring},大小 1
并集 = {Spring, Boot, 框架, 简化, 配置},大小 5
Jaccard = 1/5 = 0.2

值域 [0, 1],越接近 1 越相似。

为什么用 Jaccard 而不是 embedding?

理论上 embedding 更精准(理解语义而非词形),但工程上 Jaccard 有几个不可替代的优势:

1. 零依赖:不需要 embedding 模型
2. 极快:几十微秒,embedding 几十毫秒
3. 确定性:同样的输入永远同样的输出
4. 成本零:embedding 调用要钱

精度 vs 成本的权衡:

Jaccard 精度差一些,但够用
embedding 精度好,但贵且慢
对"语义切块"这种轻量边界判断,Jaccard 性价比更高

如果对精度有更高要求,方案里可以配 LLM 切块(成本高但效果最好)。语义切块填的是中间档:比纯字符切聪明,比 LLM 便宜。

双条件触发:exceedMaxChars OR semanticBreak
boolean exceedMaxChars = currentChunk.length() + sentence.length() > semanticMaxChars;
boolean semanticBreak = currentChunk.length() >= semanticMinChars
    && similarity < threshold;

if (currentChunk.length() > 0 && (exceedMaxChars || semanticBreak)) {
    // 切块
}

两个条件:

exceedMaxChars:硬上限,防止块无限增长
semanticBreak:软边界,主题跳变时切
semanticBreak 的双重门槛
currentChunk.length() >= semanticMinChars && similarity < threshold

为什么不是单独的 similarity < threshold 就切?

设想:开头两个句子主题不同
句子1:"项目概述"
句子2:"具体实现细节"
Jaccard 相似度可能很低
如果立即切,得到一个只含 1 句的块,太碎

加上 currentChunk.length() >= semanticMinChars 的门槛:

只有累积长度达到最小阈值后,才允许因为主题跳变切块
保证每个块至少有"足够内容"

这是精度 vs 大小的平衡。

空块时的相似度
double similarity = currentTokenSet.isEmpty() ? 1D : jaccard(...);

累积块为空时,相似度强制为 1(最相似)。原因:

空块刚开始累积 → 不应该立刻触发切块
让相似度=1 → semanticBreak 永远 false → 第一句一定会被加进来

这种边界值的特殊处理让算法在所有场景都正常推进。

3. 算法步进可视化

输入句子:
S1:"Spring Boot 是一个快速开发框架。"  tokens={spring,boot,快速,开发,框架}
S2:"它简化了 Spring 应用的配置过程。"  tokens={简化,spring,应用,配置,过程}
S3:"内置了 Tomcat 服务器,开箱即用。"   tokens={内置,tomcat,服务器,开箱,即用}
S4:"MySQL 是最流行的关系型数据库。"    tokens={mysql,流行,关系型,数据库}
S5:"它支持事务和索引优化。"            tokens={支持,事务,索引,优化}

minChars=80, maxChars=400, threshold=0.1

S1:currentChunk 为空,similarity=1
    无切块,加入 S1
    currentChunk 长度 ≈ 18,tokenSet={spring,boot,快速,开发,框架}

S2:18 字 < minChars=80,即使主题跳也不切
    similarity = jaccard({spring,boot,快速,开发,框架}, {简化,spring,应用,配置,过程})
              = |{spring}| / |{spring,boot,快速,开发,框架,简化,应用,配置,过程}|
              = 1/9 ≈ 0.11
    threshold=0.1, 0.11 > 0.1, semanticBreak=false
    加入 S2,长度变 ≈ 35

S3:35 字 < 80,继续累积
    加入 S3,长度变 ≈ 50

S4:50 字 < 80,继续累积(即使 token 完全不同)
    加入 S4,长度变 ≈ 67

S5:67 字 < 80,继续累积
    加入 S5,长度变 ≈ 80
末尾 flush 输出整块

注意上面的例子里 minChars=80 偏大,导致全部累积到一起。如果 minChars=30:

S1+S2+S3 合计 ≈ 50 字,超过 minChars=30
S4 进来,similarity 降到 ≈ 0.05(因为 mysql 等完全不同)
触发切块!输出 chunk1=[S1+S2+S3]
重置缓冲,加入 S4
S5 加入
末尾 flush 输出 chunk2=[S4+S5]

参数选择直接决定切块效果,所以 semanticMinCharsthreshold 都做成可配置项。


六、策略四:LLM 切块

LLM 切块是最重也最贵的策略。

1. 三层防护机制

// 第一层:全局降级
if (!llmEnabled || chatModel == null) {
    return applySemanticChunking(sourceList, pipelineType);
}

// 第二层:预切分
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()) {
        resultList.addAll(semanticSplit(...));  // 单段失败降级
        continue;
    }
    ...
}

第一层:配置开关 + 模型实例检查

llmEnabled = false → LLM 关闭,直接走语义
chatModel = null → 模型实例没注入,直接走语义
任一不满足都降级

为什么需要这个?

开发环境可能没配 LLM,代码不能崩
线上 LLM 限流时可能临时关闭
模型实例可能因 SpringContext 未就绪为 null

容错优先:宁可降级到次优策略,也不让任务失败。

第二层:预切分
recursiveSplit(candidate.getText(), llmMaxChars, 0)

注意 overlap=0:

LLM 切块的预切分纯粹为了控制 prompt 长度
不需要 overlap(后面 LLM 会重新组织边界)

为什么要预切分?

LLM 的 context window 有限(比如 32K tokens)
单段过长 → prompt 超限被截断
切成 llmMaxChars 大小的段(比如 4000 字符) → 安全
第三层:单段降级
if (llmChunkList.isEmpty()) {
    resultList.addAll(semanticSplit(...));
    continue;
}

某段调用失败不影响其他段:

段 1 调用成功 → 用 LLM 结果
段 2 LLM 超时返回空 → 这段降级到语义
段 3 调用成功 → 用 LLM 结果
最终结果 = 段1的LLM切块 + 段2的语义切块 + 段3的LLM切块

精细化降级让 LLM 的不稳定性不会拖垮整体。

2. llmSplit 实现

String prompt = promptTemplateService.render(
    PromptTemplateNames.DOCUMENT_LLM_SPLIT,
    Map.of("sourceText", ...)
);
String content = ChatClient.builder(chatModel).build().prompt().user(prompt).call().content();
String jsonArray = extractJsonArray(content);
List<String> resultList = objectMapper.readValue(jsonArray, ...);

prompt 设计:三个关键约束

1. 严格返回 JSON 数组字符串(机器可解析)
2. 不要输出解释文字(避免污染响应)
3. 不要丢失原文关键信息(防止过度删减)

prompt engineering 的核心是降低不确定性

明确格式要求
明确输出范围
给具体示例(["片段1","片段2"])

extractJsonArray:抗污染

LLM 可能返回:
"以下是切分结果:\n```json\n[\"片段1\",\"片段2\"]\n```"

直接 readValue 会失败。extractJsonArray 用正则或字符匹配从中提取最外层 [...]

找到第一个 [
找到匹配的 ]
返回中间内容

这是LLM 输出后处理的常见模式:再严格的 prompt 也无法保证 LLM 100% 听话,必须有客户端清洗层。

try-catch 兜底
catch (Exception exception) {
    log.warn("大模型智能切块失败,回退到语义切块", exception);
    return List.of();
}

任何异常都返回空列表:

模型调用超时 → 空列表 → 上层降级
JSON 解析失败 → 空列表 → 上层降级
模型返回空 → 空列表 → 上层降级

统一通过空列表传递失败信号,让上层逻辑只判断一次就够。这种契约式设计比抛各种异常类型简洁。

3. 成本与适用性

1 份文档 = 多个段落
每段 → 1 次 LLM 调用
按 GPT-4 价格,1 万字文档 LLM 切块 ≈ ¥0.5

这个成本对个人产品可能高,对企业知识库可接受。所以系统默认 llmEnabled=false,由用户在重要文档上显式开启


七、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();
        String uniqueKey = StrUtil.blankToDefault(candidate.getCanonicalPath(), candidate.getSectionPath())
            + "||" + candidate.getItemIndex()
            + "||" + normalizedText;
        uniqueMap.putIfAbsent(uniqueKey, cloneChunkCandidate(candidate, normalizedText));
    }
    return new ArrayList<>(uniqueMap.values());
}

去重键设计的精妙

canonicalPath + "||" + itemIndex + "||" + normalizedText

三个维度组合:

为什么不只用 text?
不同章节可能有相同文本(比如都有"详见下文")
全局按 text 去重 → 这两段被合并
失去章节区分
为什么要 path + itemIndex?
path:章节维度,区分不同位置
itemIndex:章节内位置,区分同章节多段相同文本
text:内容本身
三者组合:"哪个位置的什么内容"
|| 分隔符的选择

为什么是 || 而不是 _:

内容里可能含 _ 或 : (比如 "1.1 节" 包含空格冒号)
|| 在自然文本中极少出现,冲突概率低

这是分隔符选择的常见做法:选不太可能在数据里出现的字符。

LinkedHashMap 保序

new LinkedHashMap<String, ChunkCandidate>()

为什么不用 HashMap?

HashMap:去重正确,但顺序不稳定
LinkedHashMap:去重 + 保留插入顺序

切块结果的顺序对后续阶段很关键:

向量化按顺序处理
检索时 chunk 顺序影响展示
日志和调试依赖稳定顺序

LinkedHashMap 多消耗一点内存换稳定性,绝对值得。

putIfAbsent 保留首次出现

uniqueMap.putIfAbsent(uniqueKey, ...);
put:重复时覆盖
putIfAbsent:重复时跳过

为什么保留第一次而不是最后一次?

按流水线顺序,先出现的通常来自更"自然"的策略(结构 → 递归 → 语义 → LLM)
后出现的可能是兜底产物
保留先出现的 = 优先用语义更好的版本

这种默认策略让重复时的选择有合理偏向。


八、参数解析:父子块的阈值差异

private int resolveRecursiveMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) {
    return pipelineType == PARENT
        ? PARENT_BLOCK_MAX_CHARS
        : properties.getChunk().getRecursiveMaxChars();
}

1. 父块用代码常量,子块用配置

父块 maxChars:代码常量(比如 4000)
子块 maxChars:application.yml 配置(比如 800)

为什么这样区分?

父块阈值改动很少:它是架构级参数,改动影响大
子块阈值经常调:产品根据召回效果迭代
代码常量 = 稳定不变
配置文件 = 灵活可调
不在配置里也定义父块阈值?

可以,但工程上故意让父块阈值"不那么容易改":

父块阈值改动 → 影响 RAG 整体架构
子块阈值改动 → 只影响召回精度
重要程度不同,改动门槛也应该不同

2. 父子语义阈值的差异

private int resolveSemanticMinChars(DocumentStrategyPipelineTypeEnum pipelineType) {
    return pipelineType == PARENT
        ? Math.max(PARENT_SEMANTIC_MIN_CHARS, properties.getChunk().getSemanticMinChars())
        : properties.getChunk().getSemanticMinChars();
}

Math.max 取较大值的意义:

父块 semanticMinChars 至少是 PARENT_SEMANTIC_MIN_CHARS
即使配置文件配得更小,也不会低于这个底线

3. LLM maxChars 的特殊处理

private int resolveLlmMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) {
    return pipelineType == PARENT
        ? Math.max(properties.getChunk().getLlmMaxChars(), PARENT_BLOCK_MAX_CHARS)
        : properties.getChunk().getLlmMaxChars();
}

父块的 LLM 阈值 = max(配置值, 父块阈值)。这保证:

LLM 切块的预切分长度 >= 父块目标长度
否则 LLM 切完的子段会比父块小,语义不完整

每个 resolve* 方法背后都有对应策略的语义考量,不是简单的配置读取。


九、四种策略的工程哲学

把四种策略放一起对比,能看出几个贯穿始终的设计哲学。

1. 策略各有所长,组合使用

结构切块:利用文档天然边界,精度最高(对结构化文档)
递归切块:无任何前提,纯长度控制,通用兜底
语义切块:轻量主题判断,精度中等成本低
LLM 切块:最强语义理解,精度最高成本最贵

没有哪种策略适合所有场景,所以方案推荐阶段会组合使用

有结构 → 结构 + 递归
质量好 → 语义 + 递归
质量差 → LLM + 递归

2. 多层降级保证可用性

结构 → 无标题降级到递归
LLM → 不可用 / 单段失败 → 语义
语义 → 文本太短 → 原样保留
递归 → 自身就是兜底,不再降

整个降级网形成"所有路径最终都能产出结果"的保证。

3. 父子块阈值差异化

父块大,保留上下文
子块小,提升召回精度
通过 pipelineType 透传统一管理

4. 自然边界优先

递归切块四级:段落 > 行 > 句子 > 固定窗口
语义切块按句:不在词中间切
LLM 切块:让模型选择最合理的边界

人类可读 = 检索友好。切在段落边界的块比切在词中间的块更容易理解。

5. 元数据贯穿全流程

sectionPath:从结构切块产生,后续策略原样保留
sourceType:标记来源,便于追溯
itemIndex / canonicalPath:用于去重和定位

每次切块都通过 cloneChunkCandidate 保留元数据,不丢失上下文信息。


十、整段流程串成一句话

executePipeline 接收一组候选块和有序 step 列表,按顺序对每个 step 用 switch 表达式分派到四种策略之一并把结果作为下一步输入,中间用 cleanupChunkList 三次清洗保证数据干净;结构切块用 DocumentLineClassifier 的 9 级正则(Markdown / 附录 / 步骤 / 中文章节 / 多级数字 / 中文大纲 / 单级数字 / 无序列表 / 默认) + looksLikeHeadingContent 启发式区分逐行分类,遇到标题就 flushChunk 输出累积块、按 level 弹栈再压入新标题、用 composeSectionPath 拼出完整路径,识别不出标题就降级到递归切块;递归切块按段落 → 行 → 句子 → 固定窗口四级降级,每级用 mergeAndSplit 先拆后合并到 maxChars 附近,单段超长就递归调用,固定窗口用 maxChars - overlap 步进,父块用代码常量子块从配置读;语义切块逐句扫描提取 token 集合,用 Jaccard 相似度 + "超长" OR "已达 minChars 且主题跳变"双条件触发切块,空块时相似度强制为 1 保证第一句必加入,文本太短直接返回不切;LLM 切块三层防护——配置/模型不可用全局降级到语义、超长文本预切分(overlap=0)、单段调用失败降级到语义,llmSplit 渲染 prompt 用 ChatClient 同步调用、extractJsonArray 抗模型输出污染、Jackson 反序列化、try-catch 统一返回空列表;cleanupChunkList 用 "canonicalPath || itemIndex || normalizedText" 三维去重键 + LinkedHashMap 保序 + putIfAbsent 保留先到的版本,既去空也去重还稳序;参数通过 resolveRecursive/Semantic/Llm 系列方法按 pipelineType 区分父子块阈值,父块用更大的代码常量保证回答上下文,子块用配置文件方便产品调优。


企业级项目导航:⬅️ 03-四种切块策略详解 | 04-四级切块策略 | ➡️ 05-异步索引构建:初始化与切块执行