feat(knowledge): Executor 行动记忆 + 归一化质量等级解决 ISS-002 重复检索

- RetrievedDocTracker 升级为域级+文档级双层记录(Map<sessionId, Map<domain, Set<filePath>>>)
- LookupKnowledgeTool 新增 Min-Max 归一化层(BGE-M3 L2 距离→[0,1] similarity)
- 三等级 relevanceLevel:PRECISE / HIGHLY_RELEVANT / REFERENCE + completenessHint 兜底信号
- LookupResult 新增 relevanceLevel、completenessHint、retrievedDomainsThisSession
- Executor prompt 重写:4 条检索约束 + 合法出口不查全不追责,重复检索才惩罚
- 入库可观测性:V010 迁移 + retrieval_details JSON 扩展
- 归档 executor-action-memory-relevance change
This commit is contained in:
zhuyongxin
2026-07-01 18:24:41 +08:00
parent e4f37cb9e6
commit e438df4355
21 changed files with 1076 additions and 88 deletions
@@ -0,0 +1,189 @@
# Design: executor-action-memory-relevance
## 架构设计
### 整体数据流
```
用户问题
→ Supervisor → Planner(规划查哪些域)
→ Supervisor → Executor(自主调用 lookup_knowledge)
↓
LookupKnowledgeTool
├─ L0 精确匹配 → l0Matches (含 category)
├─ L1 语义检索 → l1Results (含 L2 score)
├─ 归一化层 → computeRelevanceLevel(l0Count, l1TopScore)
│ L2 距离 → similarity = 1 - min(score, 2.0) / 2.0
│ L0 唯一匹配 → PRECISE
│ L0 命中 + L1 similarity ≥ 0.75 → HIGHLY_RELEVANT
│ 仅 L1 similarity ≥ 0.75 → HIGHLY_RELEVANT
│ L0 多匹配 + L1 similarity [0.5, 0.75) → REFERENCE
│ 仅 L1 similarity [0.5, 0.75) → REFERENCE
├─ 域级行动记忆 → RetrievedDocTracker.markRetrieved(sessionId, domain, filePath)
│ getRetrievedDomains(sessionId) → retrievedDomainsThisSession
├─ 文档级去重 → 保留现有逻辑
└─ 组装 LookupResult(含 relevanceLevel, completenessHint, retrievedDomainsThisSession)
↓
LLM 看到:
relevanceLevel: PRECISE
completenessHint: "知识库中不存在比上述结果更精准的文档"
retrievedDomainsThisSession: ["infrastructure", "api"]
```
### Agent 边界(保持清晰)
| Agent | 知道什么 | 不知道什么 |
|-------|---------|-----------|
| Planner | 全域知识边界(knowledge map) | 执行细节、检索结果 |
| Executor | 自己的行动记忆(已检索域列表) | 全域知识边界(不注入 knowledge map) |
行动记忆通过**工具返回值**传递,不通过 prompt 注入。
### 数据结构设计
#### 1. RetrievedDocTracker 升级
```java
// 现有:sessionId → Set<filePath>(文档级)
ConcurrentHashMap<String, Set<String>> retrieved
// 新增:sessionId → { domain → Set<filePath> }(域级 + 文档级)
ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals
```
方法列表:
- `markRetrieved(sessionId, domain, filePath)` — 一次记录两层
- `isDocRetrieved(sessionId, filePath)` → boolean — 文档级去重(替代现有 isAlreadyRetrieved)
- `isDomainRetrieved(sessionId, domain)` → boolean — 域级检查(Phase 2 硬限制用)
- `getRetrievedDomains(sessionId)` → List<String> — 行动记忆(返回给 LLM)
- `clearSession(sessionId)` — 清理(不变)
#### 2. LookupResult 扩展
```java
@Data @Builder
public class LookupResult {
boolean found;
PrimaryResult primary; // 不变,不暴露原始分数
SupplementResult supplement; // 不变,不暴露原始分数
// ---- 新增 ----
String relevanceLevel; // PRECISE / HIGHLY_RELEVANT / REFERENCE
String completenessHint; // 兜底信号
List<String> retrievedDomainsThisSession; // 行动记忆
String message; // 不变
}
```
**PrimaryResult 和 SupplementResult 不加任何分数字段**。原始分数在归一化层内部消化。
#### 3. 归一化计算
`RelevanceNormalizer`(LookupKnowledgeTool 内部静态方法):
```
输入:l0MatchCount, l1TopScore (L2 距离)
输出:RelevanceAssessment { relevanceLevel, completenessHint }
归一化公式(BGE-M3 输出 L2 归一化单位向量,已实测验证):
similarity = 1 - min(l2Score, maxL2Distance) / maxL2Distance
maxL2Distance 默认 2.0,yml 可覆盖
判定逻辑:
if l0MatchCount == 1 → PRECISE
if l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold → HIGHLY_RELEVANT
if l0MatchCount == 0 && l1Similarity >= highlyRelevantThreshold → HIGHLY_RELEVANT
if l0MatchCount > 1 && l1Similarity >= referenceThreshold → REFERENCE
if l0MatchCount == 0 && l1Similarity >= referenceThreshold → REFERENCE
else → 无结果
completenessHint 映射:
PRECISE → "知识库中不存在比上述结果更精准的文档"
HIGHLY_RELEVANT → "当前结果已高度相关,继续检索不太可能找到更精准的文档"
REFERENCE → "当前结果为相关参考,如需更精准信息请明确缺少的具体维度"
```
配置项(application.yml):
```yaml
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界(单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity ≥ 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity ≥ 0.5 → REFERENCE
```
#### 4. 入库记录扩展
`tool_invocation` 表新增列:
| 列名 | 类型 | 说明 |
|------|------|------|
| `relevance_level` | VARCHAR(20) | PRECISE / HIGHLY_RELEVANT / REFERENCE / DEDUPED |
| `dedup_reason` | VARCHAR(32) | doc_retrieved / domain_retrieved / null |
`retrieval_details` JSON 扩展:
```json
{
"l0_match_count": 2,
"l0_titles": ["MySQL连接池配置", "HikariCP参数调优"],
"l1_top_score": 0.52,
"l1_top_similarity": 0.74,
"l1_match_count": 3,
"l1_scores": [0.52, 0.68, 0.91],
"relevance_level": "HIGHLY_RELEVANT",
"completeness_hint": "当前结果已高度相关...",
"retrieved_domains": ["infrastructure"],
"dedup_reason": null
}
```
原始 L2 score 和归一化后的 similarity 都入库,保留可观测性。
### Executor Prompt 设计
不加 knowledge map,只加基于行动记忆的行为规则:
```markdown
## 检索约束
### 1. 判断重复:基于已检索上下文
每次 lookup_knowledge 返回值中包含 retrievedDomainsThisSession,
表示本次会话已检索过的知识域。如果当前问题与已检索域语义重叠,
**禁止再次调用 lookup_knowledge**。
### 2. 重复了该怎么办
如果当前想检索的内容与【已检索上下文】语义相似:
- 禁止换关键词重新检索
- 直接基于已有事实回答
- 如果信息不足,先明确指出缺少什么具体维度
(如:"缺少 HikariCP 具体配置参数"、"缺少连接池耗尽的日志样例"),
再针对该维度进行一次定向补充检索——而非盲目换词重查
### 3. 合法出口:允许信息不全时给出结论
如果你认为已有信息足以回答核心问题,即使细节不全,
也请直接给出结论并说明局限性(如:"基于已有信息,连接池配置建议如下,
但具体参数值需结合实际负载调整")。
**不查全不会被追责,重复检索才会被惩罚。**
### 4. 利用质量信号判断
- relevanceLevel=PRECISE → 信息精准,直接使用,不再检索
- relevanceLevel=HIGHLY_RELEVANT + 域已在 retrievedDomainsThisSession → 禁止再次调用
- relevanceLevel=REFERENCE → 先指出缺什么维度,再定向补充一次
- completenessHint 是知识库给你的天花板信号,信任它
```
### 关键决策
1. **L0/L1 原始分数不暴露给 LLM** — 在归一化层内部消化,避免 LLM 混淆尺度
2. **BGE-M3 L2 归一化已实测验证** — 范数 1.00000002,maxL2Distance=2.0 是数学硬上界
3. **行动记忆通过工具返回值传递** — 不通过 prompt 注入,不修改 ReactAgent prompt 构建方式
4. **不给 Executor knowledge map** — 保持 Agent 边界:Planner 知道全域,Executor 只知道自己做了什么
5. **Phase 2 域级硬限制暂不实施** — 先观察 prompt 约束 + 归一化信号的效果
### 接口影响分级
| 变更 | 级别 | 说明 |
|------|------|------|
| RetrievedDocTracker 数据结构升级 | L2 内部接口 | 消费者只有 LookupKnowledgeTool,在同一实现范围内 |
| LookupResult 新增 3 个字段 | L2 内部接口 | 消费者是 LLM(工具返回值),无跨模块调用方 |
| tool_invocation 表新增 2 列 | L2 内部接口 | Flyway 迁移,nullable,不影响现有查询 |
| chat-executor-prompt.md 更新 | L1 内部实现 | Prompt 文本变更,不改变接口 |