--- title: "04-四级切块策略" created: 2026-05-20 aliases: - 四级切块策略 tags: - 项目 --- # 四级切块策略 我们接着上一篇 “异步索引构建:初始化与切块执行” 往下看。 上一篇的终点是: ```text buildParentBlocks 把工作分成两层 父块种子 → buildParentSeedList 子块种子 → buildChildSeedList 两者最终都汇入同一个引擎:executePipeline ``` 这一篇就是把这个引擎彻底拆开,看清楚四种切块策略到底怎么做、怎么相互衔接、怎么在出问题时降级兜底。 学完这一篇,你会理解: ```text 1. executePipeline 怎么把一串 step 串成执行链 2. 四种策略各自的输入输出契约和实现细节 3. 结构切块如何用标题栈维护章节路径 4. 递归切块的四级降级算法和 overlap 设计 5. 语义切块为什么用 Jaccard 相似度而不是 embedding 6. LLM 切块的三层防护机制 7. cleanupChunkList 的去重键为什么这么设计 8. 父块和子块为什么阈值不一样 ``` 下一篇会进入“切块结果落库 + 向量化”的阶段。 --- ### **一、整体认知:这一节在做什么** 可以一句话概括: > executePipeline 是切块策略的统一调度引擎,按 step 顺序串行执行,每步的输出作为下一步的输入。结构切块用标题识别 + 标题栈维护章节路径,递归切块用四级降级(段落 → 行 → 句子 → 固定窗口)保证任何文本都能切到合规长度,语义切块用 Jaccard 相似度 + 双条件触发实现按主题切分,LLM 切块用大模型理解语义但成本最高且默认关闭。每种策略内部都有自己的降级路径,加上流水线层面的 cleanupChunkList 三次清洗,保证最终输出稳定可用。 #### **整体关系图** ```mermaid 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] ``` 注意两种关系: ```text 实线:流水线串行(step 之间的衔接) 虚线:策略内部降级(单个 step 内的兜底) ``` 这两种机制独立运作,**降级发生在策略内部,不影响外层流水线推进**。 --- ### **二、executePipeline:流水线引擎** 这是所有策略的调度中心。 ```java private List executePipeline(List sourceList, List<...> orderedSteps, DocumentStrategyPipelineTypeEnum pipelineType) { List 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. 三次清洗:每步之间的"消毒环"** ```text 入口清洗一次:防御输入数据脏 每步后清洗一次:防止脏数据传给下一步 返回前清洗一次:保证输出干净 ``` 为什么这么频繁? 因为四种策略各自的实现独立,不知道彼此会产出什么样的边界情况: ```text 结构切块可能产出空 chunk(标题之间没正文) 递归切块可能产出 trim 后为空的 chunk(纯空白段落) LLM 切块可能产出乱码或重复 ``` **清洗放在中间,让每个策略只关心自己的核心逻辑,不需要互相防御**。这是典型的**"调度层兜底,业务层专注"**的分层设计。 #### **2. switch 表达式的优雅** ```text currentChunks = switch (strategyType) { case STRUCTURE -> applyStructureChunking(...); ... }; ``` Java 17 的 switch 表达式让分派代码非常紧凑: ```text 传统 switch:每个 case 后要写 break,冗长 switch 表达式:每个分支返回值,直接赋值 还能编译期检查所有枚举值是否覆盖 ``` 如果将来新增策略类型,IDE 立刻提示这里要补 case,不会漏。这是**编译期安全**的好处。 #### **3. 流水线串行的语义** 前一步输出 = 后一步输入: ```text 方案: STRUCTURE → RECURSIVE 结构切块切出 5 个章节块 → 递归切块拿这 5 个块作为输入 其中超过 maxChars 的章节块会被递归切块再切小 ``` 这种串行让多步策略可以**层层细化**: ```text 第一步划定大边界(结构) 第二步控制长度(递归) 最终既保留语义结构,又不超长 ``` #### **4. pipelineType 透传** ```text applyStructureChunking(currentChunks, pipelineType) ``` 四个 apply 方法都接收 `pipelineType`。这个参数决定每种策略内部的**阈值**: ```text 父块流水线:maxChars 大(比如 4000) 子块流水线:maxChars 小(比如 800) ``` 为什么要透传到每个策略?因为: ```text 父块要保留大上下文,块大些 子块要精准检索,块小些 同一种策略在父块和子块下行为不同 ``` 如果不透传,要么写两个版本的策略(冗余),要么策略内部判断状态(复杂)。透传 `pipelineType` 是最轻量的做法。 --- ### **三、策略一:结构切块** #### **1. 入口方法:遍历分发** ```text for (ChunkCandidate candidate : sourceList) { if (candidate == null || StrUtil.isBlank(candidate.getText())) continue; resultList.addAll(applyStructureChunking( candidate.getText(), pipelineType, candidate.getSectionPath(), candidate.getSourceType() )); } ``` 入口方法只负责**展开**——把每个候选块拆开传给真正的切块逻辑。注意三个元数据字段都被保留: ```text sectionPath:基础章节路径(用于和新切出的路径拼接) sourceType:来源类型(原文 / 结构切块 / ...) text:原始文本 ``` 这样切出的子块**继承父块的上下文**:如果父块本身已经在 `第一章` 下,子块切出的 `1.1 节` 最终路径是 `第一章 > 1.1 节`,而不是只有 `1.1 节`。 #### **2. 核心算法:逐行扫描 + 标题栈** 这是结构切块**最核心**的代码。 ```java Deque 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? ```text Deque 提供 addLast / removeLast 两个操作 天然适合栈语义(LIFO) List 也能模拟栈但语义不直观 ``` 这种**用接口表达意图**的写法让代码读起来更清晰——一看 Deque + addLast/removeLast 就知道在维护栈。 ##### **为什么遇到标题先 flushChunk?** ```text 当前在累积 "第一章" 下面的正文 遇到 "1.1 节" 标题,说明第一章的正文部分结束 必须把累积的正文落成一个 chunk,清空缓冲区 然后才开始累积 "1.1 节" 下面的正文 ``` 如果不 flush,**两个章节的正文会被串到一起**,后续的 sectionPath 也会错乱。 ##### **标题栈回退算法** ```java while (headingStack.size() >= classification.level()) { headingStack.removeLast(); } headingStack.addLast(classification.title()); ``` 这是栈维护的核心逻辑。举例: ```text 当前栈:[第一章, 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节] ``` 为什么是 `>=` 而不是 `>`? ```text 遇到同级标题(比如另一个二级标题),也要弹出当前同级的标题再压入新的 否则同级标题会被嵌套成父子关系,路径错乱 ``` 这个细节非常关键,少一个 `=` 整个路径就乱套。 ##### **栈 + 路径的优雅之处** 普通的"按章节切块"很多人会写成: ```text 正则匹配所有标题 按标题位置切片 拼接路径 ``` 但这种写法处理不好**层级关系**。栈算法的优雅在于: ```text 栈本身就编码了层级:栈底是顶层标题,栈顶是当前最深层 弹出 + 压入两个操作就能维护任意复杂的嵌套结构 路径 = String.join(" > ", headingStack) ``` 这是**用合适的数据结构让算法变简单**的典型案例。 #### **3. 降级兜底:无标题时退到递归切块** ```java if (candidateList.isEmpty()) { return applyRecursiveChunking( List.of(new ChunkCandidate(baseSectionPath, parsedText, sourceType)), pipelineType ); } ``` 什么时候会进入这个分支? ```text 整段文本扫描完一个标题都没识别出来 比如纯散文、会议纪要、聊天记录 ``` 为什么降级到递归而不是直接返回原文? ```text 直接返回原文:整段文本变一个超大块,可能超 maxChars 降级到递归:至少能保证长度合规 ``` 这种**单策略内部的降级**是这套切块系统的核心特征。每种策略都有"自己搞不定时怎么办"的预案。 #### **4. DocumentLineClassifier:9 级正则识别** 标题识别的质量决定了结构切块的质量。`DocumentLineClassifier` 用一组正则按优先级匹配。 ##### **优先级设计的考量** ```text 1. Markdown 标题(##)优先:最明确 2. 附录:特殊格式,容易识别 3. "第 1 步":先排除步骤型,避免误判为标题 4. 中文章节(第 X 章):非常明确 5. 多级数字编号(1.2.3):层级天然 6. 中文大纲(一、):需要启发式判断 7. 单级数字(1、):需要启发式判断 8. 无序列表(- 开头):明确不是标题 9. 默认:正文 ``` 为什么"第 1 步"要先于"第 X 章"识别? ```text "第 1 步" 也匹配 "第 X 章" 的部分模式 如果先识别为章节,会把步骤误标为标题 所以必须先排除步骤,再考虑章节 ``` 正则优先级的顺序在这种**模糊匹配**场景下极其重要。 ##### **looksLikeHeadingContent:启发式判断** ```java if (endsWithSentencePunctuation(normalized)) return false; if (normalized.length() > 24) return false; return !contains(",;。:"); ``` `一、xxx` 这种格式既可能是标题也可能是列表项,怎么区分? ```text "一、项目背景" → 短、无标点 → 标题 "一、本项目的主要目标是,通过引入新框架..." → 长、有逗号 → 列表项 ``` 三个启发式条件: ```text 1. 不以句末标点结尾(标题不是完整句子) 2. 长度 ≤ 24 字符(标题简短) 3. 不含逗号/分号/句号/冒号(标题不复杂) ``` 这是**经验主义工程**:没有完美算法,但 95% 场景下能区分对。剩下 5% 错误代价低(最多多切几块或少切几块),可以接受。 ##### **正则模式的取舍** 为什么不用 NLP 模型识别标题? ```text 轻量级:正则毫秒级,模型百毫秒级 确定性:正则结果可复现,模型有概率性 可调试:正则规则一目了然,模型黑盒 ``` 对于"标题识别"这种**结构化模式**任务,正则的性价比远高于模型。模型适合做**语义理解**,正则适合做**模式匹配**,各司其职。 #### **5. flushChunk 和 composeSectionPath** ```java private void flushChunk(...) { String text = currentChunk.toString().trim(); if (StrUtil.isNotBlank(text)) { candidateList.add(new ChunkCandidate(...)); } currentChunk.setLength(0); } ``` ##### **trim 的位置** trim 在 flushChunk 里做,而不是每次 append 时做。原因: ```text append 时每次 trim 浪费 CPU(StringBuilder 反复操作) flushChunk 是边界事件,频率低,trim 一次代价小 ``` 这是**热路径优化**的常见思路:把昂贵操作放在低频路径。 ##### **setLength(0) 而不是 new StringBuilder()** ```java currentChunk.setLength(0); ``` ```text new StringBuilder():创建新对象,GC 压力 setLength(0):复用底层 char[] 数组,几乎零开销 ``` 对于**循环里反复创建的 StringBuilder**,setLength 是标准优化。 #### **6. 实战例子** ```text 输入: ## 用户管理 用户管理模块负责用户的增删改查。 ### 用户注册 注册时需要验证手机号。 ## 权限管理 权限管理基于 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. 入口和参数解析** ```java int maxChars = resolveRecursiveMaxChars(pipelineType); int overlapChars = resolveRecursiveOverlap(maxChars, pipelineType); ``` ##### **父子块阈值差异** ```java return pipelineType == PARENT ? PARENT_BLOCK_MAX_CHARS : properties.getChunk().getRecursiveMaxChars(); ``` ```text 父块:用代码常量(通常较大,比如 4000) 子块:用配置文件(通常较小,比如 800-1000) ``` ##### **overlap 参数设计** 父块 overlap 用固定值,子块从配置读: ```text 父块 overlap:保守,因为父块本身大,过多重叠浪费空间 子块 overlap:可配置,产品可以根据召回效果调 ``` maxChars - 1 的边界保护 ```java return Math.min(configuredOverlap, Math.max(0, maxChars - 1)); ``` 防止 `overlap >= maxChars` 的配置错误: ```text overlap = maxChars → step = 0 → 死循环 overlap > maxChars → 逻辑混乱 所以强制 overlap <= maxChars - 1 ``` 这是**防御性编程**的体现——对配置不信任。 #### **2. recursiveSplit:四级降级** ```java 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; ``` ##### **为什么要四级降级?** 不同的边界**对人类阅读的友好程度**不同: ```text 段落:最自然(空行天然分隔语义单元) 行:中等(代码、列表场景下常用) 句子:较自然(句号是语义边界) 固定窗口:最差(可能切在词中间) ``` **优先用最自然的边界,实在不行才硬切**。这种思想保证了切块结果**对用户的可读性**。 ##### **每一级的判断:size > 1** ```text if (paragraphList.size() > 1) ... ``` 为什么是 `> 1` 而不是 `>= 1`? ```text size = 1 表示这种边界拆不开(整段文本只有一段) size > 1 才说明拆分有效,可以进入合并阶段 ``` ```text splitByRegex("整段无空行的文本", "\n\\s*\n") → ["整段无空行的文本"] size=1 此时段落级拆不开,要降到行级 ``` ##### **mergeAndSplit:先拆后合** 虽然代码没贴,但根据语义能推出: ```text 1. 把拆出的小段按顺序遍历 2. 累积到 currentBuffer 直到接近 maxChars 3. 输出 currentBuffer,开始新一轮 4. 单个小段超 maxChars → 递归调用 recursiveSplit 处理 ``` 为什么不直接返回拆出的小段? ```text 按段落拆出的段落可能太短(几个字) 需要合并到 maxChars 附近,提高 chunk 利用率 ``` 这是**逆向思维**:先按边界拆碎,再合并到合适大小。比直接按 maxChars 切更能保留自然边界。 ##### **递归发生在哪一步?** ```text 某段超过 maxChars → 递归 recursiveSplit 递归时这段文本作为新输入,从段落级开始重新降级 最终一定能切到合规长度 ``` 这就是"**递归切块**"名字的来源。 #### **3. 固定窗口的 step 计算** ```java int step = Math.max(1, maxChars - overlapChars); ``` ```text maxChars=100, overlapChars=20 step = 80 窗口 1:[0, 100) 窗口 2:[80, 180) 窗口 3:[160, 260) ... ``` 相邻窗口重叠 20 字符。这就是 overlap 的实现。 ##### **Math.max(1, ...) 的保护** ```text 万一配置错误导致 step 为 0 或负数 → 死循环 强制 step >= 1 保证一定能推进 ``` #### **4. overlap 的工程意义** 考虑场景: ```text 原文:"...为了实现这个目标,我们需要一个高性能的索引系统。该系统主要包含三个模块..." 切块在 "我们需要一个高性能的索引系统。" 后 块 A 结束于 "...索引系统。" 块 B 开始于 "该系统主要包含三个模块..." ``` 如果用户搜 "高性能索引系统包含哪些模块": ```text 块 A 包含 "高性能索引系统" 但缺 "包含哪些模块" 块 B 包含 "包含三个模块" 但缺 "高性能索引系统" 两块单独看都不够全 ``` 加 overlap 后: ```text 块 B 开头加上 "...索引系统。" 这段重叠 块 B = "我们需要一个高性能的索引系统。该系统主要包含三个模块..." 现在能完整匹配查询 ``` overlap 是**用空间换召回率**的经典手段。代价是向量库存储多 ~20%,回报是边界附近的内容不会丢。 --- ### **五、策略三:语义切块** #### **1. 入口短路** ```text if (StrUtil.isBlank(candidate.getText()) || candidate.getText().length() <= semanticMinChars) { resultList.add(candidate); continue; } ``` 文本太短直接保留不切。原因: ```text 语义切块的精度建立在"有足够多的句子"上 太短的文本只有几句,切了会过碎 不如保留原貌让递归切块兜底 ``` 这是**该策略的"我搞不定"提前返回**。 #### **2. semanticSplit:Jaccard + 双条件** ##### **核心思路** ```text 逐句扫描 每句提取 token(英文按词,中文按字) 计算当前句子和累积块的 Jaccard 相似度 满足切块条件就切 ``` ##### **Jaccard 相似度** ```text Jaccard(A, B) = |A ∩ B| / |A ∪ B| ``` ```text A = {Spring, Boot, 框架} (来自累积块) B = {Spring, 简化, 配置} (来自新句子) 交集 = {Spring},大小 1 并集 = {Spring, Boot, 框架, 简化, 配置},大小 5 Jaccard = 1/5 = 0.2 ``` 值域 [0, 1],越接近 1 越相似。 ##### **为什么用 Jaccard 而不是 embedding?** 理论上 embedding 更精准(理解语义而非词形),但工程上 Jaccard 有几个不可替代的优势: ```text 1. 零依赖:不需要 embedding 模型 2. 极快:几十微秒,embedding 几十毫秒 3. 确定性:同样的输入永远同样的输出 4. 成本零:embedding 调用要钱 ``` 精度 vs 成本的权衡: ```text Jaccard 精度差一些,但够用 embedding 精度好,但贵且慢 对"语义切块"这种轻量边界判断,Jaccard 性价比更高 ``` 如果对精度有更高要求,方案里可以配 LLM 切块(成本高但效果最好)。语义切块**填的是中间档**:比纯字符切聪明,比 LLM 便宜。 ##### **双条件触发:exceedMaxChars OR semanticBreak** ```java boolean exceedMaxChars = currentChunk.length() + sentence.length() > semanticMaxChars; boolean semanticBreak = currentChunk.length() >= semanticMinChars && similarity < threshold; if (currentChunk.length() > 0 && (exceedMaxChars || semanticBreak)) { // 切块 } ``` 两个条件: ```text exceedMaxChars:硬上限,防止块无限增长 semanticBreak:软边界,主题跳变时切 ``` ##### **semanticBreak 的双重门槛** ```text currentChunk.length() >= semanticMinChars && similarity < threshold ``` 为什么不是单独的 `similarity < threshold` 就切? ```text 设想:开头两个句子主题不同 句子1:"项目概述" 句子2:"具体实现细节" Jaccard 相似度可能很低 如果立即切,得到一个只含 1 句的块,太碎 ``` 加上 `currentChunk.length() >= semanticMinChars` 的门槛: ```text 只有累积长度达到最小阈值后,才允许因为主题跳变切块 保证每个块至少有"足够内容" ``` 这是**精度 vs 大小**的平衡。 ##### **空块时的相似度** ```java double similarity = currentTokenSet.isEmpty() ? 1D : jaccard(...); ``` 累积块为空时,相似度强制为 1(最相似)。原因: ```properties 空块刚开始累积 → 不应该立刻触发切块 让相似度=1 → semanticBreak 永远 false → 第一句一定会被加进来 ``` 这种**边界值的特殊处理**让算法在所有场景都正常推进。 #### **3. 算法步进可视化** ```text 输入句子: 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: ```text 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. 三层防护机制** ```java // 第一层:全局降级 if (!llmEnabled || chatModel == null) { return applySemanticChunking(sourceList, pipelineType); } // 第二层:预切分 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()) { resultList.addAll(semanticSplit(...)); // 单段失败降级 continue; } ... } ``` 第一层:配置开关 + 模型实例检查 ```text llmEnabled = false → LLM 关闭,直接走语义 chatModel = null → 模型实例没注入,直接走语义 任一不满足都降级 ``` 为什么需要这个? ```text 开发环境可能没配 LLM,代码不能崩 线上 LLM 限流时可能临时关闭 模型实例可能因 SpringContext 未就绪为 null ``` **容错优先**:宁可降级到次优策略,也不让任务失败。 ##### **第二层:预切分** ```text recursiveSplit(candidate.getText(), llmMaxChars, 0) ``` 注意 overlap=0: ```text LLM 切块的预切分纯粹为了控制 prompt 长度 不需要 overlap(后面 LLM 会重新组织边界) ``` 为什么要预切分? ```text LLM 的 context window 有限(比如 32K tokens) 单段过长 → prompt 超限被截断 切成 llmMaxChars 大小的段(比如 4000 字符) → 安全 ``` ##### **第三层:单段降级** ```java if (llmChunkList.isEmpty()) { resultList.addAll(semanticSplit(...)); continue; } ``` 某段调用失败不影响其他段: ```text 段 1 调用成功 → 用 LLM 结果 段 2 LLM 超时返回空 → 这段降级到语义 段 3 调用成功 → 用 LLM 结果 最终结果 = 段1的LLM切块 + 段2的语义切块 + 段3的LLM切块 ``` **精细化降级**让 LLM 的不稳定性不会拖垮整体。 #### **2. llmSplit 实现** ```java 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 resultList = objectMapper.readValue(jsonArray, ...); ``` prompt 设计:三个关键约束 ```text 1. 严格返回 JSON 数组字符串(机器可解析) 2. 不要输出解释文字(避免污染响应) 3. 不要丢失原文关键信息(防止过度删减) ``` prompt engineering 的核心是**降低不确定性**: ```text 明确格式要求 明确输出范围 给具体示例(["片段1","片段2"]) ``` extractJsonArray:抗污染 ```text LLM 可能返回: "以下是切分结果:\n```json\n[\"片段1\",\"片段2\"]\n```" ``` 直接 `readValue` 会失败。`extractJsonArray` 用正则或字符匹配从中提取最外层 `[...]`: ```text 找到第一个 [ 找到匹配的 ] 返回中间内容 ``` 这是**LLM 输出后处理**的常见模式:再严格的 prompt 也无法保证 LLM 100% 听话,必须有客户端清洗层。 ##### **try-catch 兜底** ```java catch (Exception exception) { log.warn("大模型智能切块失败,回退到语义切块", exception); return List.of(); } ``` 任何异常都返回空列表: ```text 模型调用超时 → 空列表 → 上层降级 JSON 解析失败 → 空列表 → 上层降级 模型返回空 → 空列表 → 上层降级 ``` **统一通过空列表传递失败信号**,让上层逻辑只判断一次就够。这种**契约式设计**比抛各种异常类型简洁。 #### **3. 成本与适用性** ```text 1 份文档 = 多个段落 每段 → 1 次 LLM 调用 按 GPT-4 价格,1 万字文档 LLM 切块 ≈ ¥0.5 ``` 这个成本对个人产品可能高,对企业知识库可接受。所以系统默认 `llmEnabled=false`,由用户在重要文档上**显式开启**。 --- ### **七、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(); String uniqueKey = StrUtil.blankToDefault(candidate.getCanonicalPath(), candidate.getSectionPath()) + "||" + candidate.getItemIndex() + "||" + normalizedText; uniqueMap.putIfAbsent(uniqueKey, cloneChunkCandidate(candidate, normalizedText)); } return new ArrayList<>(uniqueMap.values()); } ``` 去重键设计的精妙 ```text canonicalPath + "||" + itemIndex + "||" + normalizedText ``` 三个维度组合: ##### **为什么不只用 text?** ```text 不同章节可能有相同文本(比如都有"详见下文") 全局按 text 去重 → 这两段被合并 失去章节区分 ``` ##### **为什么要 path + itemIndex?** ```text path:章节维度,区分不同位置 itemIndex:章节内位置,区分同章节多段相同文本 text:内容本身 三者组合:"哪个位置的什么内容" ``` ##### **|| 分隔符的选择** 为什么是 `||` 而不是 `_` 或 `:`? ```text 内容里可能含 _ 或 : (比如 "1.1 节" 包含空格冒号) || 在自然文本中极少出现,冲突概率低 ``` 这是**分隔符选择**的常见做法:选不太可能在数据里出现的字符。 #### **LinkedHashMap 保序** ```text new LinkedHashMap() ``` 为什么不用 HashMap? ```text HashMap:去重正确,但顺序不稳定 LinkedHashMap:去重 + 保留插入顺序 ``` 切块结果的顺序对后续阶段很关键: ```text 向量化按顺序处理 检索时 chunk 顺序影响展示 日志和调试依赖稳定顺序 ``` LinkedHashMap **多消耗一点内存换稳定性**,绝对值得。 #### **putIfAbsent 保留首次出现** ```java uniqueMap.putIfAbsent(uniqueKey, ...); ``` ```text put:重复时覆盖 putIfAbsent:重复时跳过 ``` 为什么保留第一次而不是最后一次? ```text 按流水线顺序,先出现的通常来自更"自然"的策略(结构 → 递归 → 语义 → LLM) 后出现的可能是兜底产物 保留先出现的 = 优先用语义更好的版本 ``` 这种**默认策略**让重复时的选择有合理偏向。 --- ### **八、参数解析:父子块的阈值差异** ```java private int resolveRecursiveMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) { return pipelineType == PARENT ? PARENT_BLOCK_MAX_CHARS : properties.getChunk().getRecursiveMaxChars(); } ``` #### **1. 父块用代码常量,子块用配置** ```text 父块 maxChars:代码常量(比如 4000) 子块 maxChars:application.yml 配置(比如 800) ``` 为什么这样区分? ```text 父块阈值改动很少:它是架构级参数,改动影响大 子块阈值经常调:产品根据召回效果迭代 代码常量 = 稳定不变 配置文件 = 灵活可调 ``` ##### **不在配置里也定义父块阈值?** 可以,但工程上故意让父块阈值"**不那么容易改**": ```text 父块阈值改动 → 影响 RAG 整体架构 子块阈值改动 → 只影响召回精度 重要程度不同,改动门槛也应该不同 ``` #### **2. 父子语义阈值的差异** ```java private int resolveSemanticMinChars(DocumentStrategyPipelineTypeEnum pipelineType) { return pipelineType == PARENT ? Math.max(PARENT_SEMANTIC_MIN_CHARS, properties.getChunk().getSemanticMinChars()) : properties.getChunk().getSemanticMinChars(); } ``` `Math.max` 取较大值的意义: ```text 父块 semanticMinChars 至少是 PARENT_SEMANTIC_MIN_CHARS 即使配置文件配得更小,也不会低于这个底线 ``` #### **3. LLM maxChars 的特殊处理** ```java private int resolveLlmMaxChars(DocumentStrategyPipelineTypeEnum pipelineType) { return pipelineType == PARENT ? Math.max(properties.getChunk().getLlmMaxChars(), PARENT_BLOCK_MAX_CHARS) : properties.getChunk().getLlmMaxChars(); } ``` 父块的 LLM 阈值 = max(配置值, 父块阈值)。这保证: ```text LLM 切块的预切分长度 >= 父块目标长度 否则 LLM 切完的子段会比父块小,语义不完整 ``` 每个 `resolve*` 方法背后都有**对应策略的语义考量**,不是简单的配置读取。 --- ### **九、四种策略的工程哲学** 把四种策略放一起对比,能看出几个**贯穿始终**的设计哲学。 #### **1. 策略各有所长,组合使用** ```text 结构切块:利用文档天然边界,精度最高(对结构化文档) 递归切块:无任何前提,纯长度控制,通用兜底 语义切块:轻量主题判断,精度中等成本低 LLM 切块:最强语义理解,精度最高成本最贵 ``` 没有哪种策略适合所有场景,所以方案推荐阶段会**组合使用**: ```text 有结构 → 结构 + 递归 质量好 → 语义 + 递归 质量差 → LLM + 递归 ``` #### **2. 多层降级保证可用性** ```text 结构 → 无标题降级到递归 LLM → 不可用 / 单段失败 → 语义 语义 → 文本太短 → 原样保留 递归 → 自身就是兜底,不再降 ``` 整个降级网形成"**所有路径最终都能产出结果**"的保证。 #### **3. 父子块阈值差异化** ```text 父块大,保留上下文 子块小,提升召回精度 通过 pipelineType 透传统一管理 ``` #### **4. 自然边界优先** ```text 递归切块四级:段落 > 行 > 句子 > 固定窗口 语义切块按句:不在词中间切 LLM 切块:让模型选择最合理的边界 ``` **人类可读 = 检索友好**。切在段落边界的块比切在词中间的块更容易理解。 #### **5. 元数据贯穿全流程** ```text 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-四种切块策略详解|03-四种切块策略详解]] | 04-四级切块策略 | ➡️ [[05-异步索引构建:初始化与切块执行|05-异步索引构建:初始化与切块执行]]