feat: treat l0 retrieval as domain hint

This commit is contained in:
aruo
2026-07-05 02:18:40 +08:00
parent 9a2a44d1b5
commit 4a94c14feb
12 changed files with 494 additions and 74 deletions
@@ -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,