diff --git a/devflow/index.md b/devflow/index.md index 0d2dabb..a6831f1 100644 --- a/devflow/index.md +++ b/devflow/index.md @@ -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 | diff --git a/devflow/projects/2026-06-30-session-dedup-knowledge-map/brief.md b/devflow/projects/2026-06-30-session-dedup-knowledge-map/brief.md new file mode 100644 index 0000000..6504192 --- /dev/null +++ b/devflow/projects/2026-06-30-session-dedup-knowledge-map/brief.md @@ -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/ diff --git a/devflow/projects/2026-06-30-session-dedup-knowledge-map/decisions.md b/devflow/projects/2026-06-30-session-dedup-knowledge-map/decisions.md new file mode 100644 index 0000000..bec0e59 --- /dev/null +++ b/devflow/projects/2026-06-30-session-dedup-knowledge-map/decisions.md @@ -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>`,`SessionContextHolder.clear()` 时同步清理 diff --git a/mvp/README.md b/mvp/README.md index fdd0af8..e52162c 100644 --- a/mvp/README.md +++ b/mvp/README.md @@ -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 ⭐新增 --- diff --git a/mvp/architecture/action-memory-relevance.md b/mvp/architecture/action-memory-relevance.md new file mode 100644 index 0000000..bbc00dd --- /dev/null +++ b/mvp/architecture/action-memory-relevance.md @@ -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>> 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 数据优化归一化阈值 diff --git a/mvp/issues/ISS-001-duplicate-retrieval.md b/mvp/issues/ISS-001-duplicate-retrieval.md new file mode 100644 index 0000000..74b6fc0 --- /dev/null +++ b/mvp/issues/ISS-001-duplicate-retrieval.md @@ -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` diff --git a/mvp/issues/ISS-002-executor-unconstrained-lookup.md b/mvp/issues/ISS-002-executor-unconstrained-lookup.md index e30f4a7..c325339 100644 --- a/mvp/issues/ISS-002-executor-unconstrained-lookup.md +++ b/mvp/issues/ISS-002-executor-unconstrained-lookup.md @@ -1,8 +1,9 @@ # ISS-002 Executor 无约束重复调用 lookup_knowledge -**状态**:待修复 +**状态**:已修复 **严重程度**:中(工具层去重已拦截重复文档,但调用本身仍浪费 token 和耗时) **发现时间**:2026-07-01 +**修复时间**:2026-07-01 **关联**:ISS-001(Part A 已修,Part B 注入范围不足) --- diff --git a/mvp/issues/README.md b/mvp/issues/README.md index c8b4f46..1087826 100644 --- a/mvp/issues/README.md +++ b/mvp/issues/README.md @@ -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) | diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/.archive-ready b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/.archive-ready new file mode 100644 index 0000000..e69de29 diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/.committed b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/.committed new file mode 100644 index 0000000..da2bdc1 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/.committed @@ -0,0 +1 @@ +committed \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/design.md b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/design.md new file mode 100644 index 0000000..3e07463 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/design.md @@ -0,0 +1,157 @@ +# Design: session-dedup-knowledge-map + +## 1. 整体架构 + +本 change 包含两个独立但互补的部分: + +``` +Part A: 工具层去重 + LookupKnowledgeTool + ├── 维护 ConcurrentHashMap>(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:业务场景标签,Planner 决策用 +when_to_retrieve: "用户描述支付失败、超时时" # String:文档级检索时机,LLM 上传时生成 +``` + +对应 `Frontmatter.java` 新增两个字段: +- `List 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> retrieved + key: sessionId + value: Set + +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 索引立即一致 diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/proposal.md b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/proposal.md new file mode 100644 index 0000000..edc42d7 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/proposal.md @@ -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>`,生命周期随 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` 需确认 diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/specs/functional-spec.md b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/specs/functional-spec.md new file mode 100644 index 0000000..019847d --- /dev/null +++ b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/specs/functional-spec.md @@ -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 covers` 和 `String whenToRetrieve` +- `KnowledgeEntry.java` 新增同名字段 +- `FrontmatterParser.java` 解析 `covers`(YAML 数组)和 `when_to_retrieve`(YAML 字符串) + +**验收**: +- 现有文档(无新字段)上传/解析不报错,字段为 null 或空列表 +- 含新字段的文档正确解析 diff --git a/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/tasks.md b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/tasks.md new file mode 100644 index 0000000..34ee2a1 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-session-dedup-knowledge-map/tasks.md @@ -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 covers`、`String whenToRetrieve` +- `KnowledgeEntry`:新增 `List 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>` +- 方法:`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 决策、每域最多一次