chore(docs): 归档 ISS-001 session-dedup-knowledge-map + ISS-002 mvp 文档

- 移动 session-dedup-knowledge-map OpenSpec 到 archive 目录
- 提交 ISS-001 遗留的 devflow 档案文件
- 更新 ISS-002 状态为已修复
- 新增 mvp/architecture/action-memory-relevance.md 设计文档
- 更新 mvp/README.md 文档导航
- 更新 devflow/index.md OpenSpec 链接指向 archive
This commit is contained in:
zhuyongxin
2026-07-01 18:27:04 +08:00
parent e438df4355
commit a1c896ebda
14 changed files with 834 additions and 3 deletions
+1 -1
View File
@@ -10,5 +10,5 @@
| 2026-06-25 | doc-management-ui | 前端开发/文档管理 | 文档管理页面, CRUD, 状态监控, 纯静态页面, API集成 | archived |
| 2026-06-26 | session-storage | 会话存储/可观测 | diagnosis_session, agent_step, tool_invocation, token追踪, 多Agent路由 | openspec/changes/session-storage | archived |
| 2026-06-29 | confidence-feedback | 质量评估/反馈机制 | evidence_score, selfEvaluation, feedback, useful, not_useful, case_library, BAD_CASE, tool_invocation规则引擎, 反馈按钮, sessionId回传 | openspec/changes/confidence-feedback | archived |
| 2026-06-30 | session-dedup-knowledge-map | 去重/知识图谱 | RetrievedDocTracker, KnowledgeDomainService, knowledge_domain, covers, whenToRetrieve, Planner注入, ISS-001 | openspec/changes/session-dedup-knowledge-map | archived |
| 2026-06-30 | session-dedup-knowledge-map | 去重/知识图谱 | RetrievedDocTracker, KnowledgeDomainService, knowledge_domain, covers, whenToRetrieve, Planner注入, ISS-001 | openspec/changes/archive/2026-06-30-session-dedup-knowledge-map | archived |
| 2026-07-01 | executor-action-memory-relevance | 检索质量/行动记忆 | relevanceLevel, completenessHint, Min-Max归一化, RetrievedDocTracker域级记录, Executor检索约束, ISS-002 | openspec/changes/archive/2026-07-01-executor-action-memory-relevance | archived |
@@ -0,0 +1,33 @@
# Brief: session-dedup-knowledge-map
## 背景
ISS-001:Executor 在单次对话中重复调用 `lookup_knowledge` 多达 20 次,同一文档被召回 13 次。原因是工具层无状态、Planner 无知识边界感知。
## 目标
1. 彻底消除 session 内重复文档召回(Part A)
2. 给 Planner 注入知识图谱,让其在规划阶段就能判断需要检索哪个域、只检索一次(Part B)
## 范围
- `LookupKnowledgeTool`:session 级去重
- `Frontmatter` / `KnowledgeEntry`:新增 covers + whenToRetrieve
- `DocumentManagementService`:上传时 LLM 生成文档级字段
- `KnowledgeDomainService`(新):域级聚合与 DB 存储
- `knowledge_domain` 表(新)
- `ChatService` + `chat-planner-prompt.md`:注入 knowledge map
## 非目标(Phase 2)
- Executor 文档级 when_to_retrieve 细粒度筛选
- RRF 混合重排
- 文档 frontmatter 自动生成(手动覆盖 LLM 优先已支持)
## 分档
standard
## 关联 OpenSpec
openspec/changes/session-dedup-knowledge-map/
@@ -0,0 +1,60 @@
# decisions.md — session-dedup-knowledge-map
## Question Pool
| # | 问题 | 类型 | 状态 |
|---|---|---|---|
| Q1 | domain.when_to_retrieve 来源(手动/自动聚合/LLM上传时生成) | user-interview | 已确认 |
| Q2 | LLM 生成时机(同步上传 vs 异步补全) | user-interview | 已确认 |
| Q3 | knowledge map 结构(域级平铺 vs 两层) | user-interview | 已确认 |
| Q4 | domain.when_to_retrieve 存储(内存 vs DB) | user-interview | 已确认 |
| Q5 | Executor 文档级细粒度筛选是否进 MVP | user-interview | 已确认 |
| E1 | ThreadLocal 在多 Agent 路径是否安全 | evidence-driven | 已汇报 |
| E2 | 6 个文档是否全部有 category 字段 | evidence-driven | 已汇报 |
| E3 | 去重 key 设计 | evidence-driven | 已汇报 |
| E4 | Planner prompt token 增量是否可接受 | evidence-driven | 已汇报 |
| E5 | EvaluationService.tool_call_count 影响 | evidence-driven | 已汇报 |
## Evidence-Driven 结论
- **E1**:`AsyncConfig` 只启用 `@EnableAsync`,无 TaskDecorator。`SupervisorAgent.invoke()` 是同步阻塞调用,工具调用与主线程同线程,ThreadLocal 当前路径安全。异步扩展时需补 TaskDecorator。
- **E2**:全部 6 个文档均有 `category` 字段:api(1)、domain(1)、infrastructure(3)、troubleshooting(1)。
- **E3**:`KnowledgeEntry.filePath` 在 L0 内唯一,L1 `_source` 字段也是 filePath,统一用 filePath 作去重 key。
- **E4**:当前 planner prompt 21 行,注入 knowledge map 约增加 200-400 字符,可接受。
- **E5**:去重后 `agent_step.has_tool_call` 减少,`tool_call_count` 降低,这是修复效果,`EvaluationService` 评分规则无需改动。
## User-Interview 确认记录
**Q1** — doc.when_to_retrieve 来源
用户原话:选 C(上传时 LLM 自动生成)
确认状态:已确认
**Q2** — LLM 生成时机
用户原话:选 X(同步,上传时当场生成)
确认状态:已确认
**Q3** — knowledge map 结构
用户原话:认可两层结构(domain → documents[])
确认状态:已确认
补充:Planner 只注入域级 when_to_retrieve,文档级 when_to_retrieve 留 Executor 筛选(Phase 2)
**Q4** — domain.when_to_retrieve 存储
用户原话:存 DB,这样每次启动都不用让 LLM 再总结一次
确认状态:已确认 → 新建 knowledge_domain 表,Flyway 迁移脚本
**Q5** — Executor 文档级细粒度筛选
用户原话:留 Phase 2
确认状态:已确认,MVP 不做
## Pre-apply 补充决策
- **P1:KnowledgeIndexService.parseDocumentToEntry 替换为 Jackson**:`extractJsonValue` / `extractJsonArray` 手写解析器遇到含逗号、引号的自然语言字段(whenToRetrieve)会截断。全量替换为 `objectMapper.readValue(metadata, Frontmatter.class)`,影响范围仅 `KnowledgeIndexService`,行为更健壮。(用户确认)
- **P2:LookupResult 新增 message 字段**:去重命中时 `found=false` + `message="文档已在本会话中检索过:xxx"`,不复用 `primary.content`。语义清晰,LLM 能理解原因不会重试。(用户确认)
## 关键设计决策
1. **两级 when_to_retrieve**:文档级(upload 时 LLM 生成,存 metadata)+ 域级(文档变更时 LLM 聚合,存 knowledge_domain 表)
2. **域级重算触发**:文档上传后、文档删除后,只重算受影响的域(不是全量);`loadIndex()` 时如果某域在 DB 没有记录,则触发生成
3. **注入 Planner 只给域级**:knowledge map 只包含域级 when_to_retrieve + documents[](title + covers),不暴露文档级 when_to_retrieve
4. **去重 key**:filePath(L0+L1 统一)
5. **去重状态存储**:JVM 内 `ConcurrentHashMap<sessionId, Set<filePath>>`,`SessionContextHolder.clear()` 时同步清理
+1
View File
@@ -14,6 +14,7 @@
- [会话管理](architecture/session-management.md) - Redis + MySQL 会话管理
- [实施规划](architecture/implementation-plan.md) - 分阶段实施计划
- [证据评分与用户反馈](architecture/confidence-feedback.md) - evidence_score 规则引擎 + feedback API ⭐新增
- [行动记忆与检索归一化](architecture/action-memory-relevance.md) - Executor 行动记忆 + 归一化质量等级解决 ISS-002 ⭐新增
---
+275
View File
@@ -0,0 +1,275 @@
# 行动记忆与检索质量归一化
Executor 行动记忆 + 归一化质量等级设计,解决 ISS-002 Executor 无约束重复检索问题。
---
## 一、问题背景
ISS-001 修复文档级去重后,Executor 在单次会话中仍调用 `lookup_knowledge` 20+ 次。根因:
1. **行动记忆缺失**:Executor 不知道自己已检索过哪些域
2. **质量信号缺失**:检索结果没有给 LLM 判断"结果够不够"的信号
3. **Prompt 缺少合法出口**:原 prompt 要求"所有外部信息都必须调用工具",LLM 不敢停止检索
---
## 二、整体架构
```
lookup_knowledge(query)
│
├─ Step 1: L0 精确匹配(keywords 索引)
├─ Step 2: L1 语义检索(Milvus 向量)
├─ Step 3: computeRelevance()
│ ├─ 归一化:L2 → similarity [0,1]
│ └─ 判定:PRECISE / HIGHLY_RELEVANT / REFERENCE
├─ Step 4: RetrievedDocTracker 检查
│ ├─ 文档级去重 → isDocRetrieved(sessionId, docKey)
│ ├─ 域级检查 → isDomainRetrieved(sessionId, domain)
│ └─ 记录 → markRetrieved(sessionId, domain, docKey)
└─ Step 5: 返回 LookupResult
├─ primary / supplement(原始内容,不含分数)
├─ relevanceLevel(PRECISE / HIGHLY_RELEVANT / REFERENCE)
├─ completenessHint(兜底信号)
└─ retrievedDomainsThisSession(行动记忆)
```
### 设计原则
| 原则 | 说明 |
|------|------|
| **Agent 边界清晰** | 不给 Executor 注入 knowledge map,Executor 只知道做了什么,不用知道有什么 |
| **分数封装** | L0/L1 原始分数不在 LookupResult 中返回 LLM,只在归一化层内部使用 |
| **原始分数只入库** | 原始 L2 距离写进 `tool_invocation.retrieval_details` JSON 用于可观测 |
| **软约束 + 硬拦截** | Prompt 约束(软)+ 工具层域级去重(硬)两层防御 |
---
## 三、归一化质量等级
### L2 距离归一化
BGE-M3 输出为 L2 归一化单位向量(实测范数=1.00000002),L2 距离数学硬上界 = 2.0。
```
similarity = 1 - min(l2Score, maxL2Distance) / maxL2Distance
```
| L2 距离 | similarity | 等级 |
|---------|-----------|------|
| 0.0 | 1.0 | PRECISE |
| 0.383 | 0.8085 | HIGHLY_RELEVANT |
| 0.5 | 0.75 | HIGHLY_RELEVANT |
| 0.6031 | 0.6984 | REFERENCE |
| 1.0 | 0.5 | REFERENCE 边界 |
| 2.0+ | 0.0 | 不视为有效结果 |
### 三等级判定
| 等级 | 条件 | completenessHint | LLM 行为 |
|------|------|-----------------|---------|
| PRECISE | L0 matchCount == 1 | "知识库中不存在比上述结果更精准的文档" | 直接使用,禁止再检索 |
| HIGHLY_RELEVANT | L0 命中 + similarity ≥ 0.75,或仅 L1 similarity ≥ 0.75 | "当前结果已高度相关,继续检索不太可能找到更精准的文档" | 可综合推理,大概率不需要继续查 |
| REFERENCE | 其余命中(similarity ≥ 0.5) | "当前结果为相关参考,如需更精准信息请明确缺少的具体维度" | 可参考,如需更精准请指出缺少的维度后定向补充 |
### 阈值配置
```yaml
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界
highly-relevant-threshold: 0.75 # similarity ≥ 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity ≥ 0.5 → REFERENCE
```
---
## 四、行动记忆
### RetrievedDocTracker 数据结构
```java
// 从单层升级为双层:session → domain → filePath 集合
ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals;
```
### API
| 方法 | 作用 |
|------|------|
| `markRetrieved(sessionId, domain, filePath)` | 记录一次检索 |
| `isDocRetrieved(sessionId, filePath)` | 文档级去重 |
| `isDomainRetrieved(sessionId, domain)` | 域级检查 |
| `getRetrievedDomains(sessionId)` | 获取已检索域列表 |
| `clearSession(sessionId)` | 清理会话记录 |
### LookupResult 返回
```java
LookupResult.builder()
.found(true)
.primary(primaryResult)
.supplement(supplementResult)
.relevanceLevel("HIGHLY_RELEVANT") // PRECISE / HIGHLY_RELEVANT / REFERENCE
.completenessHint("当前结果已高度相关...") // 兜底信号
.retrievedDomainsThisSession(["infrastructure", "api"]) // 行动记忆
.message("...")
.build();
```
---
## 五、Executor Prompt 约束
### 4 条检索约束
1. **判断重复**:基于 `retrievedDomainsThisSession` 判断语义重叠
2. **重复了怎么办**:禁止换关键词重查;先指缺少的维度,再定向补充
3. **合法出口**:"不查全不会被追责,重复检索才会被惩罚"
4. **利用质量信号**:PRECISE → 停止;HIGHLY_RELEVANT + 域已检索 → 禁止;REFERENCE → 指出缺少维度
### 关键变化
原有 prompt:"所有需要外部信息的地方,都必须调用对应的工具"
→ 改为:"需要外部信息时调用工具,但须遵守下方的检索约束"
---
## 六、数据库变更
### V010
```sql
ALTER TABLE tool_invocation
ADD COLUMN relevance_level VARCHAR(20) COMMENT 'PRECISE/HIGHLY_RELEVANT/REFERENCE/DEDUPED',
ADD COLUMN dedup_reason VARCHAR(32) COMMENT 'doc_retrieved/domain_retrieved/null';
```
### retrieval_details JSON 扩展
```json
{
"l0_titles": ["MySQL 数据库连接池配置", "Redis 缓存配置指南"],
"l1_scores": [0.383, 0.4502, 0.7011],
"l1_top_score": 0.383,
"l1_top_similarity": 0.8085,
"relevance_level": "HIGHLY_RELEVANT",
"completeness_hint": "当前结果已高度相关,继续检索不太可能找到更精准的文档",
"retrieved_domains": ["infrastructure"]
}
```
扩展字段使用方式:
| 字段 | 用途 |
|------|------|
| `l1_top_score` | 原始 L2 距离最小值(可观测性) |
| `l1_top_similarity` | 归一化后的相似度 [0,1] |
| `relevance_level` | 归一化质量等级 |
| `completeness_hint` | 兜底信号 |
| `retrieved_domains` | 已检索域列表 |
| `dedup_reason` | 去重原因(如有) |
---
## 七、使用场景
### 场景 1:正常检索
```
用户:数据库连接池怎么配置?
Executor 内部:
1. lookup_knowledge("数据库连接池配置")
→ relevanceLevel=HIGHLY_RELEVANT (similarity=0.8085)
→ completenessHint="当前结果已高度相关..."
→ retrievedDomainsThisSession=["infrastructure"]
2. 基于已有信息直接回答,不再检索
```
### 场景 2:行动记忆阻止重复
```
Executor 步骤列表:
- 查数据库连接池配置
- 查 HikariCP 参数
- 查连接池耗尽排查
实际行为:
1. lookup("数据库连接池") → relevance=HIGHLY_RELEVANT, domains=["infrastructure"]
2. lookup("HikariCP 参数") → retrievedDomainsThisSession=["infrastructure"]
LLM 判断:infrastructure 域已检索过,禁止换关键词重查
→ 基于已有信息回答,指出缺少的具体维度
3. lookup("连接池耗尽") → 同域,被 prompt 约束拦截或工具层去重拦截
```
### 场景 3:PRECISE 精确匹配
```
用户:ERR_TIMEOUT 是什么?
Executor 内部:
1. lookup_knowledge("ERR_TIMEOUT")
→ L0 matchCount=1(唯一精确匹配)
→ relevanceLevel=PRECISE
→ completenessHint="知识库中不存在比上述结果更精准的文档"
2. 直接使用,不再检索
```
### 场景 4:REFERENCE + 定向补充
```
用户:如何排查生产故障?
Executor 内部:
1. lookup_knowledge("故障排查")
→ relevanceLevel=REFERENCE (similarity=0.6)
→ retrievedDomainsThisSession=["troubleshooting"]
2. LLM 判断:信息不足,缺少"日志分析"维度的具体步骤
3. lookup_knowledge("日志分析步骤")
→ 定向补充,不盲目换关键词
```
---
## 八、可观测性
### 查询质量分布
```sql
SELECT relevance_level, COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
GROUP BY relevance_level;
```
### 去重原因分布
```sql
SELECT dedup_reason, COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
GROUP BY dedup_reason;
```
### 归一化分数分布
```sql
SELECT
JSON_EXTRACT(retrieval_details, '$.l1_top_similarity') AS similarity,
COUNT(*) AS cnt
FROM tool_invocation
WHERE tool_name = 'lookup_knowledge'
AND retrieval_details IS NOT NULL
GROUP BY similarity
ORDER BY similarity;
```
---
## 九、扩展方向(Phase 2)
- **域级硬限流**:`isDomainRetrieved` 已就绪,在 LookupKnowledgeTool 入口直接拦截同域调用,不依赖 LLM 遵守 prompt
- **DEDUPED 等级**:去重时单独标记为 DEDUPED 等级,与 REFERENCE 区分
- **分数反馈调优**:基于 feedback 数据优化归一化阈值
+81
View File
@@ -0,0 +1,81 @@
# ISS-001 Executor 重复召回同一文档
**状态**:已修复(2026-06-30)
**严重程度**:中(影响 token 消耗和上下文质量,不影响功能正确性)
**发现时间**:2026-06-30
**修复版本**:session-dedup-knowledge-map
---
## 现象
单次对话中 `lookup_knowledge` 被调用 20 次,其中"故障诊断流程规范"被重复召回约 13 次,多个文档被重复召回 3-6 次。
```
tool_invocation 记录(db8bfa0f):
L0 命中"故障诊断流程规范" × 13
L0+L1 命中"MySQL 数据库连接池配置" × 5
L1 命中性能类故障 × 2
```
---
## 根本原因
**两个层面同时缺失去重机制:**
1. **工具层无去重**:`LookupKnowledgeTool` 每次独立检索,不感知调用历史,同一查询关键词必然返回同一文档
2. **Agent 层无记忆**:Executor Prompt 未要求跟踪已使用文档,LLM 每步倾向于"再确认一下",反复触发相同检索
**调用链路:**
```
Planner step 0:制定排查计划
Executor step 0:检索知识库 → 命中故障诊断流程规范
Executor step 1:继续检索 → 又命中故障诊断流程规范(不知道已取过)
Executor step 3:继续检索 → 又命中故障诊断流程规范
... (重复 13 次)
```
---
## 影响
- **Token 浪费**:同一文档内容反复塞入上下文,多 Agent 场景尤为明显
- **上下文窗口压缩**:重复内容占用有效 token 空间,可能导致有用信息被截断
- **evidence_score 失真**:`tool_call_count` 虚高,规则评分中"成功调用次数"被膨胀
---
## 修法方向
### 方案 A:Prompt 层约束(简单,优先验证)
在 `chat-executor-prompt.md` 中加规则:
```
已检索过的文档不要重复检索。每次调用 lookup_knowledge 前,
先检查对话历史中是否已有该文档的内容,有则直接使用,不再重复调用。
```
优点:不改代码,立即可验证
缺点:依赖 LLM 遵守指令,不保证 100% 生效
### 方案 B:工具层去重(可靠,推荐长期方案)
`LookupKnowledgeTool` 在 session 维度维护已召回文档 ID 集合,检索结果返回前过滤掉已召回的文档。
优点:彻底解决,不依赖 LLM
缺点:需要改工具代码,需要 session 级状态传递
### 建议
MVP 阶段先做**方案 A**验证效果,若重复率明显下降则保留;
若 LLM 不稳定遵守,再升级到**方案 B**。
---
## 相关文件
- `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java`
- `src/main/resources/prompts/chat-executor-prompt.md`
@@ -1,8 +1,9 @@
# ISS-002 Executor 无约束重复调用 lookup_knowledge
**状态**:待修复
**状态**:已修复
**严重程度**:中(工具层去重已拦截重复文档,但调用本身仍浪费 token 和耗时)
**发现时间**:2026-07-01
**修复时间**:2026-07-01
**关联**:ISS-001(Part A 已修,Part B 注入范围不足)
---
+1 -1
View File
@@ -3,4 +3,4 @@
| # | 标题 | 严重程度 | 状态 | 文件 |
|---|---|---|---|---|
| ISS-001 | Executor 重复召回同一文档 | 中 | 已修复 | [ISS-001-duplicate-retrieval.md](ISS-001-duplicate-retrieval.md) |
| ISS-002 | Executor 无约束重复调用 lookup_knowledge | 中 | 待修复 | [ISS-002-executor-unconstrained-lookup.md](ISS-002-executor-unconstrained-lookup.md) |
| ISS-002 | Executor 无约束重复调用 lookup_knowledge | 中 | 已修复 | [ISS-002-executor-unconstrained-lookup.md](ISS-002-executor-unconstrained-lookup.md) |
@@ -0,0 +1 @@
committed
@@ -0,0 +1,157 @@
# Design: session-dedup-knowledge-map
## 1. 整体架构
本 change 包含两个独立但互补的部分:
```
Part A: 工具层去重
LookupKnowledgeTool
├── 维护 ConcurrentHashMap<sessionId, Set<filePath>>(JVM 内)
├── 每次检索前过滤已召回文档
└── SessionContextHolder.clear() 时同步清理
Part B: 知识图谱
┌─────────────────────────────────────────────┐
│ 文档上传 (DocumentManagementService) │
│ → LLM 生成 doc.covers + doc.when_to_retrieve │
│ → 存入 api_document.metadata │
│ → 触发域级重算 (KnowledgeDomainService) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 域级聚合 (KnowledgeDomainService) │
│ → 读取同域所有文档的 when_to_retrieve │
│ → LLM 生成 domain.when_to_retrieve │
│ → 存入 knowledge_domain 表 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 启动 (KnowledgeIndexService.loadIndex) │
│ → 加载 knowledge_domain 表 │
│ → 某域无记录则触发域级生成 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Planner prompt (ChatService) │
│ → 注入 knowledge map(域级) │
│ → Planner 做粗粒度检索决策 │
└─────────────────────────────────────────────┘
```
---
## 2. 数据结构定义
### 2.1 Frontmatter 新增字段
```yaml
# 新增两个字段,其余不变
covers: ["支付失败排查", "扣款无回调"] # List<String>:业务场景标签,Planner 决策用
when_to_retrieve: "用户描述支付失败、超时时" # String:文档级检索时机,LLM 上传时生成
```
对应 `Frontmatter.java` 新增两个字段:
- `List<String> covers`
- `String whenToRetrieve`
对应 `KnowledgeEntry.java` 新增两个字段(同上)。
### 2.2 knowledge_domain 表(新表)
```sql
CREATE TABLE knowledge_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain_id VARCHAR(64) NOT NULL UNIQUE, -- category 值,如 "payment"
description VARCHAR(256), -- 域描述(聚合自文档 summary)
when_to_retrieve TEXT, -- 域级检索时机(LLM 生成)
document_count INT DEFAULT 0, -- 该域当前文档数
updated_at DATETIME,
created_at DATETIME
);
```
### 2.3 knowledge map 结构(注入 Planner 的 YAML 文本)
```yaml
available_knowledge_domains:
- domain_id: "payment"
description: "支付链路问题排查"
when_to_retrieve: "用户问题涉及支付、退款、对账时检索;优先检索一次,勿重复"
documents:
- title: "支付失败排查手册"
covers: ["支付超时", "扣款无回调"]
- title: "退款处理指南"
covers: ["退款未到账", "退款状态异常"]
- domain_id: "infrastructure"
...
```
---
## 3. 新增组件
### 3.1 KnowledgeDomainService(新类)
职责:域级聚合与存储
```
buildDomainSummary(category)
→ 读取同域所有 KnowledgeEntry(含 when_to_retrieve)
→ 拼装 prompt,调用 LLM
→ 写入 knowledge_domain 表
buildKnowledgeMap()
→ 读取所有 knowledge_domain 记录
→ 拼装 YAML 文本(含 documents 列表)
→ 返回 String(供 Planner prompt 注入)
onDocumentChange(category)
→ 调用 buildDomainSummary(category)(只重算受影响域)
```
### 3.2 RetrievedDocTracker(新类,或内联入 LookupKnowledgeTool)
职责:session 级已召回文档追踪
```
ConcurrentHashMap<String, Set<String>> retrieved
key: sessionId
value: Set<filePath>
isAlreadyRetrieved(sessionId, filePath) → boolean
markRetrieved(sessionId, filePath)
clearSession(sessionId) ← 由 SessionContextHolder.clear() 触发
```
---
## 4. 改动文件清单
| 文件 | 改动类型 | 说明 |
|---|---|---|
| `LookupResult.java` | 修改 | 新增 `message` 字段(去重提示文本) |
| `Frontmatter.java` | 修改 | 新增 `covers`、`whenToRetrieve` |
| `KnowledgeEntry.java` | 修改 | 新增 `covers`、`whenToRetrieve` |
| `FrontmatterParser.java` | 修改 | 解析新字段 |
| `DocumentManagementService.java` | 修改 | upload 时调 LLM 生成文档级字段;upload/delete 后触发域级重算 |
| `KnowledgeIndexService.java` | 修改 | loadIndex 时加载域级数据;若域无记录则触发生成 |
| `KnowledgeDomainService.java` | 新增 | 域聚合、LLM 调用、DB 读写、buildKnowledgeMap |
| `KnowledgeDomain.java`(entity) | 新增 | knowledge_domain 表映射 |
| `KnowledgeDomainRepository.java` | 新增 | JPA Repository |
| `LookupKnowledgeTool.java` | 修改 | 集成 RetrievedDocTracker,检索前过滤,检索后标记 |
| `SessionContextHolder.java` | 修改 | clear() 时通知 RetrievedDocTracker |
| `RetrievedDocTracker.java` | 新增 | session 级去重状态管理 |
| `ChatService.java` | 修改 | buildChatPlannerAgent 注入 knowledge map |
| `chat-planner-prompt.md` | 修改 | 添加 knowledge map 使用规则 |
| `V009__add_knowledge_domain.sql` | 新增 | Flyway 建表脚本 |
---
## 5. 关键决策记录
1. **域级 when_to_retrieve 存 DB**:避免每次重启调 LLM;文档变更时只重算受影响域
2. **文档级 when_to_retrieve 存 metadata JSON**:沿用现有 frontmatter 存储路径,无需新字段
3. **RetrievedDocTracker 独立于 SessionContextHolder**:SessionContextHolder 只持有 sessionId,Tracker 是业务状态,职责分离;clear() 时通过 Tracker.clearSession() 联动
4. **Planner 只看域级**:文档级 when_to_retrieve 留 Executor 筛选(Phase 2),MVP 不暴露给 Planner
5. **LLM 调用同步执行**:上传时同步生成,接受约 1-2s 延迟,保证数据库和 L0 索引立即一致
@@ -0,0 +1,61 @@
# Proposal: session-dedup-knowledge-map
## 问题
1. **ISS-001 重复召回**:`LookupKnowledgeTool` 每次调用完全无状态,同一 session 中同一文档可被重复召回 13+ 次,浪费 token、压缩上下文窗口、导致 `tool_call_count` 虚高。
2. **Planner 缺少全局视野**:Planner 不知道知识库里有哪些域,只能靠 Executor 反复试探,导致低效的"盲目检索"模式。
## 建议方案
### Part A:工具层去重(彻底修复 ISS-001)
在 `LookupKnowledgeTool` 的 session 维度维护已召回文档 ID 集合。
每次检索时,过滤掉已召回的文档;相同 query 命中相同文档则直接跳过(返回"已在上下文中"提示)。
状态存储:`ConcurrentHashMap<sessionId, Set<docKey>>`,生命周期随 session(`SessionContextHolder.clear()` 时清理)。
### Part B:知识图谱注入 Planner
启动时(`KnowledgeIndexService.loadIndex()` 完成后),将 L0 索引中的所有 `KnowledgeEntry` 聚合为域级摘要(knowledge map)。
每次构建 Planner prompt 时(`buildChatPlannerAgent()`),将 knowledge map 注入 system prompt,让 Planner 有"知识边界"。
聚合策略:按 `category` 字段分组,生成结构:
```
available_knowledge_domains:
- domain_id: "payment"
description: "..."
covers: [...]
document_count: N
when_to_retrieve: "..."
```
知识图谱的 `description` / `when_to_retrieve` 字段来源于:
- 选项 1:直接聚合 KnowledgeEntry 的 title/summary
- 选项 2:文档 frontmatter 中新增 `domain_description` / `when_to_retrieve` 字段
- 选项 3:上传时 LLM 自动生成这两个字段
## 范围
**In scope**:
- `LookupKnowledgeTool`:添加 session 级去重状态管理
- `KnowledgeIndexService`:添加 `buildKnowledgeMap()` 方法
- `ChatService.buildChatPlannerAgent()`:注入 knowledge map 到 prompt
- `chat-planner-prompt.md`:添加如何使用 knowledge map 的指令
**Out of scope**(本次不做):
- `EvaluationService.tool_call_count` 的统计口径调整(去重后虚高问题自然消失,但评分规则不改)
- RRF 混合重排
- 文档 frontmatter 自动生成(上传时 LLM 生成,留 Phase 2)
## 风险
- Part A 引入 JVM 内存 Map,高并发时多 session 并发需线程安全
- Part B knowledge map 注入 Planner prompt 会增加每次请求的 token 消耗(固定开销)
- 文档 `category` 字段缺失或不规范时,聚合结果可能混乱
## 上下文约束
- `SessionContextHolder` 是 ThreadLocal,异步路径不安全(已知限制,Part A 需确认同步路径)
- `EvaluationService` 依赖 `tool_call_count`,去重会降低此值(是修复,不是回归)
- `KnowledgeEntry` 已有 `category` 字段,但当前数据库中的文档是否都有 `category` 需确认
@@ -0,0 +1,87 @@
# Functional Spec: session-dedup-knowledge-map
## REQ-01:工具层去重(Part A)
**触发**:`LookupKnowledgeTool.lookupKnowledge(query)` 被调用
**行为**:
1. 从 `SessionContextHolder.getSessionId()` 获取当前 sessionId;若为 null(非会话上下文)跳过去重逻辑,正常检索
2. L0+L1 检索完成后,将结果中已在 `RetrievedDocTracker` 中标记的 filePath 过滤掉
3. 若过滤后 L0 结果为空、L1 结果也为空(全部已召回),返回 `LookupResult.found=false`,并在结果中附带提示文本:"以下文档已在本会话中检索过:[列表],无需重复召回"
4. 未被过滤的文档正常返回后,将其 filePath 写入 `RetrievedDocTracker`
5. `SessionContextHolder.clear()` 调用时,`RetrievedDocTracker.clearSession(sessionId)` 同步清理
**验收**:
- 同一 session 内同一文档第二次命中时,返回去重提示而非完整文档内容
- 不同 session 之间互不影响
- sessionId 为 null 时不影响正常检索流程
---
## REQ-02:文档级 LLM 字段生成(Part B - 文档级)
**触发**:`DocumentManagementService.uploadDocument()` 完成 frontmatter 解析后
**行为**:
1. 若文档 frontmatter 中已包含 `covers` 和 `whenToRetrieve`,跳过 LLM 生成(作者手动填写优先)
2. 否则,调用 LLM,输入为文档 title + summary + 正文前 1000 字符
3. Prompt 要求 LLM 返回 JSON:`{"covers": [...], "whenToRetrieve": "..."}`
4. 解析结果,回填到 `Frontmatter` 对象
5. 序列化存入 `api_document.metadata`;同步更新 `KnowledgeEntry` 写入 L0 索引
6. LLM 调用失败时,`covers` 置为空列表,`whenToRetrieve` 置为 summary(降级),不阻断上传流程
**验收**:
- 上传后 `api_document.metadata` 中包含 `covers` 和 `whenToRetrieve` 字段
- frontmatter 已有这两个字段时不覆盖
- LLM 调用异常时文档仍上传成功,字段降级填充
---
## REQ-03:域级聚合与存储(Part B - 域级)
**触发**:文档上传成功后;文档删除后;`loadIndex()` 时发现某域在 `knowledge_domain` 表无记录
**行为**:
1. `KnowledgeDomainService.onDocumentChange(category)` 读取该 category 下所有 `KnowledgeEntry` 的 title + covers + whenToRetrieve
2. 调用 LLM,生成域级 `when_to_retrieve`(要求 LLM 识别域内文档边界,输出含区分语义的路由描述)
3. 写入 `knowledge_domain` 表(upsert by domain_id),同时更新 `document_count`
4. LLM 调用失败时,`domain.when_to_retrieve` 保留上次 DB 记录;若无历史记录则置为空字符串
**验收**:
- 上传文档后,对应 category 的 `knowledge_domain` 记录被更新
- 删除文档后,对应 category 的 `document_count` 减少,`when_to_retrieve` 重新生成
- `loadIndex()` 时无 DB 记录的域自动触发生成
---
## REQ-04:knowledge map 注入 Planner(Part B - 注入)
**触发**:`ChatService.buildChatPlannerAgent()` 调用时
**行为**:
1. 调用 `KnowledgeDomainService.buildKnowledgeMap()` 生成 YAML 文本
2. YAML 结构:域列表,每个域包含 domain_id、description、when_to_retrieve、documents(title + covers)
3. 若 knowledge_domain 表为空(无任何域记录),跳过注入,不修改 prompt
4. 注入位置:Planner system prompt 末尾,独立区块
**Planner prompt 附加规则**:
- 制定步骤时,先查看 `available_knowledge_domains`,按 `when_to_retrieve` 判断是否需要检索该域
- 每个域最多指示 Executor 检索一次;已检索过的域不再安排检索步骤
**验收**:
- Planner prompt 包含 `available_knowledge_domains` 区块
- 无域记录时 prompt 不包含该区块(不注入空结构)
- knowledge map 文本长度 < 1000 字符(6 个文档场景下)
---
## REQ-05:Frontmatter 字段扩展
**行为**:
- `Frontmatter.java` 新增 `List<String> covers` 和 `String whenToRetrieve`
- `KnowledgeEntry.java` 新增同名字段
- `FrontmatterParser.java` 解析 `covers`(YAML 数组)和 `when_to_retrieve`(YAML 字符串)
**验收**:
- 现有文档(无新字段)上传/解析不报错,字段为 null 或空列表
- 含新字段的文档正确解析
@@ -0,0 +1,74 @@
# Tasks: session-dedup-knowledge-map
## T1:数据层基础
**T1-1:新增 knowledge_domain 表** ✅
- 创建 `src/main/resources/db/migration/V009__add_knowledge_domain.sql`
- 字段:id、domain_id(unique)、description、when_to_retrieve(TEXT)、document_count、created_at、updated_at
**T1-2:新增 KnowledgeDomain 实体和 Repository** ✅
- `KnowledgeDomain.java`:JPA 实体,对应 knowledge_domain 表
- `KnowledgeDomainRepository.java`:`findByDomainId(String)` + save
---
## T2:Frontmatter 扩展
**T2-1:Frontmatter.java / KnowledgeEntry.java 新增字段** ✅
- `Frontmatter`:新增 `List<String> covers`、`String whenToRetrieve`
- `KnowledgeEntry`:新增 `List<String> covers`、`String whenToRetrieve`
**T2-2:FrontmatterParser 解析新字段** ✅
- 解析 YAML 中的 `covers`(List)和 `when_to_retrieve`(String)
**T2-3:KnowledgeIndexService 替换为 Jackson 解析** ✅
- 全量替换手写 extractJsonValue/extractJsonArray 为 `objectMapper.readValue(metadata, Frontmatter.class)`
**T2-4:LookupResult 新增 message 字段** ✅
- 新增 `String message` 字段,去重时填入提示
---
## T3:工具层去重(Part A)
**T3-1:新增 RetrievedDocTracker** ✅
- `RetrievedDocTracker.java`:Spring `@Component`,`ConcurrentHashMap<String, Set<String>>`
- 方法:`isAlreadyRetrieved`、`markRetrieved`、`clearSession`
**T3-2:ChatService.finally 联动 Tracker** ✅
- `executeChat` / `executeChatComplex` 的 finally 块显式调用 `retrievedDocTracker.clearSession(sessionId)`
**T3-3:LookupKnowledgeTool 集成去重** ✅
- 注入 `RetrievedDocTracker`,检索后过滤已召回文档,全部已召回时返回去重提示
---
## T4:文档级 LLM 生成(Part B 文档级)
**T4-1:新增 DocumentFieldEnricher + 上传时调用** ✅
- `DocumentFieldEnricher.java`:调用 LLM 生成 covers / whenToRetrieve
- `DocumentManagementService.uploadDocument()` frontmatter 解析后调用 enrich
- 失败时降级(covers=空列表,whenToRetrieve=summary),不阻断上传
---
## T5:域级聚合(Part B 域级)
**T5-1:新增 KnowledgeDomainService** ✅
- `buildDomainSummary`:同域文档聚合 → LLM → upsert knowledge_domain
- `buildKnowledgeMap`:全量 knowledge_domain → YAML 字符串
- `onDocumentChange`:触发 buildDomainSummary
**T5-2:loadIndex 触发域生成 + 文档变更触发域重算** ✅
- `KnowledgeIndexService.loadIndex` 末尾:无 DB 记录的域自动触发生成
- `DocumentManagementService.uploadDocument` / `deleteDocument` 末尾:调用 `onDocumentChange`
---
## T6:Planner 注入(Part B 注入)
**T6-1:ChatService 注入 knowledge map** ✅
- `buildChatPlannerAgent()` 注入 `KnowledgeDomainService.buildKnowledgeMap()` 到 prompt
**T6-2:chat-planner-prompt.md 新增规则** ✅
- 新增知识库检索规则区块,要求 Planner 按 when_to_retrieve 决策、每域最多一次