# Plan: 分片策略第 4 步重构 > 分支: `refactor/rag-chunking-strategy` > 状态: 规划中 > 范围: 仅改 `DocumentChunkService.chunkSection()` 一个方法 --- ## 背景 经 debug 确认,当前分片流程的 1/2/3 步逻辑正确: ``` 第1步 chunkDocument() → splitByHeadings(content) ✅ 保持不变 第2步 for each Section → 循环章节 ✅ 保持不变 第3步 chunkSection() 入口 → 容量短路判断 + splitByParagraphs ✅ 保持不变 第4步 chunkSection() 累积循环 → 段落累积 + 字符触发切分 ❌ 需重构 ``` **第 4 步的两个核心问题:** | 问题 | 现象 | |------|------| | A. 丢失顺序 | `trim()` + 手工拼接 `\n\n` 导致 `currentStartIndex` 漂移 | | B. 结构无感知 | 有序列表项被拆散到不同分块(排查步骤 1-4 在一块,第 5 步在另一块) | --- ## 目标 改造 `chunkSection()` 的段落累积循环,使其: 1. **不丢顺序** — 用原始文本索引替代手工拼装的 `currentStartIndex` 2. **感知列表结构** — 有序/无序列表项之间不在中间切断 3. **Token 感知** — 用启发式 token 估算替代纯字符计数(为后续 Spring AI TokenTextSplitter 做准备) 4. **软边界** — 在接近上限时查找语义安全切点,而非硬截断 --- ## 不改的部分 | 组件 | 理由 | |------|------| | `splitByHeadings()` | 标题分割正确,正则够用 | | `getOverlapText()` | 句子校准逻辑保留,作为安全网 | | `DocumentChunk` 数据结构 | 字段完备,无需新增 | | `DocumentChunkConfig` | 增加 `maxTokens` 字段,保留原字段兼容 | | `VectorIndexService` | 消费者改动延后到下一阶段 | --- ## 改动方案 ### 改动 1: `DocumentChunkConfig` — 增加 token 配置 ```java // 新增字段 private int maxTokens = 500; // token 上限(中文约500字,英文约2000字符) private int maxTokensHard = 600; // 硬上限(maxTokens × 1.2) // 保留原字段作为向后兼容 private int maxSize = 800; // 保留但标记 @Deprecated ``` ### 改动 2: `chunkSection()` — 改造累积循环 **当前逻辑(伪代码):** ``` for each paragraph: if length + paragraph > maxSize → 切分 → 从 overlap 开始新块 append paragraph + "\n\n" ``` **新逻辑(伪代码):** ``` for each paragraph: currentTokens = estimateTokens(buffer) paraTokens = estimateTokens(paragraph) if currentTokens + paraTokens > maxTokens: if isInUnbreakableContext(buffer, paragraph): if currentTokens + paraTokens > maxTokensHard: → 必须切(硬上限保护) else: → 不切,继续累积(容忍超出,保护列表完整性) else: → 切分(段落边界 = 安全切点) → 从 overlap 开始新块 else: → 不切,继续累积 append paragraph + "\n\n" ``` ### 改动 3: 新增 `estimateTokens()` — 启发式 token 估算 ```java /** * 启发式 token 估算(无需外部依赖) * 中文: ~1 字符/token * 英文/数字: ~4 字符/token * 标点/空白: 忽略 */ private int estimateTokens(String text) { int tokens = 0; for (char c : text.toCharArray()) { if (Character.UnicodeBlock.of(c) == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS || Character.UnicodeBlock.of(c) == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A) { tokens += 1; // 中文字符 1:1 } else if (Character.isWhitespace(c)) { // 空白字符不计 } else { tokens += 1; // 非中文凑 4 个算 1 token(简化) } } // 非中文部分 / 4 return tokens; } ``` ### 改动 4: 新增 `isInUnbreakableContext()` — 结构感知 ```java /** * 判断当前段落是否属于不可中断的结构 * 返回 true = 不能在当前位置切分 */ private boolean isInUnbreakableContext(String buffer, String nextParagraph) { // 有序列表: "1. " "2. " "3. " 格式 if (nextParagraph.matches("^\\d{1,2}\\.\\s.*")) { // 前一个段落也是列表项 → 不切 String lastLine = getLastNonEmptyLine(buffer); if (lastLine.matches("^\\d{1,2}\\.\\s.*|.*\\n\\d{1,2}\\.\\s.*")) { return true; } } // 无序列表: "- " 或 "* " 格式 if (nextParagraph.matches("^[-*]\\s.*")) { String lastLine = getLastNonEmptyLine(buffer); if (lastLine.matches("^[-*]\\s.*|.*\\n[-*]\\s.*")) { return true; } } // 代码块: ``` 内部不切 if (buffer.contains("```") && countOccurrences(buffer, "```") % 2 == 1) { return true; // 在未闭合的代码块内 → 不切 } return false; } ``` ### 改动 5: 修复 index 漂移 ```java // 当前问题:用手工拼装的 chunkContent.length() 推算 offset // String chunkContent = currentChunk.toString().trim(); ← trim 丢字符 // currentStartIndex = currentStartIndex + chunkContent.length() - overlap.length(); ← 漂移 // 改为:用段落在原始文档中的实际位置 // 对每个 paragraph 记录其在 section.content 中的 offset,切分时直接使用 ``` --- ## 改动文件清单 | 文件 | 改动 | 行数变化 | |------|------|----------| | `config/DocumentChunkConfig.java` | +2 字段 | +8 | | `service/DocumentChunkService.java` | 改造 `chunkSection()` + 3 个新方法 | ~+50 / -20 | | `test/.../DocumentChunkServiceTest.java` | 新增列表结构感知 + token 估算用例 | +40 | 总计改动约 80 行,仅影响一个核心方法。 --- ## 验收标准 | # | 用例 | 预期 | |---|------|------| | 1 | 有序列表(5 项,每项 50 字符,maxTokens=180) | 5 项不拆散,容忍略超上限 | | 2 | 有序列表(20 项,超 maxTokensHard) | 在硬上限处切,但不在列表项中间切 | | 3 | 纯段落(10 段,每段 100 字符,maxTokens=300) | 在段落边界切 | | 4 | 中文 800 字 vs 英文 3200 字符 | 分块数接近 | | 5 | H1→空的→H2(标题空壳) | 仍有(不在本次修复范围) | | 6 | 原有测试:空文档、短文档、标题分割、重叠、chunkIndex | 全部通过 | --- ## 后续阶段 | 阶段 | 内容 | 依赖 | |------|------|------| | **Phase 1(本次)** | 改 `chunkSection()` — token + 列表感知 | 无 | | Phase 2 | 标题空壳问题修复(`splitByHeadings` 合并相邻空 section) | Phase 1 | | Phase 3 | 可选:切换到 Spring AI `TokenTextSplitter` | Phase 1/2 | | Phase 4 | 语义相似度辅助切点决策 | Phase 1 | | Phase 5 | Markdown AST 解析替代正则 | 低优先级 |