commit
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
# Essence Report: SuperBizAgent-java — RAG 切片流程
|
||||
|
||||
> **Lens:** mechanical
|
||||
> **Design analyzed:** 四层递进式文档分块算法——标题→章节→段落→句子边界的逐级切割策略
|
||||
> **Files examined:** 3 (`DocumentChunkService.java`, `DocumentChunkConfig.java`, `DocumentChunk.java`)
|
||||
> **Pattern:** Hierarchical Splitter with Sentence-Boundary-Aware Overlap
|
||||
> **Status:** complete
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Deep Dive
|
||||
|
||||
### 核心文件
|
||||
|
||||
| # | 文件 | 行数 | 角色 |
|
||||
|---|------|------|------|
|
||||
| 1 | `service/DocumentChunkService.java` | 229 | 分块引擎本身 |
|
||||
| 2 | `config/DocumentChunkConfig.java` | 32 | 参数契约 `maxSize=800, overlap=100` |
|
||||
| 3 | `dto/DocumentChunk.java` | 59 | 分块数据载体 |
|
||||
|
||||
### 完整调用链
|
||||
|
||||
```
|
||||
VectorIndexService.indexSingleFile()
|
||||
└─ chunkService.chunkDocument(content, filePath) [L35]
|
||||
│
|
||||
├─ splitByHeadings(content) [L44]
|
||||
│ ├─ 正则: ^(#{1,6})\s+(.+)$ [L65]
|
||||
│ ├─ 迭代 matcher.find() 找到每个标题位置
|
||||
│ ├─ 标题之间的内容 → Section(title, content, startIndex)
|
||||
│ └─ → List<Section>
|
||||
│
|
||||
└─ for each Section:
|
||||
└─ chunkSection(section, globalChunkIndex) [L49]
|
||||
│
|
||||
├─ if content.length() ≤ maxSize (800):
|
||||
│ └─ 直接作为一个分块 [L110-119]
|
||||
│
|
||||
├─ else (需要进一步切割):
|
||||
│ ├─ splitByParagraphs(content) [L124]
|
||||
│ │ └─ content.split("\n\n+") [L178]
|
||||
│ │
|
||||
│ ├─ for each paragraph: [L130-167]
|
||||
│ │ ├─ 当前缓冲区 + 新段落 ≤ maxSize? → 继续追加
|
||||
│ │ └─ 当前缓冲区 + 新段落 > maxSize? → 触发切分:
|
||||
│ │ ├─ 保存当前分块
|
||||
│ │ ├─ getOverlapText(当前分块内容) [L147]
|
||||
│ │ │ ├─ 取末尾 overlap(100) 字符
|
||||
│ │ │ ├─ 在重叠文本中找最后一个句子终止符
|
||||
│ │ │ │ max(lastIndexOf('。'), lastIndexOf('?'), lastIndexOf('!'))
|
||||
│ │ │ ├─ if 句子边界 > overlapSize/2 (50字符):
|
||||
│ │ │ │ └─ 从句子边界后截取(保证新块以完整句开头)
|
||||
│ │ │ └─ else:
|
||||
│ │ │ └─ 直接用 overlap 末尾截取
|
||||
│ │ └─ 新缓冲区 = 重叠文本 + 当前段落
|
||||
│ │
|
||||
│ └─ 最后一个分块: 保存缓冲区剩余内容
|
||||
│
|
||||
└─ → List<DocumentChunk>
|
||||
```
|
||||
|
||||
### 算法的四层递进结构
|
||||
|
||||
```
|
||||
第1层:标题分割
|
||||
输入:"# CPU高负载\n内容...\n## 排查步骤\n内容..."
|
||||
输出:Section("CPU高负载", "内容..."), Section("排查步骤", "内容...")
|
||||
作用:保持文档结构,同一主题的内容不被拆散
|
||||
|
||||
第2层:容量判断
|
||||
if section.length() ≤ 800: 整个章节 = 一个分块
|
||||
else: 进入段落级切割
|
||||
作用:短章节保持完整,不破坏语义
|
||||
|
||||
第3层:段落边界切割
|
||||
输入:超长章节的全部段落
|
||||
算法:逐个追加段落到缓冲区,超过 maxSize 时触发一次切分
|
||||
作用:不在段落中间截断
|
||||
|
||||
第4层:重叠窗口 + 句子边界对齐
|
||||
输入:即将被切断的分块末尾
|
||||
算法:取末尾100字符 → 找最近的。?! → 从该位置之后截取作为下一块的"种子"
|
||||
作用:相邻分块在语义上是"连续"的,检索时召回更完整
|
||||
```
|
||||
|
||||
### 架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DOC[/"原始文档"/] --> L1{"第1层: splitByHeadings()"}
|
||||
|
||||
L1 --> S1["Section 1<br/>title: CPU高负载<br/>content: ..."]
|
||||
L1 --> S2["Section 2<br/>title: 排查步骤<br/>content: ..."]
|
||||
L1 --> S3["Section N"]
|
||||
|
||||
S1 --> L2{"第2层: 容量判断"}
|
||||
S2 --> L2
|
||||
S3 --> L2
|
||||
|
||||
L2 -->|"≤800字符"| CHUNK["作为1个分块<br/>继承 title"]
|
||||
L2 -->|">800字符"| L3{"第3层: splitByParagraphs()<br/>在段落边界切分"}
|
||||
|
||||
L3 --> BUF["逐段追加到缓冲区"]
|
||||
BUF --> CHECK{"buf + para<br/>> maxSize?"}
|
||||
CHECK -->|否| APPEND["追加段落<br/>继续累积"]
|
||||
CHECK -->|是| L4{"第4层: getOverlapText()<br/>句子边界校准"}
|
||||
|
||||
APPEND --> CHECK
|
||||
|
||||
L4 --> FIND["在重叠区末尾100字符<br/>找最近的 。?!"]
|
||||
FIND --> EVAL{"句子边界位置<br/>> overlapSize/2?"}
|
||||
EVAL -->|是| ALIGN["从句号后截取<br/>保证新块以完整句开头"]
|
||||
EVAL -->|否| RAW["退回原始截取<br/>直接用末尾100字符"]
|
||||
|
||||
ALIGN --> SEED["种子 + 当前段落<br/>→ 新缓冲区"]
|
||||
RAW --> SEED
|
||||
SEED --> CHECK
|
||||
|
||||
CHUNK --> RESULT[/"List<DocumentChunk><br/>每个携带: content + title + startIndex + endIndex + chunkIndex"/]
|
||||
```
|
||||
|
||||
### 关键代码证据
|
||||
|
||||
#### 第1层——标题正则
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:65
|
||||
Pattern headingPattern = Pattern.compile("^(#{1,6})\\s+(.+)$", Pattern.MULTILINE);
|
||||
```
|
||||
|
||||
支持 H1-H6,`MULTILINE` 模式让 `^` 匹配行首而非仅字符串首。
|
||||
|
||||
#### 第2层——容量判断(短路)
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:110-119
|
||||
if (content.length() <= chunkConfig.getMaxSize()) {
|
||||
DocumentChunk chunk = new DocumentChunk(content, startIndex, endIndex, chunkIndex);
|
||||
chunk.setTitle(title);
|
||||
chunks.add(chunk);
|
||||
return chunks; // 直接返回,不进入段落切割
|
||||
}
|
||||
```
|
||||
|
||||
#### 第3层——段落级触发切分
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:132-148
|
||||
if (currentChunk.length() > 0 &&
|
||||
currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) {
|
||||
// 触发切分:保存当前块
|
||||
String overlap = getOverlapText(chunkContent); // 提取重叠文本
|
||||
currentChunk = new StringBuilder(overlap); // 新块以重叠文本开头
|
||||
currentStartIndex = currentStartIndex + chunkContent.length() - overlap.length();
|
||||
}
|
||||
currentChunk.append(paragraph).append("\n\n"); // 继续追加
|
||||
```
|
||||
|
||||
#### 第4层——句子边界检测(核心巧思)
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:193-213
|
||||
private String getOverlapText(String text) {
|
||||
int overlapSize = Math.min(chunkConfig.getOverlap(), text.length());
|
||||
String overlap = text.substring(text.length() - overlapSize);
|
||||
|
||||
// 在重叠文本中找最近的句子终止符
|
||||
int lastSentenceEnd = Math.max(
|
||||
overlap.lastIndexOf('。'),
|
||||
Math.max(overlap.lastIndexOf('?'), overlap.lastIndexOf('!'))
|
||||
);
|
||||
|
||||
// 质量阈值:只有句子边界在重叠区后半段才采用
|
||||
if (lastSentenceEnd > overlapSize / 2) {
|
||||
return overlap.substring(lastSentenceEnd + 1).trim();
|
||||
}
|
||||
return overlap.trim(); // 退回普通重叠
|
||||
}
|
||||
```
|
||||
|
||||
`overlapSize / 2` 条件是一个**质量阈值**。如果最近的句子边界在重叠区的前半段(即离截断点太远),说明分块点本身就接近句子边界,不需要特殊处理。只有句子边界明显位于重叠区后半段时才调整——避免把半个句子作为新块的"种子"。
|
||||
|
||||
### 数据流契约
|
||||
|
||||
```
|
||||
chunkDocument(content, filePath)
|
||||
│
|
||||
│ IN: String content — 原始文档全文
|
||||
│ String filePath — 仅用于日志
|
||||
│
|
||||
│ INNER CLASS: Section
|
||||
│ String title — 所在标题(可为 null)
|
||||
│ String content — 标题下的所有文本
|
||||
│ int startIndex — 在原文档中的字符偏移
|
||||
│
|
||||
│ OUT: List<DocumentChunk>
|
||||
│ String content — 分块文本
|
||||
│ int startIndex — 在原文档中的起始位置
|
||||
│ int endIndex — 在原文档中的结束位置
|
||||
│ int chunkIndex — 分块序号 (0, 1, 2, ...)
|
||||
│ String title — 所属章节标题(继承自 Section)
|
||||
│
|
||||
└─ 消费者: VectorIndexService.indexSingleFile():142
|
||||
→ 遍历 chunks → embeddingService.generateEmbedding(chunk.content)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Extract Pattern
|
||||
|
||||
### 模式名:Hierarchical Splitter with Sentence-Boundary-Aware Overlap
|
||||
|
||||
**一句话:** 从粗到细逐级切割——先按文档结构(标题)分章,再按语义边界(段落)分块,最后在切分点用句子终止符校准重叠窗口。
|
||||
|
||||
### 问题
|
||||
|
||||
固定长度切割的典型失败场景:
|
||||
|
||||
```
|
||||
切在句子中间: "CPU使用率达到 95%,建议" | "立即重启相关服务"
|
||||
↑ 检索"CPU问题"时召回这块——后半句完全脱离上下文,LLM 误判
|
||||
|
||||
切在段落中间:"## 排查步骤\n1. 查看监控\n2. 检" | "查日志\n3. 重启服务"
|
||||
↑ 步骤 2 被切断,Agent 拿着残缺的排查步骤执行操作
|
||||
```
|
||||
|
||||
### 替代方案对比
|
||||
|
||||
| 方案 | 切分依据 | 优势 | 劣势 |
|
||||
|------|----------|------|------|
|
||||
| **固定字符切割**(最简陋) | maxSize,不关心内容 | 实现简单 | 句子截断、丢失语义 |
|
||||
| **递归字符切割**(LangChain RecursiveTextSplitter) | `\n\n` → `\n` → ` ` → `` | 通用性好 | 不理解 Markdown 结构 |
|
||||
| **语义切割**(用 LLM 判断切点) | LLM 标注切分位置 | 理论上最优 | 慢、贵、不可预测 |
|
||||
| **本项目:层级式+句子校准** | 标题→段落→句子终止符 | 快速 + 保留文档结构 | 仅支持 Markdown,非标题文档退化为段落切割 |
|
||||
|
||||
### 为什么标题分割放在第一步?
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:43-44
|
||||
// 1. 首先尝试按标题分割(Markdown格式)
|
||||
List<Section> sections = splitByHeadings(content);
|
||||
```
|
||||
|
||||
看本项目的知识库文档就懂了:
|
||||
|
||||
```markdown
|
||||
# CPU高负载问题排查 ← 一个独立主题
|
||||
## 问题现象
|
||||
...
|
||||
## 排查步骤 ← 这些步骤必须完整才能被 Agent 执行
|
||||
1. 使用 top 命令确认 CPU 使用率最高的进程
|
||||
2. 检查对应服务的日志
|
||||
3. ...
|
||||
## 解决方案
|
||||
...
|
||||
|
||||
# 内存高负载问题排查 ← 另一个独立主题
|
||||
...
|
||||
```
|
||||
|
||||
如果把「CPU 排查步骤」和「内存排查步骤」混在一个分块里,Agent 查询"CPU 高"时会召回包含内存排查步骤的分块——噪声干扰判断。
|
||||
|
||||
标题优先分割 = **用文档作者自己标注的结构来界定语义边界**,比任何算法都准确。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Migrate
|
||||
|
||||
### 可迁移性
|
||||
|
||||
这个切分策略**直接可用**于任何需要为 Markdown 文档建 RAG 的项目。三个参数全部可配置:
|
||||
|
||||
```yaml
|
||||
# application.yml — 按文档类型调整
|
||||
document:
|
||||
chunk:
|
||||
max-size: 800 # 短文档(API文档)可设500,长文档(周报)可设1200
|
||||
overlap: 100 # 800的12.5%,保持比例即可
|
||||
```
|
||||
|
||||
### Steal-it 示例(17 行)
|
||||
|
||||
```java
|
||||
/**
|
||||
* 四层递进分块:标题 → 章节 → 段落 → 句子校准
|
||||
* 依赖:maxSize / overlap 两个参数
|
||||
*/
|
||||
public List<Chunk> chunk(String doc) {
|
||||
List<Chunk> result = new ArrayList<>();
|
||||
int globalIdx = 0;
|
||||
|
||||
// 第1层:按标题分章
|
||||
for (Section sec : splitByHeadings(doc)) {
|
||||
if (sec.content.length() <= maxSize) {
|
||||
// 第2层:短章节直接作为一个分块
|
||||
result.add(new Chunk(sec.content, sec.title, globalIdx++));
|
||||
} else {
|
||||
// 第3层:超长章节在段落边界切分
|
||||
String overlap = "";
|
||||
for (String para : sec.content.split("\n\n+")) {
|
||||
String candidate = overlap + para;
|
||||
if (candidate.length() > maxSize && !overlap.isEmpty()) {
|
||||
result.add(new Chunk(overlap, sec.title, globalIdx++));
|
||||
overlap = tailOverlap(overlap); // 第4层:句子校准
|
||||
}
|
||||
overlap = (overlap.isEmpty() ? "" : overlap + "\n\n") + para;
|
||||
}
|
||||
if (!overlap.isEmpty()) result.add(new Chunk(overlap, sec.title, globalIdx++));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 落地陷阱
|
||||
|
||||
| 陷阱 | 原因 | 规避 |
|
||||
|------|------|------|
|
||||
| **非 Markdown 文档退化为单块** | `splitByHeadings()` 找不到标题时整个文档作为一个 Section | L93-96:返回一个 Section,后续段落切割仍生效 |
|
||||
| **代码块内的 `#` 被误识别为标题** | 正则不区分代码块和正文 | 未规避——可加反引号检测 `` ``` `` |
|
||||
| **overlap=0 时句子校准无效** | `getOverlapText` 第一行 `Math.min(0, length)=0` 返回空串 | L194:直接返回空字符串,跳过校准 |
|
||||
| **单段落超过 maxSize 不做切割** | `splitByParagraphs` 后每个段落作为一个单位 | L132 条件要求 `currentChunk.length() > 0`,首段落即使超长也会被单独保存为一块 |
|
||||
|
||||
---
|
||||
|
||||
### Self-review
|
||||
|
||||
- [x] 设计真实——每层切割均有代码行号证据
|
||||
- [x] 深度足够——追溯到正则、条件分支、句子校准的数学逻辑
|
||||
- [x] 迁移示例 17 行——提取了四层递进的核心骨架
|
||||
- [x] 陷阱具体到代码行——非 Markdown 退化为单块(L93)、单段落超长不切割(L132)
|
||||
|
||||
```
|
||||
Essence Report: SuperBizAgent-java — RAG 切片流程
|
||||
Lens: mechanical
|
||||
Design analyzed: 四层递进式文档分块算法
|
||||
Files examined: 3
|
||||
Pattern: Hierarchical Splitter with Sentence-Boundary-Aware Overlap
|
||||
Migration: 17-line steal-it skeleton
|
||||
HTML generated: no
|
||||
Status: complete
|
||||
```
|
||||
@@ -0,0 +1,314 @@
|
||||
# Essence Report: SuperBizAgent-java — RAG 实现
|
||||
|
||||
> **Lens:** mechanical(机械论——结构、接口、数据流)
|
||||
> **Design analyzed:** RAG 管道——从文档上传到 Agent 辅助检索的完整写入/读取双路径
|
||||
> **Files examined:** 12
|
||||
> **Pattern:** Pipeline-as-Services + Agent-Mediated Retrieval
|
||||
> **Status:** complete
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 定位 — 设计目标确认
|
||||
|
||||
来自 `/explore` 报告的「设计二:完整的 RAG 管道(5 级流水线)」。用户指定深入 RAG 实现部分。
|
||||
|
||||
涉及 12 个核心文件,跨越 controller → service → client → constant 四层。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Deep Dive — 逐文件追踪
|
||||
|
||||
### 核心文件清单
|
||||
|
||||
| # | 文件 | 角色 | 暴露接口 |
|
||||
|---|------|------|----------|
|
||||
| 1 | `constant/MilvusConstants.java` | Schema 契约常量 | `VECTOR_DIM=1024`, `COLLECTION_NAME="biz"` |
|
||||
| 2 | `client/MilvusClientFactory.java` | 数据库初始化 | `createClient()` → 自动建表+建索引 |
|
||||
| 3 | `config/DocumentChunkConfig.java` | 分块参数 | `maxSize=800`, `overlap=100` |
|
||||
| 4 | `dto/DocumentChunk.java` | 分块实体 | `content`, `startIndex/endIndex`, `chunkIndex`, `title` |
|
||||
| 5 | `service/DocumentChunkService.java` | 智能分块器 | `chunkDocument(content, filePath)` → `List<DocumentChunk>` |
|
||||
| 6 | `service/VectorEmbeddingService.java` | 向量化网关 | `generateEmbedding(text)` → `List<Float>` (1024-dim) |
|
||||
| 7 | `service/VectorIndexService.java` | 写入管道编排 | `indexSingleFile(path)` → 读→删旧→分块→向量化→写 |
|
||||
| 8 | `service/VectorSearchService.java` | 语义检索 | `searchSimilarDocuments(query, topK)` → `List<SearchResult>` |
|
||||
| 9 | `service/RagService.java` | 全栈 RAG 问答 | `queryStream(question, history, callback)` → SSE流式 |
|
||||
| 10 | `agent/tool/InternalDocsTools.java` | Agent 工具桥 | `queryInternalDocs(query)` → JSON(仅检索,不生文) |
|
||||
| 11 | `controller/FileUploadController.java` | 写入入口 | `POST /api/upload` → 文件存储 + 自动索引 |
|
||||
| 12 | `controller/ChatController.java` | 读取入口 | `POST /api/chat(_stream)` → ReactAgent + 工具调用 |
|
||||
|
||||
### 完整的调用链(双路径)
|
||||
|
||||
#### 写入路径(索引管道)
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
└─ FileUploadController.upload() [L34]
|
||||
├─ Files.copy() → 保存文件到 uploadPath
|
||||
└─ VectorIndexService.indexSingleFile() [L124]
|
||||
├─ Files.readString() [L135]
|
||||
├─ deleteExistingData() [L173]
|
||||
│ └─ milvusClient.delete() [L198]
|
||||
│ expr: metadata["_source"] == "/path/to/file"
|
||||
├─ chunkService.chunkDocument() [L142]
|
||||
│ ├─ splitByHeadings() [L61]
|
||||
│ │ └─ 正则: ^(#{1,6})\s+(.+)$
|
||||
│ ├─ chunkSection() × N [L104]
|
||||
│ │ ├─ splitByParagraphs() [L174]
|
||||
│ │ └─ getOverlapText() [L193]
|
||||
│ └─ → List<DocumentChunk>
|
||||
└─ for each chunk: [L146]
|
||||
├─ embeddingService.generateEmbedding() [L76]
|
||||
│ └─ DashScope TextEmbedding API → List<Float>[1024]
|
||||
└─ insertToMilvus() [L255]
|
||||
└─ UUID(source+chunkIndex) + vector + content + metadata(JSON)
|
||||
```
|
||||
|
||||
#### 读取路径(Agent 中介检索)
|
||||
|
||||
```
|
||||
POST /api/chat_stream
|
||||
└─ ChatController.chatStream() [L143]
|
||||
└─ chatService.createReactAgent() [L183]
|
||||
└─ tools: [DateTimeTools, InternalDocsTools, QueryMetricsTools, QueryLogsTools]
|
||||
└─ agent.stream(question) [L189]
|
||||
└─ Agent 自主决策 → 调用 queryInternalDocs
|
||||
└─ InternalDocsTools.queryInternalDocs() [L53]
|
||||
└─ VectorSearchService.searchSimilarDocuments() [L42]
|
||||
├─ embeddingService.generateQueryVector() [L47]
|
||||
├─ milvusClient.search() [L51]
|
||||
│ └─ L2距离, IVF_FLAT, nprobe=10
|
||||
└─ → List<SearchResult>{id, content, score, metadata}
|
||||
└─ return JSON to Agent
|
||||
└─ Agent 融合检索结果 + LLM推理 → 最终回答
|
||||
```
|
||||
|
||||
### 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 写入路径
|
||||
UPLOAD[POST /api/upload]
|
||||
FC[FileUploadController]
|
||||
VIS[VectorIndexService]
|
||||
DCS[DocumentChunkService]
|
||||
VES[VectorEmbeddingService]
|
||||
MV_W[(Milvus)]
|
||||
end
|
||||
|
||||
subgraph 读取路径
|
||||
CHAT[POST /api/chat_stream]
|
||||
CC[ChatController]
|
||||
AGENT[ReactAgent]
|
||||
IDT[InternalDocsTools<br/>@Tool注解]
|
||||
VSS[VectorSearchService]
|
||||
MV_R[(Milvus)]
|
||||
LLM[DashScope LLM]
|
||||
end
|
||||
|
||||
UPLOAD --> FC
|
||||
FC --> VIS
|
||||
VIS --> DCS --> VIS
|
||||
VIS --> VES --> VIS
|
||||
VIS --> MV_W
|
||||
|
||||
CHAT --> CC
|
||||
CC --> AGENT
|
||||
AGENT -->|自主决策调用| IDT
|
||||
IDT --> VSS
|
||||
VSS --> VES --> VSS
|
||||
VSS --> MV_R
|
||||
IDT -->|JSON结果| AGENT
|
||||
AGENT --> LLM
|
||||
LLM -->|SSE流式| CC
|
||||
```
|
||||
|
||||
### 关键设计决策(代码证据)
|
||||
|
||||
#### 1. 幂等上传——元数据驱动的去重策略
|
||||
|
||||
```java
|
||||
// VectorIndexService.java:138-139
|
||||
// 删除该文件的旧数据(如果存在)
|
||||
deleteExistingData(path.toString());
|
||||
```
|
||||
|
||||
`deleteExistingData()` (L173-215) 使用 `metadata["_source"] == filePath` 作为删除表达式。每次上传同一文件时,先清空旧向量再写入新数据,保证数据一致性。
|
||||
|
||||
#### 2. 路径标准化——跨平台一致性
|
||||
|
||||
```java
|
||||
// VectorIndexService.java:176-178
|
||||
Path path = Paths.get(filePath).normalize();
|
||||
String normalizedPath = path.toString().replace(File.separator, "/");
|
||||
```
|
||||
|
||||
Windows `\` 和 Unix `/` 统一为正斜杠,避免 Milvus 表达式解析错误。在 `deleteExistingData()` 和 `buildMetadata()` 中均有应用。
|
||||
|
||||
#### 3. 重叠窗口 + 句子边界感知
|
||||
|
||||
```java
|
||||
// DocumentChunkService.java:132-148
|
||||
if (currentChunk.length() > 0 &&
|
||||
currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) {
|
||||
// 保存当前分片
|
||||
String overlap = getOverlapText(chunkContent); // 提取重叠文本
|
||||
currentChunk = new StringBuilder(overlap); // 新分片以重叠文本开头
|
||||
```
|
||||
|
||||
`getOverlapText()` (L193-213) 更进一步:在重叠文本中寻找句子边界(`。?!`),避免在句子中间截断。当句子边界超过 `overlapSize/2` 时才使用,否则退回原始重叠策略。
|
||||
|
||||
#### 4. 检索与生成分离
|
||||
|
||||
`InternalDocsTools.queryInternalDocs()` 只做检索,不做生成。它将搜索结果序列化为 JSON 返回给 Agent,由 Agent 的 LLM 自行判断如何使用这些信息。
|
||||
|
||||
```java
|
||||
// InternalDocsTools.java:68
|
||||
String resultJson = objectMapper.writeValueAsString(searchResults);
|
||||
return resultJson;
|
||||
```
|
||||
|
||||
对比 `RagService.queryStream()` 则完整执行「检索→构建上下文→LLM 生成」三步,是一个独立的全栈 RAG 备用路径。
|
||||
|
||||
#### 5. Milvus Schema 设计
|
||||
|
||||
```java
|
||||
// MilvusClientFactory.java:109-142
|
||||
// 四个字段:
|
||||
// id VarChar(256) 主键 — UUID(source + chunkIndex)
|
||||
// vector FloatVector(1024) — text-embedding-v4 输出
|
||||
// content VarChar(8192) — 分块后的文本内容
|
||||
// metadata JSON — {_source, _extension, _file_name, chunkIndex, totalChunks, title}
|
||||
// 索引: IVF_FLAT, L2距离, nlist=128
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Extract Pattern
|
||||
|
||||
### 设计模式:Pipeline-as-Services + Agent-Mediated Retrieval
|
||||
|
||||
**问题:** 如何将知识库文档转化为 AI Agent 可检索、可利用的语义记忆?
|
||||
|
||||
**传统方案的问题:**
|
||||
- 关键词检索:无法理解语义相似的查询
|
||||
- 硬编码 FAQ:无法应对未见过的问题
|
||||
- 直接向量检索 + 固定提示词:所有问题都触发检索,浪费资源
|
||||
|
||||
**本项目的方案:两阶段架构**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ STAGE 1: 写入管道 (离线/上传时触发) │
|
||||
│ │
|
||||
│ 文档 ──→ 智能分块 ──→ 向量化 ──→ Milvus存储 │
|
||||
│ (标题+段落 (text-embedding (IVF_FLAT │
|
||||
│ 边界感知) -v4, 1024-dim) L2索引) │
|
||||
│ │
|
||||
│ 接口契约: │
|
||||
│ IN: File → OUT: N × (vector + content + meta) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ STAGE 2: 读取管道 (Agent 决策时触发) │
|
||||
│ │
|
||||
│ 用户问题 ──→ Agent 思考 ──→ 决定查知识库 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 向量检索 (L2距离) ──→ Top-K 文档片段 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Agent 融合检索结果 + LLM推理 → 回答 │
|
||||
│ │
|
||||
│ 接口契约: │
|
||||
│ IN: query(自然语言) → OUT: JSON(检索结果) │
|
||||
│ Agent 自主决定: 是否调用 / 如何使用结果 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 接口契约(隐式——通过 Spring DI 实现)
|
||||
|
||||
| 契约 | 生产者 | 消费者 | 数据形状 |
|
||||
|------|--------|--------|----------|
|
||||
| `List<DocumentChunk>` | DocumentChunkService | VectorIndexService | `{content, startIndex, endIndex, chunkIndex, title}` |
|
||||
| `List<Float>[1024]` | VectorEmbeddingService | VectorIndexService, VectorSearchService | DashScope text-embedding-v4 输出 |
|
||||
| `List<SearchResult>` | VectorSearchService | InternalDocsTools, RagService | `{id, content, score, metadata}` |
|
||||
| `StreamCallback` | RagService | (外部调用者) | `{onSearchResults, onContentChunk, onComplete, onError}` |
|
||||
|
||||
### 替代方案对比
|
||||
|
||||
| 方案 | 本项目 | LangChain4j | 纯 DashScope API |
|
||||
|------|--------|--------------|-------------------|
|
||||
| 分块策略 | 标题感知 + 段落边界 + 句子重叠 | 多种内置 Splitter | 无,需自建 |
|
||||
| 向量库 | Milvus (IVF_FLAT) | 多后端支持 | 无 |
|
||||
| Agent 集成 | Spring AI @Tool 注解,Agent 自主决策 | AiServices + @Tool | 无 Agent 框架 |
|
||||
| 去重 | metadata["_source"] 匹配删除 | 需自定义 | 不适用 |
|
||||
|
||||
### 为什么选择这种设计?
|
||||
|
||||
1. **「检索」和「生成」分离**:`InternalDocsTools` 只返回检索结果,生成由 Agent 的 LLM 完成。Agent 可以选择**不使用**检索结果(如果检索质量不高),或者**交叉验证**多次检索的结果
|
||||
2. **工具化 RAG**:将 RAG 暴露为 Agent 工具而非独立 API,让 Agent 在合适的时机触发检索——而非对所有问题都做 RAG
|
||||
3. **5 个独立 Service**:每个阶段可单独替换。想换分块策略?只改 `DocumentChunkService`。想换向量库?只改 `VectorSearchService` + `VectorIndexService`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Migrate — 可迁移的设计
|
||||
|
||||
### 可迁移性评估
|
||||
|
||||
这个 RAG 设计**高度可迁移**到任何需要「知识库 + AI Agent」的 Java 项目。核心依赖是 Spring AI 生态 + 一个向量数据库。
|
||||
|
||||
### Steal-it 示例(12 行)
|
||||
|
||||
```java
|
||||
// 核心思想:Pipeline-as-Services + Agent Tool Bridge
|
||||
// 以下骨架可直接用于任何 Spring Boot 项目
|
||||
|
||||
// 1. 分块器:语义感知分割
|
||||
public List<Chunk> chunk(String doc) {
|
||||
return splitByHeadings(doc).stream()
|
||||
.flatMap(s -> splitToFit(s, MAX_SIZE, OVERLAP))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 2. Agent 工具桥:检索但不生文
|
||||
@Component
|
||||
class KnowledgeBaseTool {
|
||||
@Tool(description = "搜索内部知识库获取相关信息")
|
||||
public String search(@ToolParam(description="查询内容") String query) {
|
||||
List<Float> qv = embedder.embed(query); // 向量化
|
||||
var results = vectorDB.search(qv, TOP_K); // 语义检索
|
||||
return toJson(results); // 返回给Agent
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 落地陷阱
|
||||
|
||||
| 陷阱 | 说明 | 本项目如何规避 |
|
||||
|------|------|----------------|
|
||||
| **路径分隔符不一致** | Windows `\` vs Unix `/` 导致 Milvus 表达式解析失败 | `VectorIndexService.java:177` 强制 `replace(File.separator, "/")` |
|
||||
| **重复上传污染数据** | 同一文件多次上传产生重复向量 | `VectorIndexService.java:138-139` delete-before-insert |
|
||||
| **分块边界截断语义** | 固定长度切割可能切断句子 | `DocumentChunkService.java:203-206` 在重叠区找句子边界 |
|
||||
| **Agent 未触发工具** | Agent 不知道何时该查知识库 | `InternalDocsTools.java:49-52` @Tool description 用英文详细描述触发场景 |
|
||||
| **向量维度不匹配** | embedding 模型输出维度与 Milvus schema 不一致 | `MilvusConstants.java:18` 集中管理 `VECTOR_DIM=1024` |
|
||||
| **API Key 未初始化** | 静态 Constants 被其他线程覆盖 | `VectorEmbeddingService.java:86-89` 每次调用前检查并修复 |
|
||||
|
||||
### Self-review
|
||||
|
||||
- [x] 设计真实存在 — 每个声明均有文件+行号证据
|
||||
- [x] 分析深度足够 — 完整追踪了写入/读取两条全路径
|
||||
- [x] 迁移示例≤20行 — 仅提取 Pipeline + Tool Bridge 骨架
|
||||
- [x] 陷阱具体 — 每个都有代码规避证据
|
||||
- [x] 可解释为什么优于替代方案 — Agent 自主决策 vs 强制 RAG
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Essence Report: SuperBizAgent-java
|
||||
Lens: mechanical
|
||||
Design analyzed: RAG 管道 — Pipeline-as-Services + Agent-Mediated Retrieval
|
||||
Files examined: 12
|
||||
Pattern: Pipeline-as-Services + Agent Tool Bridge
|
||||
Migration: 12-line steal-it skeleton
|
||||
HTML generated: no
|
||||
Status: complete
|
||||
```
|
||||
@@ -0,0 +1,352 @@
|
||||
# Explore Report: SuperBizAgent-java
|
||||
|
||||
> 生成时间: 2026-04-30
|
||||
> Project type: **code repository**
|
||||
> Phases completed: 4/4
|
||||
> Diagram included: yes
|
||||
> Core designs: 3
|
||||
> Status: complete
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Positioning & Structure
|
||||
|
||||
### 这是什么项目
|
||||
|
||||
SuperBizAgent-java 是一个基于 **Spring AI + Alibaba DashScope (Qwen)** 的智能运维 AI Agent 平台。它将大语言模型、向量检索增强生成(RAG)和多智能体协作(Planner-Executor-Replanner)整合为一体,面向企业 IT 运维场景提供:
|
||||
|
||||
- **智能文档问答**:上传运维知识库文档(Markdown/TXT),通过 RAG 管道实现向量化检索 + LLM 流式生成回答
|
||||
- **告警分析自动化**:多 Agent 协作分析 Prometheus 告警,结合日志查询(腾讯云 CLS)和内部知识库,生成结构化的告警分析报告
|
||||
- **MCP 协议集成**:通过 Spring AI MCP Client 连接外部工具服务,扩展 Agent 能力边界
|
||||
|
||||
### 为什么值得研究
|
||||
|
||||
| 维度 | 价值 |
|
||||
|------|------|
|
||||
| **AI 框架落地** | Spring AI Alibaba 生态的完整实践——ReactAgent、SupervisorAgent、Tool 注册、流式对话 |
|
||||
| **多 Agent 协作** | 非玩具级的 Planner-Executor-Replanner 监督循环,实际解决告警分析这种开放性问题 |
|
||||
| **RAG 工程化** | 完整的文档分块→向量化→Milvus 存储→语义检索→流式生成的端到端管道 |
|
||||
| **MCP 协议** | 业界较早将 MCP (Model Context Protocol) 用于生产场景的 Java 案例 |
|
||||
|
||||
### 适合谁
|
||||
|
||||
- Spring Boot / Java 开发者学习 AI Agent 框架的落地模式
|
||||
- AIOps / SRE 工程师了解智能运维 Agent 的架构设计
|
||||
- 对 Spring AI Alibaba 生态感兴趣的技术决策者
|
||||
|
||||
### 项目规模
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| Java 源文件 | ~25 个 |
|
||||
| 代码行数 | ~2500 行 |
|
||||
| API 端点 | 7 个 |
|
||||
| Agent 工具 | 4 个 |
|
||||
| 知识库文档 | 5 篇 |
|
||||
|
||||
### 技术栈
|
||||
|
||||
```
|
||||
应用层 Spring Boot 3.2 / Java 17
|
||||
AI 层 Spring AI Alibaba 1.1.0 / Qwen3-Max / text-embedding-v4
|
||||
存储层 Milvus 2.5 (向量库) / MinIO (对象存储)
|
||||
集成层 MCP Client (WebFlux SSE) / Prometheus
|
||||
部署 Docker Compose (Milvus + etcd + MinIO + Attu)
|
||||
```
|
||||
|
||||
### 与替代方案的对比
|
||||
|
||||
| 方案 | 优势 | 劣势 |
|
||||
|------|------|------|
|
||||
| 本项目 (Spring AI Alibaba) | 完整生态、国产模型、Java 原生 | 社区相对年轻 |
|
||||
| LangChain4j | 社区活跃、模型支持广 | 多 Agent 模式需自行构建 |
|
||||
| Python LangChain | 生态最丰富 | 非 Java 技术栈 |
|
||||
| 纯 DashScope API | 简单直接 | 缺乏 Agent 编排、工具调用框架 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Flow
|
||||
|
||||
### 架构总览
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 前端
|
||||
WEB[Web UI<br/>index.html + app.js]
|
||||
end
|
||||
|
||||
subgraph 控制层
|
||||
CC[ChatController<br/>/api/chat /api/chat_stream]
|
||||
AO[AIOpsController<br/>/api/ai_ops]
|
||||
UP[FileUploadController<br/>/api/upload]
|
||||
HC[MilvusCheckController<br/>/milvus/health]
|
||||
end
|
||||
|
||||
subgraph 服务层
|
||||
CS[ChatService<br/>ReactAgent 编排]
|
||||
AIS[AiOpsService<br/>多Agent 协作]
|
||||
RS[RagService<br/>RAG 流式问答]
|
||||
VIS[VectorIndexService<br/>文件索引管道]
|
||||
VSS[VectorSearchService<br/>向量相似搜索]
|
||||
VES[VectorEmbeddingService<br/>文本向量化]
|
||||
DCS[DocumentChunkService<br/>智能文档分块]
|
||||
end
|
||||
|
||||
subgraph Agent工具
|
||||
DT[DateTimeTools]
|
||||
IDT[InternalDocsTools]
|
||||
QMT[QueryMetricsTools]
|
||||
QLT[QueryLogsTools]
|
||||
end
|
||||
|
||||
subgraph 外部服务
|
||||
DS[DashScope API<br/>Qwen3-Max / Embedding]
|
||||
MV[Milvus<br/>向量数据库]
|
||||
PM[Prometheus<br/>监控告警]
|
||||
CLS[腾讯云CLS<br/>MCP SSE]
|
||||
end
|
||||
|
||||
WEB --> CC
|
||||
WEB --> AO
|
||||
WEB --> UP
|
||||
WEB --> HC
|
||||
|
||||
CC --> CS
|
||||
CC --> RS
|
||||
AO --> AIS
|
||||
UP --> VIS
|
||||
|
||||
CS --> DT & IDT & QMT & QLT
|
||||
AIS --> DT & IDT & QMT & QLT
|
||||
|
||||
CS --> DS
|
||||
RS --> DS
|
||||
RS --> VSS
|
||||
VIS --> DCS --> VES --> MV
|
||||
VSS --> MV
|
||||
QMT --> PM
|
||||
QLT --> CLS
|
||||
|
||||
VES --> DS
|
||||
```
|
||||
|
||||
### 主要运行时流程
|
||||
|
||||
#### 流程 A:RAG 智能问答(文档→检索→生成)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant Ctrl as FileUploadController
|
||||
participant VIS as VectorIndexService
|
||||
participant DCS as DocumentChunkService
|
||||
participant VES as VectorEmbeddingService
|
||||
participant MV as Milvus
|
||||
participant RS as RagService
|
||||
participant DS as DashScope
|
||||
|
||||
Note over User,DS: === 索引阶段 ===
|
||||
User->>Ctrl: POST /api/upload (file.md)
|
||||
Ctrl->>VIS: indexSingleFile(file)
|
||||
VIS->>VIS: 删除旧向量(按source路径匹配)
|
||||
VIS->>DCS: chunkDocument(content)
|
||||
DCS-->>VIS: List<DocumentChunk>
|
||||
loop 每个分块
|
||||
VIS->>VES: generateEmbedding(chunk)
|
||||
VES->>DS: text-embedding-v4 API
|
||||
DS-->>VES: float[1024]
|
||||
VES-->>VIS: 向量
|
||||
end
|
||||
VIS->>MV: insert(向量 + 原文 + metadata)
|
||||
MV-->>VIS: OK
|
||||
|
||||
Note over User,DS: === 问答阶段 ===
|
||||
User->>Ctrl: POST /api/chat (question)
|
||||
Ctrl->>RS: generateAnswerStream(question)
|
||||
RS->>VES: 向量化问题
|
||||
VES->>DS: text-embedding-v4
|
||||
DS-->>RS: query_vector[1024]
|
||||
RS->>MV: search(query_vector, topK=3)
|
||||
MV-->>RS: 3条最相似文档片段
|
||||
RS->>DS: Generation API (提示词 + 上下文 + 问题)
|
||||
DS-->>User: SSE 流式生成回答
|
||||
```
|
||||
|
||||
#### 流程 B:AIOps 多 Agent 告警分析
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant Ctrl as ChatController
|
||||
participant AIS as AiOpsService
|
||||
participant Sup as SupervisorAgent
|
||||
participant P as PlannerAgent
|
||||
participant E as ExecutorAgent
|
||||
participant Tools as Agent Tools
|
||||
participant DS as DashScope
|
||||
|
||||
User->>Ctrl: POST /api/ai_ops (告警信息)
|
||||
Ctrl->>AIS: executeAiOpsAnalysis(request)
|
||||
|
||||
Note over AIS, DS: 启动监督循环
|
||||
AIS->>Sup: 启动,传入 Planner + Executor
|
||||
|
||||
loop Planner-Executor-Replanner
|
||||
Sup->>P: 分析当前状态,决定下一步
|
||||
alt 需要制定/修订计划
|
||||
P-->>User: SSE: 📋 分析计划...
|
||||
else 需要执行步骤
|
||||
P-->>Sup: EXECUTE
|
||||
Sup->>E: 执行计划第一步
|
||||
E->>Tools: 调用工具收集证据
|
||||
Tools-->>E: 日志/告警/文档信息
|
||||
E-->>User: SSE: 🔍 执行结果...
|
||||
E-->>Sup: 反馈 + 证据
|
||||
Note over Sup: 将执行结果反馈给Planner
|
||||
else 分析完成
|
||||
P-->>Sup: FINISH
|
||||
end
|
||||
end
|
||||
|
||||
Sup-->>User: SSE: ✅ Markdown 告警分析报告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Start Path
|
||||
|
||||
### 最小启动步骤
|
||||
|
||||
```bash
|
||||
# 1. 启动基础设施(Milvus + etcd + MinIO)
|
||||
cd D:\zhu\project\SuperBizAgent-java
|
||||
docker compose -f vector-database.yml up -d
|
||||
|
||||
# 2. 设置 API Key 环境变量
|
||||
export DASHSCOPE_API_KEY="your-dashscope-api-key"
|
||||
|
||||
# 3. 启动应用
|
||||
mvn spring-boot:run
|
||||
# 应用启动在 http://localhost:9900
|
||||
|
||||
# 4. 打开 Web 测试页面
|
||||
# http://localhost:9900/index.html
|
||||
```
|
||||
|
||||
### 学习起点
|
||||
|
||||
1. **第一入口**:`src/main/java/org/example/Main.java` — Spring Boot 启动类,了解组件扫描范围
|
||||
2. **核心对话**:`src/main/java/org/example/controller/ChatController.java` — 所有 API 端点定义,理解请求路由
|
||||
3. **Agent 编排**:`src/main/java/org/example/service/ChatService.java` — ReactAgent 如何注册工具、处理对话
|
||||
4. **多 Agent 协作**:`src/main/java/org/example/service/AiOpsService.java` — Planner-Executor-Replanner 模式完整实现
|
||||
5. **RAG 管道**:按 `VectorIndexService → DocumentChunkService → VectorEmbeddingService → RagService` 顺序阅读
|
||||
|
||||
### 建议的第一个修改
|
||||
|
||||
在 `QueryMetricsTools.java` 的 `queryPrometheusAlerts()` 方法中添加一个 Mock 数据,观察 Agent 如何将新的工具输出整合到对话中。修改后重新提问相关问题即可看到效果。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Core Designs
|
||||
|
||||
### 设计一:Planner-Executor-Replanner 监督循环
|
||||
|
||||
**位置**:`src/main/java/org/example/service/AiOpsService.java`
|
||||
|
||||
**是什么**:一个三层多 Agent 协作模式,用监督者控制循环来解决开放性的告警分析问题。
|
||||
|
||||
```
|
||||
SupervisorAgent (监督者)
|
||||
├── PlannerAgent (规划者)
|
||||
│ └── 决策三个状态: PLAN → 制定/修订计划
|
||||
│ EXECUTE → 交给执行者
|
||||
│ FINISH → 输出最终报告
|
||||
└── ExecutorAgent (执行者)
|
||||
└── 执行计划中的第一步
|
||||
└── 调用工具获取真实数据
|
||||
└── 返回反馈给 Planner 重新规划
|
||||
```
|
||||
|
||||
**为什么重要**:
|
||||
- 不是简单的单次 Agent 调用,而是通过**循环反馈**逐步逼近准确分析
|
||||
- Planner 根据 Executor 返回的证据**动态调整计划**(即 Replan 机制)
|
||||
- 通过 SSE 将每一步的中间结果实时推送给前端,用户体验好
|
||||
- 工具调用是**实际的**:Prometheus 查询、日志搜索、知识库检索,不是 mock 玩具
|
||||
|
||||
**关键实现细节**:
|
||||
```java
|
||||
// SupervisorAgent 创建并传入子 Agent
|
||||
SupervisorAgent supervisor = SupervisorAgent.builder()
|
||||
.supervisorAgent(supervisor)
|
||||
.subAgents(plannerAgent, executorAgent)
|
||||
.build();
|
||||
```
|
||||
|
||||
### 设计二:完整的 RAG 管道(5 级流水线)
|
||||
|
||||
**位置**:`VectorIndexService` → `DocumentChunkService` → `VectorEmbeddingService` → `VectorSearchService` → `RagService`
|
||||
|
||||
**是什么**:从原始文档到流式问答输出的完整 RAG 管道,涉及 5 个松耦合的服务组件。
|
||||
|
||||
| 阶段 | 组件 | 关键技术点 |
|
||||
|------|------|------------|
|
||||
| 1. 智能分块 | DocumentChunkService | 按 Markdown 标题层级 + 段落边界分割,800 字符/块,100 字符重叠 |
|
||||
| 2. 向量化 | VectorEmbeddingService | DashScope text-embedding-v4,1024 维,支持批量 |
|
||||
| 3. 向量存储 | MilvusClientFactory | IVF_FLAT 索引,L2 距离,自动去重(按 source 路径) |
|
||||
| 4. 语义检索 | VectorSearchService | Top-K 配置化(default 3),返回原文 + 相似度分数 |
|
||||
| 5. 流式生成 | RagService | DashScope Generation API,SSE 流式输出,支持 system prompt |
|
||||
|
||||
**为什么重要**:
|
||||
- 每个阶段**独立可替换**——可以换分块策略、换向量库、换 LLM
|
||||
- **幂等上传**:同一文件重新上传时,先删除旧向量再写入,保证数据一致性
|
||||
- 分块策略考虑了 Markdown 的文档结构(标题层级),而不是简单的固定长度切割
|
||||
|
||||
### 设计三:工具即插即用的 Agent 工具系统
|
||||
|
||||
**位置**:`src/main/java/org/example/agent/tool/*.java`
|
||||
|
||||
**是什么**:基于 Spring AI `@Tool` 注解的工具系统,Agent 自动发现并可调用。
|
||||
|
||||
```java
|
||||
// 工具定义示例
|
||||
@Component
|
||||
public class DateTimeTools {
|
||||
@Tool(description = "获取当前日期和时间")
|
||||
public String getCurrentDateTime() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**核心设计决策**:
|
||||
|
||||
| 决策 | 做法 | 原因 |
|
||||
|------|------|------|
|
||||
| Mock 开关 | `QueryMetricsTools` 和 `QueryLogsTools` 都有 `mockEnabled` 配置 | 开发/演示时不需要真实 Prometheus/CLS 环境 |
|
||||
| MCP 优先 | 当 MCP Client 可用时,自动排除 `QueryLogsTools` | 避免工具重复,MCP 提供更丰富的日志能力 |
|
||||
| JSON Schema 生成 | 使用 `jsonschema-generator` 为工具参数生成 schema | 让 LLM 理解工具的参数类型和约束 |
|
||||
| 工具注册 | `ChatService` 和 `AiOpsService` 各自注册工具集 | Agent 只获得需要的能力,避免干扰 |
|
||||
|
||||
**ChatService 工具注册**:
|
||||
```java
|
||||
// 构建时注册所有可用工具
|
||||
ReactAgent agent = ReactAgent.builder()
|
||||
.tools(dateTimeTools, internalDocsTools,
|
||||
queryMetricsTools, queryLogsTools)
|
||||
.build();
|
||||
```
|
||||
|
||||
**为什么重要**:
|
||||
- Agent 工具系统是 AI Agent 的**能力边界**——定义了 Agent 能做什么
|
||||
- Mock/Real 模式切换体现了**开发友好性**
|
||||
- MCP 协议的集成展示了**可扩展性**——Agent 可以从外部获取新能力
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
SuperBizAgent-java 是一个小而完整的 AI Agent 实践项目。它的三个核心竞争力是:
|
||||
|
||||
1. **多 Agent 协作**(Planner-Executor-Replanner)——不是玩具,是真正解决问题的模式
|
||||
2. **工程化的 RAG 管道**——5 级流水线、幂等上传、智能分块
|
||||
3. **Spring AI 生态的完整实践**——从 @Tool 注解到 MCP 协议,展示了 Java 生态做 AI Agent 的成熟路径
|
||||
|
||||
对于想将 AI Agent 引入企业运维场景的 Java 团队,这是一个很好的学习起点和脚手架。
|
||||
@@ -0,0 +1,282 @@
|
||||
# 当前分片策略问题分析与根因
|
||||
|
||||
> 基于 `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<String> splitByParagraphs(String content) {
|
||||
List<String> 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 标题)。
|
||||
@@ -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 解析替代正则 | 低优先级 |
|
||||
Reference in New Issue
Block a user