201 lines
6.6 KiB
Markdown
201 lines
6.6 KiB
Markdown
# 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 解析替代正则 | 低优先级 |
|