# 当前分片策略问题分析与根因 > 基于 `DocumentChunkServiceTest` 可视化测试的运行结果 > 配置:`maxSize=800, overlap=100`(默认) / 可视化测试使用 `maxSize=300/200, overlap=50/30` --- ## 问题总览 | # | 问题 | 严重程度 | 根因归类 | |---|------|----------|----------| | 1 | 标题独立成空壳块 | 中 | 标题分割逻辑 | | 2 | 有序列表被拆散 | 高 | 段落级切割 + 缺少结构感知 | | 3 | 英文块 token 密度远低于中文块 | 高 | 字符计数代替 token 计数 | | 4 | 硬截断点在语义转折处无特殊处理 | 中 | 仅依赖 maxSize 触发 | | 5 | overlap 窗口对中文句号后截取命中率低 | 低 | 句子校准逻辑覆盖不全 | --- ## 问题 1:标题独立成空壳块 ### 现象 运维文档 `maxSize=300` 下,H1 标题产生了一个只有 14 字符的分块: ``` Chunk #0 │ Title: CPU高负载问题排查指南 │ Range: [0→14] (14字符) │ Content: │ │ # CPU高负载问题排查指南 ``` 紧随其后的 `## 问题现象` 被分到下一个块。14 字符的块没有任何可检索的实质内容。 ### 根因 ```java // DocumentChunkService.java:71-83 while (matcher.find()) { // 保存上一个章节 if (lastEnd < matcher.start()) { String sectionContent = content.substring(lastEnd, matcher.start()).trim(); if (!sectionContent.isEmpty()) { // ← 条件:content 非空 sections.add(new Section(currentTitle, sectionContent, lastEnd)); } } currentTitle = matcher.group(2).trim(); lastEnd = matcher.start(); } ``` `splitByHeadings()` 遍历标题时,`lastEnd` 指向当前标题起始位置,`matcher.start()` 是下一个标题的起始位置。当 H1 后紧跟 H2(中间只有 `#` 行本身的内容),`content.substring(lastEnd, matcher.start())` 取出的是 **H1 标题行本身 + H1 标题行和 H2 之间的空白**。 关键问题: - H1 标题行被当作上一个 section 的 "content" 保存(因为中间文本不为空——标题行本身是文本) - 但实质上标题不应该独立成为一个可检索的分块 ### 影响 - 向量库中出现大量无效向量(仅含标题、无实质内容) - 检索时可能召回标题块,Agent 得不到有用信息 - 浪费 Milvus 存储空间 --- ## 问题 2:有序列表被拆散 ### 现象 排查步骤 1-4 在 Chunk #2,第 5 步被单独踢到 Chunk #3: ``` Chunk #2 → 1. 登录服务器... 2. 使用 ps... 3. 查看应用日志... 4. 检查数据库... Chunk #3 → 5. 检查JVM内存... ``` Agent 调用工具拿到 Chunk #2 时,排查步骤不完整,可能漏掉关键操作。 ### 根因 ```java // DocumentChunkService.java:174-188 private List splitByParagraphs(String content) { List paragraphs = new ArrayList<>(); String[] parts = content.split("\n\n+"); // ← 双换行分割 for (String part : parts) { String trimmed = part.trim(); if (!trimmed.isEmpty()) { paragraphs.add(trimmed); } } return paragraphs; } ``` ```java // DocumentChunkService.java:132-148 if (currentChunk.length() > 0 && currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) { // 触发切分——不关心这个段落属于什么语义结构 String overlap = getOverlapText(chunkContent); currentChunk = new StringBuilder(overlap); } currentChunk.append(paragraph).append("\n\n"); ``` 两层根因: 1. `splitByParagraphs()` 只认 `\n\n+` 作为段落分割符,不识别 **有序列表**(`1. \n2. \n3.` 之间通常是单换行) 2. `chunkSection()` 走到字符上限就切,完全不感知"这是一个列表的第几项"——列表项之间的语义强关联被忽略 ### 影响 - 排查步骤、操作指南类文档的完整性被破坏 - RAG 检索召回不完整的步骤列表,Agent 据此操作可能导致遗漏 - 这是运维场景的致命问题——运维文档大量使用列表 --- ## 问题 3:英文块 token 密度远低于中文块 ### 现象 可视化测试数据: ``` 中文: 218字符 → 2个分块(约218 tokens,密度 ~1.0 token/字符) 英文: 602字符 → 3个分块(约150 tokens,密度 ~0.25 token/字符) ``` 同样 `maxSize=200`,英文 602 字符装了 150 token 还产生 3 个分块;中文 218 字符装了 218 token 只产生 2 个分块。中文块的实际 token 负担是英文的 **~4x**。 ### 根因 ```java // DocumentChunkConfig.java:18 private int maxSize = 800; // 字符数上限 // DocumentChunkService.java:132-133 if (currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) { // 这里比的是 Java String.length() — 字符数,不是 token 数 ``` Java 的 `String.length()` 对每个 Unicode 字符(包括中文)都返回 1。但 LLM tokenizer 对中文和英文的 token 化效率完全不同: ``` "这是中文" → 4 字符 → ~4 tokens (1:1) "This is English" → 15 字符 → ~4 tokens (3.75:1) ``` 用字符数作为切割上限,相当于: - 中文块:可以装 800 token(甚至更多) - 英文块:只能装 ~200 token LLM 上下文窗口是按 token 计费的,这种偏差意味着**中文知识库的 RAG 开销是英文的 4 倍**。 ### 影响 - LLM 调用成本不可预测(中英混排时波动大) - 中文知识库的上下文窗口利用率极易超标 - 无法对 prompt 的 token 预算做精确控制 --- ## 问题 4:硬截断在语义转折处无特殊处理 ### 现象 同问题 2 的根因延伸。当前逻辑: ``` 段落1 + 段落2 + 段落3 + ... + 段落N → 总字符数 < maxSize → 继续追加 → 总字符数 > maxSize → 立刻切 ``` 不考虑「段落 N 和段落 N+1 是否属于同一语义单元」。两个语义上需要绑定的段落恰好越过 maxSize 边界就会被拆散。 ### 根因 ```java // DocumentChunkService.java:132 if (currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) { ``` 触发条件只有一个——字符数。不缺以下信号: - 相邻段落的语义相似度(可用 embedding 计算) - 当前缓冲区是否处于列表/表格/代码块内部 - 当前位置是否是 Markdown 层级的自然边界(如 `##` 标题前) ### 影响 - 切出来的分块边界在语义上不可预测 - 同一主题的内容可能跨越两个分块,召回时只能拿到一半上下文 --- ## 问题 5:overlap 句子校准对中文覆盖不全 ### 现象 测试用的中文句子边界校准场景中,文档字数不足 `maxSize=100`,未触发切分。但即便触发,当前校准逻辑存在盲区: ```java // DocumentChunkService.java:203-206 int lastSentenceEnd = Math.max( overlap.lastIndexOf('。'), // 只有三个终止符 Math.max(overlap.lastIndexOf('?'), overlap.lastIndexOf('!')) ); ``` ### 根因 中文句子终止符不止 `。?!` 三种: | 终止符 | 是否覆盖 | 遗漏场景 | |--------|----------|----------| | `。` | ✅ | — | | `?` | ✅ | — | | `!` | ✅ | — | | `;`(分号) | ❌ | 长复句的语义断点 | | `:`(冒号) | ❌ | 列表/说明的引入点 | | `……` | ❌ | 省略号表示语义未尽 | | `\n`(换行) | ❌ | 中文短句常用换行代替标点 | 阈值逻辑也有盲区: ```java if (lastSentenceEnd > overlapSize / 2) { // only apply if sentence boundary is in the LATER half of overlap } ``` 如果句子边界在重叠区的前半段(即离截断点不到 overlap/2),直接退回原始截取——但实际上即使在前半段,也比随机截取更好。 ### 影响 - 中文内容的重叠窗口可能从句子中间截取 - 新分块的"种子"文本不完整,影响该块的语义完整性 --- ## 根因总结 所有 5 个问题的根源收敛到两点: ### 根因 A:切割触发器只有一个维度——字符数 ``` currentChunk.length() + paragraph.length() > maxSize → 切! ``` 这个条件不知道: - 这个"paragraph"是列表项还是普通段落?(问题 2) - 中文还是英文?(问题 3) - 和上一条内容语义紧密还是已经转移话题?(问题 4) - 这个位置是在 Markdown 结构树上的什么层级?(问题 1) ### 根因 B:文档结构感知仅限于正则标题 ```java Pattern headingPattern = Pattern.compile("^(#{1,6})\\s+(.+)$", Pattern.MULTILINE); ``` 这是唯一的结构感知入口。正则比 AST 脆弱,无法区分: - 代码块内的 `#` 注释 vs 真正的 Markdown 标题 - 列表项 vs 段落 - 代码块 vs 正文 - 表格 vs 正文 --- ## 修复优先级建议 | 优先级 | 问题 | 对策 | 改动量 | |--------|------|------|--------| | P0 | 问题 3(中英 token 密度) | 字符计数 → token 计数 | ~10 行 | | P0 | 问题 2(列表拆散) | 增加列表结构感知 | ~30 行 | | P1 | 问题 1(标题空壳) | 标题与下一个 H2 之间内容为空时合并 | ~15 行 | | P1 | 问题 4(硬截断) | 语义相似度辅助决策切点 | ~30 行 | | P2 | 问题 5(句子校准覆盖) | 增加终止符 + 降低阈值条件 | ~5 行 | 最终方案:替换为 Spring AI `TokenTextSplitter`,同时保留本项目特有的 `title` 元数据传播能力(因为 `TokenTextSplitter` 也不感知 Markdown 标题)。