feat: treat l0 retrieval as domain hint
This commit is contained in:
@@ -19,9 +19,11 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 知识库索引服务
|
||||
@@ -123,39 +125,73 @@ public class KnowledgeIndexService {
|
||||
}
|
||||
|
||||
public List<KnowledgeEntry> exactMatch(String query) {
|
||||
return analyzeQuery(query).matches();
|
||||
}
|
||||
|
||||
public L0Hint analyzeQuery(String query) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
if (query == null || query.trim().isEmpty()) {
|
||||
log.debug("查询关键词为空,返回空结果");
|
||||
return List.of();
|
||||
return L0Hint.empty();
|
||||
}
|
||||
|
||||
String queryLower = query.toLowerCase();
|
||||
List<KnowledgeEntry> results = new ArrayList<>();
|
||||
Set<String> matchedKeywords = new LinkedHashSet<>();
|
||||
Set<String> domains = new LinkedHashSet<>();
|
||||
Set<String> entities = new LinkedHashSet<>();
|
||||
Set<String> titles = new LinkedHashSet<>();
|
||||
|
||||
List<KnowledgeEntry> results = knowledgeIndex.stream()
|
||||
.filter(entry -> matchesKeywords(entry, queryLower))
|
||||
.collect(Collectors.toList());
|
||||
for (KnowledgeEntry entry : knowledgeIndex) {
|
||||
List<String> entryMatchedKeywords = matchedKeywords(entry, queryLower);
|
||||
if (entryMatchedKeywords.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
||||
log.debug("L0精确匹配: query={}, matches={}, indexSize={}, time={}ms",
|
||||
query, results.size(), knowledgeIndex.size(), elapsedTime);
|
||||
results.add(entry);
|
||||
matchedKeywords.addAll(entryMatchedKeywords);
|
||||
entities.addAll(entryMatchedKeywords);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private boolean matchesKeywords(KnowledgeEntry entry, String query) {
|
||||
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (String keyword : entry.getKeywords()) {
|
||||
String keywordLower = keyword.toLowerCase();
|
||||
if (query.contains(keywordLower) || keywordLower.contains(query)) {
|
||||
return true;
|
||||
if (entry.getCategory() != null && !entry.getCategory().isBlank()) {
|
||||
domains.add(entry.getCategory());
|
||||
}
|
||||
if (entry.getTitle() != null && !entry.getTitle().isBlank()) {
|
||||
titles.add(entry.getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
||||
log.debug("L0 Hint分析: query={}, matches={}, domains={}, keywords={}, indexSize={}, time={}ms",
|
||||
query, results.size(), domains, matchedKeywords, knowledgeIndex.size(), elapsedTime);
|
||||
|
||||
return new L0Hint(
|
||||
List.copyOf(results),
|
||||
List.copyOf(matchedKeywords),
|
||||
List.copyOf(domains),
|
||||
List.copyOf(entities),
|
||||
List.copyOf(titles)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean matchesKeywords(KnowledgeEntry entry, String query) {
|
||||
return !matchedKeywords(entry, query).isEmpty();
|
||||
}
|
||||
|
||||
private List<String> matchedKeywords(KnowledgeEntry entry, String query) {
|
||||
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> matches = new ArrayList<>();
|
||||
for (String keyword : entry.getKeywords()) {
|
||||
String keywordLower = keyword.toLowerCase();
|
||||
if (query.contains(keywordLower) || keywordLower.contains(query)) {
|
||||
matches.add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
public String readDocument(String filePath, int maxChars) {
|
||||
@@ -224,4 +260,20 @@ public class KnowledgeIndexService {
|
||||
public List<KnowledgeEntry> getAllEntries() {
|
||||
return List.copyOf(knowledgeIndex);
|
||||
}
|
||||
|
||||
public record L0Hint(
|
||||
List<KnowledgeEntry> matches,
|
||||
List<String> matchedKeywords,
|
||||
List<String> domains,
|
||||
List<String> entities,
|
||||
List<String> titles
|
||||
) {
|
||||
public static L0Hint empty() {
|
||||
return new L0Hint(List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
public String singleDomainOrNull() {
|
||||
return domains.size() == 1 ? domains.get(0) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.superbiz.agent.util.SessionContextHolder;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -108,6 +107,15 @@ public class ToolInvocationRecorder {
|
||||
if (record.l0Titles() != null && !record.l0Titles().isEmpty()) {
|
||||
details.put("l0_titles", record.l0Titles());
|
||||
}
|
||||
if (record.l0MatchedKeywords() != null && !record.l0MatchedKeywords().isEmpty()) {
|
||||
details.put("l0_matched_keywords", record.l0MatchedKeywords());
|
||||
}
|
||||
if (record.l0Domains() != null && !record.l0Domains().isEmpty()) {
|
||||
details.put("l0_domains", record.l0Domains());
|
||||
}
|
||||
if (record.l0Entities() != null && !record.l0Entities().isEmpty()) {
|
||||
details.put("l0_entities", record.l0Entities());
|
||||
}
|
||||
if (record.l1TopScore() != null) {
|
||||
details.put("l1_top_score", record.l1TopScore());
|
||||
}
|
||||
@@ -196,12 +204,15 @@ public class ToolInvocationRecorder {
|
||||
String evidenceStatus,
|
||||
String errorMessage,
|
||||
List<String> l0Titles,
|
||||
List<String> l0MatchedKeywords,
|
||||
List<String> l0Domains,
|
||||
List<String> l0Entities,
|
||||
Double l1TopScore,
|
||||
Double l1TopSimilarity,
|
||||
List<Double> l1Scores
|
||||
) {
|
||||
public static LookupKnowledgeRecord from(String query,
|
||||
List<KnowledgeEntry> l0Matches,
|
||||
KnowledgeIndexService.L0Hint l0Hint,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence,
|
||||
LookupResult result,
|
||||
@@ -209,6 +220,7 @@ public class ToolInvocationRecorder {
|
||||
String dedupReason,
|
||||
int durationMs,
|
||||
double l1TopSimilarity) {
|
||||
List<KnowledgeEntry> l0Matches = l0Hint != null ? l0Hint.matches() : List.of();
|
||||
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
|
||||
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
|
||||
String layer;
|
||||
@@ -272,6 +284,9 @@ public class ToolInvocationRecorder {
|
||||
.success(true)
|
||||
.evidenceStatus(evidenceStatus)
|
||||
.l0Titles(l0Titles)
|
||||
.l0MatchedKeywords(l0Hint != null ? l0Hint.matchedKeywords() : List.of())
|
||||
.l0Domains(l0Hint != null ? l0Hint.domains() : List.of())
|
||||
.l0Entities(l0Hint != null ? l0Hint.entities() : List.of())
|
||||
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
|
||||
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
|
||||
.l1Scores(l1Scores)
|
||||
|
||||
@@ -35,13 +35,13 @@ public class LookupKnowledgeTool {
|
||||
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
|
||||
|
||||
@Value("${retrieval.normalization.max-l2-distance:2.0}")
|
||||
private double maxL2Distance;
|
||||
private double maxL2Distance = 2.0;
|
||||
|
||||
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
|
||||
private double highlyRelevantThreshold;
|
||||
private double highlyRelevantThreshold = 0.75;
|
||||
|
||||
@Value("${retrieval.normalization.reference-threshold:0.5}")
|
||||
private double referenceThreshold;
|
||||
private double referenceThreshold = 0.5;
|
||||
|
||||
@Autowired
|
||||
private KnowledgeIndexService knowledgeIndexService;
|
||||
@@ -83,30 +83,28 @@ public class LookupKnowledgeTool {
|
||||
log.info(">>> RequestId: {}", requestId);
|
||||
log.info("----------------------------------------");
|
||||
|
||||
// Step 1: L0 精确匹配
|
||||
// Step 1: L0 hint 分析
|
||||
long l0Start = System.currentTimeMillis();
|
||||
List<KnowledgeEntry> l0Matches = knowledgeIndexService.exactMatch(query);
|
||||
KnowledgeIndexService.L0Hint l0Hint = knowledgeIndexService.analyzeQuery(query);
|
||||
List<KnowledgeEntry> l0Matches = l0Hint.matches();
|
||||
long l0Time = System.currentTimeMillis() - l0Start;
|
||||
log.info("[L0 精确匹配] 完成: matches={}, time={}ms", l0Matches.size(), l0Time);
|
||||
log.info("[L0 Hint] 完成: matches={}, domains={}, keywords={}, time={}ms",
|
||||
l0Matches.size(), l0Hint.domains(), l0Hint.matchedKeywords(), l0Time);
|
||||
if (!l0Matches.isEmpty()) {
|
||||
log.info("[L0 精确匹配] 找到文档:");
|
||||
log.info("[L0 Hint] 找到文档:");
|
||||
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
|
||||
KnowledgeEntry entry = l0Matches.get(i);
|
||||
log.info(" - [{}] 标题: {}, 路径: {}, 域: {}", i+1, entry.getTitle(), entry.getFilePath(), entry.getCategory());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: 判断是否高置信度(唯一匹配)
|
||||
boolean highConfidence = (l0Matches.size() == 1);
|
||||
log.info("[置信度判断] highConfidence={}, reason={}",
|
||||
highConfidence, highConfidence ? "唯一匹配" : "多个或零个匹配");
|
||||
|
||||
// Step 3: L1 条件调用
|
||||
List<VectorSearchService.SearchResult> l1Results = null;
|
||||
if (!highConfidence) {
|
||||
log.info("[L1 语义检索] L0非唯一匹配,触发L1语义检索...");
|
||||
// Step 2: L1 默认调用;L0 只提供可解释 hint 和可选 category filter
|
||||
List<VectorSearchService.SearchResult> l1Results = List.of();
|
||||
String l0CategoryFilter = l0Hint.singleDomainOrNull();
|
||||
try {
|
||||
log.info("[L1 语义检索] 触发L1语义检索, categoryFilter={}", l0CategoryFilter);
|
||||
long l1Start = System.currentTimeMillis();
|
||||
l1Results = vectorSearchService.searchSimilarDocuments(query, 3, null);
|
||||
l1Results = vectorSearchService.searchSimilarDocuments(query, 3, l0CategoryFilter);
|
||||
long l1Time = System.currentTimeMillis() - l1Start;
|
||||
log.info("[L1 语义检索] 完成: matches={}, time={}ms",
|
||||
l1Results != null ? l1Results.size() : 0, l1Time);
|
||||
@@ -117,25 +115,27 @@ public class LookupKnowledgeTool {
|
||||
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
|
||||
} catch (Exception e) {
|
||||
log.warn("[L1 语义检索] 调用失败,保留L0 fallback: {}", e.getMessage());
|
||||
l1Results = List.of();
|
||||
}
|
||||
|
||||
// Step 4: 归一化质量等级判定
|
||||
// Step 3: 归一化质量等级判定
|
||||
float l1TopScore = (l1Results != null && !l1Results.isEmpty()) ? l1Results.get(0).getScore() : Float.MAX_VALUE;
|
||||
RelevanceAssessment assessment = computeRelevance(l0Matches.size(), l1TopScore);
|
||||
boolean highConfidence = isHighConfidence(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: 组装结果
|
||||
// Step 4: 组装结果
|
||||
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
|
||||
result.setRelevanceLevel(assessment.level);
|
||||
result.setCompletenessHint(assessment.hint);
|
||||
|
||||
// Step 6: session 级去重过滤 + 域级行动记忆
|
||||
// Step 5: session 级去重过滤 + 域级行动记忆
|
||||
String sessionId = SessionContextHolder.getSessionId();
|
||||
String domain = extractDomain(l0Matches, l1Results);
|
||||
|
||||
@@ -144,7 +144,7 @@ public class LookupKnowledgeTool {
|
||||
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
|
||||
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
|
||||
List<String> retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
|
||||
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
|
||||
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
|
||||
return LookupResult.builder()
|
||||
.found(false)
|
||||
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
|
||||
@@ -197,7 +197,7 @@ public class LookupKnowledgeTool {
|
||||
log.info("========================================");
|
||||
|
||||
// 记录 tool_invocation
|
||||
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, null);
|
||||
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, null);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -225,11 +225,16 @@ public class LookupKnowledgeTool {
|
||||
RelevanceAssessment computeRelevance(int l0MatchCount, float l1TopScore) {
|
||||
double l1Similarity = (l1TopScore != Float.MAX_VALUE) ? normalizeL2(l1TopScore) : 0.0;
|
||||
|
||||
// L0 唯一匹配 → PRECISE
|
||||
if (l0MatchCount == 1) {
|
||||
// L0 唯一匹配 + L1 高分 → PRECISE
|
||||
if (l0MatchCount == 1 && l1Similarity >= highlyRelevantThreshold) {
|
||||
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
|
||||
}
|
||||
|
||||
// L0 唯一匹配但缺少 L1 支持 → REFERENCE
|
||||
if (l0MatchCount == 1) {
|
||||
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
|
||||
}
|
||||
|
||||
// L0 命中 + L1 高分 → HIGHLY_RELEVANT
|
||||
if (l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold) {
|
||||
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
|
||||
@@ -259,6 +264,16 @@ public class LookupKnowledgeTool {
|
||||
return new RelevanceAssessment(null, null);
|
||||
}
|
||||
|
||||
boolean isHighConfidence(int l0MatchCount, float l1TopScore) {
|
||||
if (l0MatchCount != 1) {
|
||||
return false;
|
||||
}
|
||||
if (l1TopScore == Float.MAX_VALUE) {
|
||||
return true;
|
||||
}
|
||||
return normalizeL2(l1TopScore) >= highlyRelevantThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化评估结果
|
||||
*/
|
||||
@@ -302,7 +317,7 @@ public class LookupKnowledgeTool {
|
||||
/**
|
||||
* 保存工具调用明细到 tool_invocation 表
|
||||
*/
|
||||
private void saveToolInvocation(String query, List<KnowledgeEntry> l0Matches,
|
||||
private void saveToolInvocation(String query, KnowledgeIndexService.L0Hint l0Hint,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence, long startTime,
|
||||
LookupResult result, String domain, String dedupReason) {
|
||||
@@ -317,7 +332,7 @@ public class LookupKnowledgeTool {
|
||||
|
||||
ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
|
||||
query,
|
||||
l0Matches,
|
||||
l0Hint,
|
||||
l1Results,
|
||||
highConfidence,
|
||||
result,
|
||||
|
||||
Reference in New Issue
Block a user