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
@@ -61,6 +61,12 @@ public class ToolInvocation {
@Column(name = "is_truncated")
private Boolean isTruncated;
@Column(name = "relevance_level", length = 20)
private String relevanceLevel;
@Column(name = "dedup_reason", length = 32)
private String dedupReason;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "retrieval_details", columnDefinition = "JSON")
private String retrievalDetails;
@@ -27,6 +27,21 @@ public class LookupResult {
*/
private SupplementResult supplement;
/**
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE
*/
private String relevanceLevel;
/**
* 兜底信号:告诉 LLM 知识库的"天花板"
*/
private String completenessHint;
/**
* 本次会话已检索过的域列表(行动记忆)
*/
private List<String> retrievedDomainsThisSession;
/**
* 系统消息(如去重提示)
*/
@@ -1,5 +1,6 @@
package com.superbiz.agent.tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.*;
import com.superbiz.agent.repository.ToolInvocationRepository;
@@ -9,6 +10,7 @@ import com.superbiz.agent.util.SessionContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -17,11 +19,29 @@ import java.util.stream.Collectors;
/**
* 知识库查询工具
* 提供给 Agent 的混合检索工具(L0 + L1)
* 内置归一化层:将 L0 匹配数 + L1 L2 距离归一化为统一质量等级
*/
@Slf4j
@Component
public class LookupKnowledgeTool {
private static final String LEVEL_PRECISE = "PRECISE";
private static final String LEVEL_HIGHLY_RELEVANT = "HIGHLY_RELEVANT";
private static final String LEVEL_REFERENCE = "REFERENCE";
private static final String HINT_PRECISE = "知识库中不存在比上述结果更精准的文档";
private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档";
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance;
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
private double highlyRelevantThreshold;
@Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@@ -34,6 +54,9 @@ public class LookupKnowledgeTool {
@Autowired
private RetrievedDocTracker retrievedDocTracker;
@Autowired
private ObjectMapper objectMapper;
/**
* 查询知识库文档
*
@@ -50,7 +73,6 @@ public class LookupKnowledgeTool {
"4) 配置说明 - 查询系统配置、中间件参数,例如 'HikariCP'、'Redis 集群配置'。" +
"参数 query: 查询关键词或描述")
public LookupResult lookupKnowledge(String query) {
// 生成请求ID用于追踪
String requestId = java.util.UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis();
@@ -69,7 +91,7 @@ public class LookupKnowledgeTool {
log.info("[L0 精确匹配] 找到文档:");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
KnowledgeEntry entry = l0Matches.get(i);
log.info(" - [{}] 标题: {}, 路径: {}", i+1, entry.getTitle(), entry.getFilePath());
log.info(" - [{}] 标题: {}, 路径: {}, 域: {}", i+1, entry.getTitle(), entry.getFilePath(), entry.getCategory());
}
}
@@ -91,90 +113,201 @@ public class LookupKnowledgeTool {
log.info("[L1 语义检索] 找到文档:");
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
VectorSearchService.SearchResult result = l1Results.get(i);
log.info(" - [{}] 文档ID: {}, 相似度得分: {}", i+1, result.getId(), result.getScore());
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
}
}
} else {
log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
}
// Step 4: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
// Step 4: 归一化质量等级判定
float l1TopScore = (l1Results != null && !l1Results.isEmpty()) ? l1Results.get(0).getScore() : Float.MAX_VALUE;
RelevanceAssessment assessment = computeRelevance(l0Matches.size(), l1TopScore);
log.info("[归一化] relevanceLevel={}, completenessHint={}", assessment.level, assessment.hint);
if (l1TopScore != Float.MAX_VALUE) {
double similarity = normalizeL2(l1TopScore);
log.info("[归一化] L2距离={}, similarity={}", String.format("%.4f", l1TopScore), String.format("%.4f", similarity));
}
// Step 5: session 级去重过滤
// Step 5: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
result.setRelevanceLevel(assessment.level);
result.setCompletenessHint(assessment.hint);
// Step 6: session 级去重过滤 + 域级行动记忆
String sessionId = SessionContextHolder.getSessionId();
String domain = extractDomain(l0Matches, l1Results);
if (sessionId != null && result.isFound()) {
String docKey = extractDocKey(result);
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result);
List<String> retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
return LookupResult.builder()
.found(false)
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
.relevanceLevel(assessment.level)
.completenessHint(assessment.hint)
.retrievedDomainsThisSession(retrievedDomains)
.build();
}
if (docKey != null) {
retrievedDocTracker.markRetrieved(sessionId, docKey);
retrievedDocTracker.markRetrieved(sessionId, domain, docKey);
}
}
// 记录结构化结果摘要(替代原始 MD 内容预览)
// 附加行动记忆
if (sessionId != null) {
result.setRetrievedDomainsThisSession(retrievedDocTracker.getRetrievedDomains(sessionId));
}
// 记录结构化结果摘要
long totalTime = System.currentTimeMillis() - startTime;
log.info("----------------------------------------");
log.info("<<< [工具返回] lookup_knowledge");
log.info("<<< 结果: found={}, 耗时: {}ms (L0={}ms, L1={}ms)",
result.isFound(), totalTime, l0Time,
l1Results != null ? System.currentTimeMillis() - startTime - l0Time : 0);
log.info("<<< 结果: found={}, relevanceLevel={}, 耗时: {}ms",
result.isFound(), result.getRelevanceLevel(), totalTime);
log.info("<<< 行动记忆: retrievedDomainsThisSession={}", result.getRetrievedDomainsThisSession());
// L0 精确匹配摘要
if (!l0Matches.isEmpty()) {
KnowledgeEntry top = l0Matches.get(0);
log.info("<<< [L0 主结果] 标题: {}", top.getTitle());
log.info("<<< [L0 主结果] 来源: {}", top.getFilePath());
log.info("<<< [L0 主结果] 域: {}", top.getCategory());
if (top.getSummary() != null) {
log.info("<<< [L0 主结果] 摘要: {}", top.getSummary());
}
if (top.getKeywords() != null && !top.getKeywords().isEmpty()) {
log.info("<<< [L0 主结果] 关键词: {}", String.join(", ", top.getKeywords()));
}
// 内容概况:长度 + 章节数
String content = result.getPrimary() != null ? result.getPrimary().getContent() : null;
if (content != null) {
int headingCount = countMdHeadings(content);
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节",
content.length(), headingCount);
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节", content.length(), headingCount);
}
}
// L1 语义检索摘要
if (l1Results != null && !l1Results.isEmpty()) {
VectorSearchService.SearchResult topL1 = l1Results.get(0);
log.info("<<< [L1 补充] 来源: {}", topL1.getMetadata() != null ? topL1.getMetadata() : topL1.getId());
log.info("<<< [L1 补充] 相似度: {}", String.format("%.4f", topL1.getScore()));
if (topL1.getContent() != null) {
String snippet = extractFirstMeaningfulLine(topL1.getContent(), 120);
log.info("<<< [L1 补充] 内容片段: {}", snippet);
log.info("<<< [L1 补充] 片段长度: {} 字符", topL1.getContent().length());
}
log.info("<<< [L1 补充] L2距离: {}, similarity: {}",
String.format("%.4f", topL1.getScore()),
String.format("%.4f", normalizeL2(topL1.getScore())));
}
log.info("========================================");
// 记录 tool_invocation(持久化检索明细)
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result);
// 记录 tool_invocation
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, null);
return result;
}
// ==================== 归一化层 ====================
/**
* L2 距离 Min-Max 归一化到 [0,1] similarity
* BGE-M3 输出 L2 归一化单位向量,L2 距离硬上界 = 2.0
* similarity = 1 - min(score, maxL2Distance) / maxL2Distance
* score=0 → 1.0(完全相同),score=2.0 → 0.0(完全相反)
*/
double normalizeL2(float l2Score) {
double clamped = Math.min(l2Score, maxL2Distance);
return 1.0 - clamped / maxL2Distance;
}
/**
* 归一化质量等级判定
*
* @param l0MatchCount L0 匹配数
* @param l1TopScore L1 最高分(L2 距离),无 L1 结果时传 Float.MAX_VALUE
* @return RelevanceAssessment(level + hint)
*/
RelevanceAssessment computeRelevance(int l0MatchCount, float l1TopScore) {
double l1Similarity = (l1TopScore != Float.MAX_VALUE) ? normalizeL2(l1TopScore) : 0.0;
// L0 唯一匹配 → PRECISE
if (l0MatchCount == 1) {
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
}
// L0 命中 + L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
}
// 仅 L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount == 0 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
}
// L0 多匹配 + L1 中分 → REFERENCE
if (l0MatchCount > 1 && l1Similarity >= referenceThreshold) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// 仅 L1 中分 → REFERENCE
if (l0MatchCount == 0 && l1Similarity >= referenceThreshold) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// L0 多匹配 + 无 L1 / L1 低分 → REFERENCE(L0 命中本身有价值)
if (l0MatchCount > 1) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// 无有效结果
return new RelevanceAssessment(null, null);
}
/**
* 归一化评估结果
*/
record RelevanceAssessment(String level, String hint) {}
// ==================== 域提取 ====================
/**
* 从检索结果中提取域信息
* 优先使用 L0 的 category,兜底从 L1 metadata 解析
*/
private String extractDomain(List<KnowledgeEntry> l0Matches, List<VectorSearchService.SearchResult> l1Results) {
// 优先 L0
if (l0Matches != null && !l0Matches.isEmpty()) {
String category = l0Matches.get(0).getCategory();
if (category != null && !category.isBlank()) {
return category;
}
}
// 兜底 L1:从 metadata JSON 中解析 category
if (l1Results != null && !l1Results.isEmpty()) {
try {
String metadata = l1Results.get(0).getMetadata();
if (metadata != null && metadata.contains("category")) {
var node = objectMapper.readTree(metadata);
if (node.has("category")) {
return node.get("category").asText();
}
}
} catch (Exception e) {
log.debug("L1 metadata 解析 category 失败: {}", e.getMessage());
}
}
return null;
}
// ==================== 入库 ====================
/**
* 保存工具调用明细到 tool_invocation 表
*/
private void saveToolInvocation(String query, List<KnowledgeEntry> l0Matches,
List<VectorSearchService.SearchResult> l1Results,
boolean highConfidence, long startTime, LookupResult result) {
boolean highConfidence, long startTime,
LookupResult result, String domain, String dedupReason) {
try {
String sessionId = SessionContextHolder.getSessionId();
if (sessionId == null) return; // 非会话上下文不记录
if (sessionId == null) return;
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
@@ -201,7 +334,7 @@ public class LookupKnowledgeTool {
layer = null;
}
// 拼接 output_preview(前500字符)
// output_preview
if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
String content = result.getPrimary().getContent();
outputLength = content.length();
@@ -222,24 +355,48 @@ public class LookupKnowledgeTool {
}
}
// 构建检索明细 JSON
// L1 top score + similarity
float l1TopScore = (hasL1) ? l1Results.get(0).getScore() : -1;
double l1TopSimilarity = (hasL1) ? normalizeL2(l1TopScore) : -1;
// 构建检索明细 JSON(扩展版)
StringBuilder details = new StringBuilder("{");
if (hasL0) {
details.append("\"l0_match_count\":").append(l0Count).append(",");
details.append("\"l0_titles\":[");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
if (i > 0) details.append(",");
details.append("\"").append(escapeJson(l0Matches.get(i).getTitle())).append("\"");
}
details.append("]");
details.append("],");
}
if (hasL1) {
if (hasL0) details.append(",");
details.append("\"l1_top_score\":").append(String.format("%.4f", l1TopScore)).append(",");
details.append("\"l1_top_similarity\":").append(String.format("%.4f", l1TopSimilarity)).append(",");
details.append("\"l1_match_count\":").append(l1Count).append(",");
details.append("\"l1_scores\":[");
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
if (i > 0) details.append(",");
details.append(l1Results.get(i).getScore());
details.append(String.format("%.4f", l1Results.get(i).getScore()));
}
details.append("]");
details.append("],");
}
// 归一化信息
if (result != null && result.getRelevanceLevel() != null) {
details.append("\"relevance_level\":\"").append(result.getRelevanceLevel()).append("\",");
details.append("\"completeness_hint\":\"").append(escapeJson(result.getCompletenessHint())).append("\",");
}
// 域信息
if (domain != null) {
details.append("\"retrieved_domains\":[\"").append(escapeJson(domain)).append("\"],");
}
// 去重原因
if (dedupReason != null) {
details.append("\"dedup_reason\":\"").append(dedupReason).append("\",");
}
// 移除末尾逗号
if (details.charAt(details.length() - 1) == ',') {
details.setLength(details.length() - 1);
}
details.append("}");
@@ -254,12 +411,15 @@ public class LookupKnowledgeTool {
.l1MatchCount(hasL1 ? l1Count : null)
.isTruncated(truncated)
.retrievalDetails(details.toString())
.relevanceLevel(result != null ? result.getRelevanceLevel() : null)
.dedupReason(dedupReason)
.durationMs((int) duration)
.success(true)
.build();
toolInvocationRepository.save(inv);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, duration={}ms", sessionId, layer, duration);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, relevanceLevel={}, duration={}ms",
sessionId, layer, result != null ? result.getRelevanceLevel() : null, duration);
} catch (Exception e) {
log.error("保存 tool_invocation 失败", e);
}
@@ -274,14 +434,8 @@ public class LookupKnowledgeTool {
.replace("\t", "\\t");
}
/**
* 组装查询结果
*
* @param l0Matches L0 匹配结果
* @param l1Results L1 检索结果
* @param highConfidence 是否高置信度
* @return 组装后的结果
*/
// ==================== 结果组装 ====================
private LookupResult buildResult(
List<KnowledgeEntry> l0Matches,
List<VectorSearchService.SearchResult> l1Results,
@@ -294,8 +448,6 @@ public class LookupKnowledgeTool {
if (l0Matches != null && !l0Matches.isEmpty()) {
KnowledgeEntry first = l0Matches.get(0);
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
// 场景决策:唯一匹配或 L1 无结果 → LLM 需要正文内容;多匹配且有 L1 → 只需元数据
boolean needFullContent = highConfidence || !hasL1;
String content = needFullContent
? buildCompactSummary(first)
@@ -307,7 +459,7 @@ public class LookupKnowledgeTool {
.source(first.getFilePath())
.matchType("exact_L0")
.confidence(highConfidence ? "high" : "low")
.availableSections(null) // MVP 返回 null
.availableSections(null)
.build();
log.debug("L0结果已构建: source={}, contentLength={}", first.getFilePath(), content.length());
} else {
@@ -330,16 +482,12 @@ public class LookupKnowledgeTool {
}
builder.supplement(supplement);
// 判断是否找到结果(primary 或 supplement 至少有一个)
boolean found = (primary != null) || (supplement != null);
builder.found(found);
return builder.build();
}
/**
* 统计 MD 文档中的章节数(二级标题 ## 数量)
*/
private int countMdHeadings(String content) {
if (content == null) return 0;
return (int) content.lines()
@@ -347,15 +495,10 @@ public class LookupKnowledgeTool {
.count();
}
/**
* 构建紧凑文档摘要(替代原始 MD 全文,节省上下文窗口)
* 组合:title/summary + 章节结构 + 正文片段(~500 字符)
*/
private String buildCompactSummary(KnowledgeEntry entry) {
String rawContent = knowledgeIndexService.readDocument(entry.getFilePath(), 2000);
if (rawContent == null) return null;
// 跳过 YAML frontmatter 得到正文
String body = rawContent;
if (body.startsWith("---")) {
int end = body.indexOf("---", 3);
@@ -365,14 +508,11 @@ public class LookupKnowledgeTool {
}
StringBuilder sb = new StringBuilder();
// 1. 元数据头(始终包含)
sb.append("文档: ").append(entry.getTitle()).append("\n");
if (entry.getSummary() != null) {
sb.append("摘要: ").append(entry.getSummary()).append("\n");
}
// 2. 章节结构(## 标题列表)
String headings = body.lines()
.filter(l -> l.trim().startsWith("##"))
.map(l -> " - " + l.trim().replaceAll("^#+\\s*", ""))
@@ -382,13 +522,11 @@ public class LookupKnowledgeTool {
}
sb.append("---\n");
// 3. 正文片段(去标题行、去空行,智能截断)
String textContent = body.lines()
.filter(l -> !l.trim().startsWith("#") && !l.trim().isEmpty())
.collect(Collectors.joining("\n"))
.trim();
// 短文档保留更多内容,长文档节省上下文
int maxBodyChars = body.length() < 500 ? 800 : 500;
if (textContent.length() > maxBodyChars) {
sb.append(textContent, 0, maxBodyChars).append("...");
@@ -399,10 +537,6 @@ public class LookupKnowledgeTool {
return sb.toString();
}
/**
* 构建纯元数据摘要(不读文件,仅用内存索引信息)
* 多匹配且有 L1 补充时使用,L0 只需告知 LLM 命中了哪些文档
*/
private String buildMetadataOnlySummary(KnowledgeEntry entry) {
StringBuilder sb = new StringBuilder();
sb.append("文档: ").append(entry.getTitle()).append("\n");
@@ -416,14 +550,10 @@ public class LookupKnowledgeTool {
return sb.toString();
}
/**
* 提取 MD 内容中第一个有意义的文本行(跳过 frontmatter 和标题行)
*/
private String extractFirstMeaningfulLine(String content, int maxLen) {
if (content == null || content.isBlank()) return "(空)";
String text = content.trim();
// 跳过 YAML frontmatter (--- ... ---)
if (text.startsWith("---")) {
int end = text.indexOf("---", 3);
if (end != -1) {
@@ -431,7 +561,6 @@ public class LookupKnowledgeTool {
}
}
// 查找第一个非空、非标题行
String[] lines = text.split("\n");
for (String line : lines) {
String tl = line.trim();
@@ -440,7 +569,6 @@ public class LookupKnowledgeTool {
}
}
// 兜底:第一行非空行
for (String line : lines) {
if (!line.trim().isEmpty()) {
String tl = line.trim();
@@ -3,34 +3,87 @@ package com.superbiz.agent.tool;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* session 级已召回文档追踪器
* 防止同一 session 内重复召回相同文档
* 支持文档级去重 + 域级行动记忆
*
* 数据结构:sessionId → { domain → Set<filePath> }
* - 域级:控制"不要重复查同域",提供行动记忆给 LLM
* - 文档级:控制"不要重复召回同文档"(替代原有单层结构)
*/
@Component
public class RetrievedDocTracker {
// key: sessionId, value: 已召回文档的 filePath 集合
private final ConcurrentHashMap<String, Set<String>> retrieved = new ConcurrentHashMap<>();
// key: sessionId, value: { domain → Set<filePath> }
private final ConcurrentHashMap<String, Map<String, Set<String>>> sessionRetrievals = new ConcurrentHashMap<>();
public boolean isAlreadyRetrieved(String sessionId, String filePath) {
if (sessionId == null || filePath == null) return false;
Set<String> docs = retrieved.get(sessionId);
return docs != null && docs.contains(filePath);
}
public void markRetrieved(String sessionId, String filePath) {
/**
* 记录一次检索(域级 + 文档级)
*/
public void markRetrieved(String sessionId, String domain, String filePath) {
if (sessionId == null || filePath == null) return;
retrieved.computeIfAbsent(sessionId,
k -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
sessionRetrievals.computeIfAbsent(sessionId,
k -> new ConcurrentHashMap<>())
.computeIfAbsent(domain != null ? domain : "_unknown",
d -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
.add(filePath);
}
/**
* 文档级去重:检查 filePath 是否已在本会话中检索过
*/
public boolean isDocRetrieved(String sessionId, String filePath) {
if (sessionId == null || filePath == null) return false;
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
if (domains == null) return false;
return domains.values().stream().anyMatch(docs -> docs.contains(filePath));
}
/**
* 域级检查:检查 domain 是否已在本会话中检索过
*/
public boolean isDomainRetrieved(String sessionId, String domain) {
if (sessionId == null || domain == null) return false;
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
return domains != null && domains.containsKey(domain);
}
/**
* 获取本次会话已检索的域列表(行动记忆,返回给 LLM)
*/
public List<String> getRetrievedDomains(String sessionId) {
if (sessionId == null) return List.of();
Map<String, Set<String>> domains = sessionRetrievals.get(sessionId);
if (domains == null) return List.of();
return List.copyOf(domains.keySet());
}
/**
* 向后兼容:文档级去重(委托给 isDocRetrieved)
*/
public boolean isAlreadyRetrieved(String sessionId, String filePath) {
return isDocRetrieved(sessionId, filePath);
}
/**
* 向后兼容:旧版 markRetrieved(domain 设为 null,归入 _unknown)
*/
public void markRetrieved(String sessionId, String filePath) {
markRetrieved(sessionId, null, filePath);
}
/**
* 清理会话
*/
public void clearSession(String sessionId) {
if (sessionId == null) return;
retrieved.remove(sessionId);
sessionRetrievals.remove(sessionId);
}
}
+7
View File
@@ -119,6 +119,13 @@ document:
rag:
top-k: 3 # 检索返回的最相似文档数量
# 检索归一化配置
retrieval:
normalization:
max-l2-distance: 2.0 # L2 距离上界(BGE-M3 单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity >= 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity >= 0.5 → REFERENCE
# Prometheus 配置
prometheus:
base-url: http://localhost:9090
@@ -0,0 +1,6 @@
-- V010: 新增 relevance_level 和 dedup_reason 列到 tool_invocation 表
-- 用于检索归一化等级和去重原因的可观测性
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';
@@ -2,11 +2,38 @@
## 职责
- 按步骤执行具体的查询任务
- 使用知识库查询、日志查询等工具获取信息
- 将执行结果汇总,给出完整的最终答案
- 需要外部信息时调用工具,但须遵守下方的检索约束
- 不要凭记忆回答,必须基于工具返回的真实数据
- 执行完成后,综合所有结果给出完整的答案
## 规则
- 按顺序执行,不可跳过步骤
- 所有需要外部信息的地方,都必须调用对应的工具
- 不要凭记忆回答,必须基于工具返回的真实数据
- 执行完成后,综合所有结果给出完整的答案
## 检索约束
### 1. 判断重复:基于已检索上下文
每次 lookup_knowledge 返回值中包含 `retrievedDomainsThisSession`,
表示本次会话已检索过的知识域。如果当前问题与已检索域语义重叠,
**禁止再次调用 lookup_knowledge**。
### 2. 重复了该怎么办
如果当前想检索的内容与【已检索上下文】语义相似:
- 禁止换关键词重新检索
- 直接基于已有事实回答
- 如果信息不足,先明确指出缺少什么具体维度
(如:"缺少 HikariCP 具体配置参数"、"缺少连接池耗尽的日志样例"),
再针对该维度进行一次定向补充检索——而非盲目换词重查
### 3. 合法出口:允许信息不全时给出结论
如果你认为已有信息足以回答核心问题,即使细节不全,
也请直接给出结论并说明局限性(如:"基于已有信息,连接池配置建议如下,
但具体参数值需结合实际负载调整")。
**不查全不会被追责,重复检索才会被惩罚。**
### 4. 利用质量信号判断
- relevanceLevel=PRECISE → 信息精准,直接使用,不再检索
- relevanceLevel=HIGHLY_RELEVANT + 域已在 retrievedDomainsThisSession → 禁止再次调用
- relevanceLevel=REFERENCE → 先指出缺什么维度,再定向补充一次
- completenessHint 是知识库给你的天花板信号,信任它