四级切块策略
我们接着上一篇 “异步索引构建:初始化与切块执行” 往下看。
上一篇的终点是:
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]
参数选择直接决定切块效果,所以 semanticMinChars 和 threshold 都做成可配置项。
六、策略四: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-异步索引构建:初始化与切块执行
💬 评论