Files
SuperBizAgent-java/docs/design/plan-chunking-step4-refactor.md
T
2026-05-29 21:38:16 +08:00

6.6 KiB
Raw Blame History

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 配置

// 新增字段
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 估算

/**
 * 启发式 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() — 结构感知

/**
 * 判断当前段落是否属于不可中断的结构
 * 返回 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 漂移

// 当前问题:用手工拼装的 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 解析替代正则 低优先级