This commit is contained in:
zhuyongxin
2026-05-29 21:38:16 +08:00
parent 80eada415d
commit d4b5015beb
28 changed files with 3373 additions and 47 deletions
+200
View File
@@ -0,0 +1,200 @@
# 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 解析替代正则 | 低优先级 |