Files
SuperBizAgent-java/docs/analysis/plan-chunking-step4-refactor.md
zhuyongxin 60be51f4a5 docs: 重构文档结构,分离学习笔记和 MVP 架构设计
**变更概述:**
- 将 MVP 架构设计文档独立到项目根目录 `mvp/`
- 整理 `docs/` 为纯学习和分析文档目录
- 按类型分类:learning(学习)、analysis(分析)、reports(报告)、guides(指南)

**目录结构:**
```
mvp/                          # MVP 架构设计(独立)
├── README.md                 # 数据库设计总览
├── architecture/             # 架构文档
│   ├── agent-architecture-mvp.md
│   ├── implementation-plan.md
│   └── ...
└── tables/                   # 数据表设计

docs/                         # 学习和分析文档
├── learning/                 # 学习笔记(00-08 编号)
├── analysis/                 # 分析笔记 + 重构计划
├── reports/                  # 临时报告
└── guides/                   # 指南文档
```

**详细变更:**
- docs/README.md → mvp/README.md(数据库设计入口)
- docs/architecture/ → mvp/architecture/(架构设计)
- docs/tables/ → mvp/tables/(数据表设计)
- docs/学习笔记-*.md → docs/learning/07-*.md, 08-*.md
- docs/项目学习路径.md → docs/learning/00-*.md
- docs/功能分析报告.md → docs/analysis/
- docs/修复报告-*.md → docs/reports/
- docs/日志配置*.md → docs/guides/ 或 docs/reports/
- docs/design/ → docs/analysis/(问题分析和重构计划)
2026-06-23 14:14:51 +08:00

6.6 KiB
Raw Permalink 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 解析替代正则 低优先级