feat(rag): modularize knowledge retrieval pipeline
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package com.superbiz.agent.domain.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -20,6 +22,7 @@ import java.util.Map;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SessionContext implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
@@ -118,6 +121,7 @@ public class SessionContext implements Serializable {
|
||||
/**
|
||||
* 获取聊天历史副本,避免调用方直接修改内部列表。
|
||||
*/
|
||||
@JsonIgnore
|
||||
public List<Map<String, String>> getMessageHistorySnapshot() {
|
||||
if (this.messageHistory == null || this.messageHistory.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
@@ -141,6 +145,7 @@ public class SessionContext implements Serializable {
|
||||
this.lastActiveAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public int getMessagePairCount() {
|
||||
return this.messageHistory == null ? 0 : this.messageHistory.size() / 2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent-facing packed context assembled from final evidence blocks.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class ContextPack {
|
||||
|
||||
private String packedText;
|
||||
|
||||
private String strategy;
|
||||
|
||||
private Integer charBudget;
|
||||
|
||||
private Integer usedChars;
|
||||
|
||||
private List<String> includedSources;
|
||||
|
||||
private List<String> omittedSources;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Output of post-retrieval processing before context packing.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class EvidencePostprocessResult {
|
||||
|
||||
private Integer candidateCount;
|
||||
|
||||
private Integer evidenceBlockCount;
|
||||
|
||||
private List<EvidenceBlock> evidenceBlocks;
|
||||
|
||||
private String relevanceLevel;
|
||||
|
||||
private String completenessHint;
|
||||
|
||||
private RerankTrace rerankTrace;
|
||||
|
||||
private Double topSimilarity;
|
||||
|
||||
public boolean hasUsableEvidence() {
|
||||
return evidenceBlocks != null && !evidenceBlocks.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Query understanding output used by the knowledge retrieval pipeline.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class KnowledgeQuery {
|
||||
|
||||
private String originalQuery;
|
||||
|
||||
private String rewrittenQuery;
|
||||
|
||||
private List<String> domainHints;
|
||||
|
||||
private List<String> matchedKeywords;
|
||||
|
||||
private List<String> entities;
|
||||
|
||||
private String categoryFilter;
|
||||
|
||||
private List<String> l0Titles;
|
||||
|
||||
private Integer l0MatchCount;
|
||||
}
|
||||
@@ -17,21 +17,26 @@ public class LookupResult {
|
||||
*/
|
||||
private boolean found;
|
||||
|
||||
/**
|
||||
* 主要结果(L0 精确匹配)
|
||||
*/
|
||||
private PrimaryResult primary;
|
||||
|
||||
/**
|
||||
* 补充结果(L1 语义检索)
|
||||
*/
|
||||
private SupplementResult supplement;
|
||||
|
||||
/**
|
||||
* Structured evidence blocks after retrieval post-processing.
|
||||
*/
|
||||
private List<EvidenceBlock> evidenceBlocks;
|
||||
|
||||
/**
|
||||
* Packed Agent-facing context assembled from evidence blocks.
|
||||
*/
|
||||
private ContextPack contextPack;
|
||||
|
||||
/**
|
||||
* Retrieval attempts and fallback trace.
|
||||
*/
|
||||
private RetrievalTrace retrievalTrace;
|
||||
|
||||
/**
|
||||
* Rule-based rerank explanation.
|
||||
*/
|
||||
private RerankTrace rerankTrace;
|
||||
|
||||
/**
|
||||
* Candidate count before evidence deduplication.
|
||||
*/
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* L0 精确匹配结果
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PrimaryResult {
|
||||
|
||||
/**
|
||||
* 文档内容(前 2000 字符)
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 文档来源路径
|
||||
*/
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 匹配类型(exact_L0)
|
||||
*/
|
||||
private String matchType;
|
||||
|
||||
/**
|
||||
* 置信度(high / low)
|
||||
*/
|
||||
private String confidence;
|
||||
|
||||
/**
|
||||
* 可用的章节列表(预留字段,MVP 返回 null)
|
||||
*/
|
||||
private List<String> availableSections;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Rule-based rerank explanation for final evidence blocks.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RerankTrace {
|
||||
|
||||
private List<Item> items;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class Item {
|
||||
private Integer finalRank;
|
||||
private String source;
|
||||
private Double baseScore;
|
||||
private Double finalScore;
|
||||
private List<String> boostReasons;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Trace of retrieval attempts used by lookup_knowledge.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RetrievalTrace {
|
||||
|
||||
private String originalQuery;
|
||||
|
||||
private String rewrittenQuery;
|
||||
|
||||
private String categoryFilter;
|
||||
|
||||
private String selectedAttempt;
|
||||
|
||||
private String fallbackReason;
|
||||
|
||||
private String evidenceStatus;
|
||||
|
||||
private Map<String, Object> queryHints;
|
||||
|
||||
private List<Attempt> attempts;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class Attempt {
|
||||
private String name;
|
||||
private String query;
|
||||
private String categoryFilter;
|
||||
private Integer candidateCount;
|
||||
private Boolean usable;
|
||||
private String errorMessage;
|
||||
private Integer durationMs;
|
||||
private Double topScore;
|
||||
private Double topSimilarity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Normalized vector retrieval candidate before evidence post-processing.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RetrievedEvidenceCandidate {
|
||||
|
||||
private String id;
|
||||
|
||||
private String source;
|
||||
|
||||
private String title;
|
||||
|
||||
private String breadcrumb;
|
||||
|
||||
private String content;
|
||||
|
||||
private String retrievalLayer;
|
||||
|
||||
private String retrievalAttempt;
|
||||
|
||||
private Double score;
|
||||
|
||||
private Double rawScore;
|
||||
|
||||
private String scoreLabel;
|
||||
|
||||
private Integer originalRank;
|
||||
|
||||
private Map<String, String> metadata;
|
||||
|
||||
private List<String> hitReasons;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.superbiz.agent.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* L1 语义检索补充结果
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class SupplementResult {
|
||||
|
||||
/**
|
||||
* 文档内容片段
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 文档来源
|
||||
*/
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 匹配类型(semantic_L1)
|
||||
*/
|
||||
private String matchType;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.ContextPack;
|
||||
import com.superbiz.agent.dto.EvidenceBlock;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Packs final evidence blocks into compact Agent-facing context.
|
||||
*/
|
||||
@Service
|
||||
public class KnowledgeContextPacker {
|
||||
|
||||
@Value("${rag.context-pack.char-budget:4000}")
|
||||
private int charBudget = 4000;
|
||||
|
||||
public ContextPack pack(List<EvidenceBlock> blocks) {
|
||||
List<EvidenceBlock> safeBlocks = blocks == null ? List.of() : blocks;
|
||||
StringBuilder packed = new StringBuilder();
|
||||
List<String> included = new ArrayList<>();
|
||||
List<String> omitted = new ArrayList<>();
|
||||
|
||||
int rank = 1;
|
||||
for (EvidenceBlock block : safeBlocks) {
|
||||
String header = buildHeader(rank, block);
|
||||
String content = block.getContent() == null ? "" : block.getContent();
|
||||
int remaining = charBudget - packed.length() - header.length();
|
||||
if (remaining <= 0) {
|
||||
omitted.add(block.getSource());
|
||||
continue;
|
||||
}
|
||||
String body = content.length() <= remaining ? content : content.substring(0, Math.max(0, remaining)) + "...";
|
||||
packed.append(header).append(body).append("\n\n");
|
||||
included.add(block.getSource());
|
||||
rank++;
|
||||
}
|
||||
|
||||
return ContextPack.builder()
|
||||
.packedText(packed.toString().trim())
|
||||
.strategy("ranked_evidence_char_budget")
|
||||
.charBudget(charBudget)
|
||||
.usedChars(packed.length())
|
||||
.includedSources(included)
|
||||
.omittedSources(omitted)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String buildHeader(int rank, EvidenceBlock block) {
|
||||
StringBuilder header = new StringBuilder();
|
||||
header.append("[Evidence ").append(rank).append("]\n");
|
||||
appendLine(header, "source", block.getSource());
|
||||
appendLine(header, "title", block.getTitle());
|
||||
appendLine(header, "breadcrumb", block.getBreadcrumb());
|
||||
appendLine(header, "layer", block.getRetrievalLayer());
|
||||
if (block.getHitReasons() != null && !block.getHitReasons().isEmpty()) {
|
||||
appendLine(header, "reasons", String.join(", ", block.getHitReasons()));
|
||||
}
|
||||
header.append("content:\n");
|
||||
return header.toString();
|
||||
}
|
||||
|
||||
private void appendLine(StringBuilder builder, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
builder.append(key).append(": ").append(value).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.dto.RetrievalTrace;
|
||||
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Vector retrieval adapter for the modular knowledge pipeline.
|
||||
*/
|
||||
@Service
|
||||
public class KnowledgeDocumentRetriever {
|
||||
|
||||
private final VectorSearchService vectorSearchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public KnowledgeDocumentRetriever(VectorSearchService vectorSearchService, ObjectMapper objectMapper) {
|
||||
this.vectorSearchService = vectorSearchService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public RetrievalAttemptResult retrieve(String attemptName, String query, String categoryFilter, int topK) {
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
List<VectorSearchService.SearchResult> results =
|
||||
vectorSearchService.searchSimilarDocuments(query, topK, categoryFilter);
|
||||
List<RetrievedEvidenceCandidate> candidates = toCandidates(attemptName, results);
|
||||
return new RetrievalAttemptResult(
|
||||
attempt(attemptName, query, categoryFilter, candidates.size(), null,
|
||||
(int) (System.currentTimeMillis() - start), topScore(results)),
|
||||
candidates
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return new RetrievalAttemptResult(
|
||||
attempt(attemptName, query, categoryFilter, 0, e.getMessage(),
|
||||
(int) (System.currentTimeMillis() - start), null),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName,
|
||||
List<VectorSearchService.SearchResult> results) {
|
||||
if (results == null || results.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<RetrievedEvidenceCandidate> candidates = new ArrayList<>();
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
VectorSearchService.SearchResult result = results.get(i);
|
||||
Map<String, String> metadata = parseMetadata(result.getMetadata());
|
||||
String source = firstNonBlank(
|
||||
metadata.get("_source"),
|
||||
metadata.get("source"),
|
||||
metadata.get("filePath"),
|
||||
metadata.get("docId"),
|
||||
result.getMetadata(),
|
||||
result.getId()
|
||||
);
|
||||
candidates.add(RetrievedEvidenceCandidate.builder()
|
||||
.id(result.getId())
|
||||
.source(source)
|
||||
.title(metadata.get("title"))
|
||||
.breadcrumb(metadata.get("breadcrumb"))
|
||||
.content(result.getContent())
|
||||
.retrievalLayer("L1")
|
||||
.retrievalAttempt(attemptName)
|
||||
.score((double) result.getScore())
|
||||
.rawScore(result.getRawScore())
|
||||
.scoreLabel(result.getScoreLabel())
|
||||
.originalRank(i + 1)
|
||||
.metadata(metadata)
|
||||
.hitReasons(List.of("semantic_rank:" + (i + 1), "attempt:" + attemptName))
|
||||
.build());
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private RetrievalTrace.Attempt attempt(String name,
|
||||
String query,
|
||||
String categoryFilter,
|
||||
int candidateCount,
|
||||
String errorMessage,
|
||||
int durationMs,
|
||||
Double topScore) {
|
||||
return RetrievalTrace.Attempt.builder()
|
||||
.name(name)
|
||||
.query(query)
|
||||
.categoryFilter(categoryFilter)
|
||||
.candidateCount(candidateCount)
|
||||
.usable(errorMessage == null && candidateCount > 0)
|
||||
.errorMessage(errorMessage)
|
||||
.durationMs(durationMs)
|
||||
.topScore(topScore)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Double topScore(List<VectorSearchService.SearchResult> results) {
|
||||
if (results == null || results.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return (double) results.get(0).getScore();
|
||||
}
|
||||
|
||||
private Map<String, String> parseMetadata(String metadata) {
|
||||
if (metadata == null || metadata.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
Map<?, ?> raw = objectMapper.readValue(metadata, Map.class);
|
||||
Map<String, String> parsed = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : raw.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
parsed.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception ignored) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public record RetrievalAttemptResult(RetrievalTrace.Attempt attempt,
|
||||
List<RetrievedEvidenceCandidate> candidates) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.EvidenceBlock;
|
||||
import com.superbiz.agent.dto.EvidencePostprocessResult;
|
||||
import com.superbiz.agent.dto.KnowledgeQuery;
|
||||
import com.superbiz.agent.dto.RerankTrace;
|
||||
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Post-retrieval evidence normalization, rerank, and evidence block assembly.
|
||||
*/
|
||||
@Service
|
||||
public class KnowledgeEvidencePostProcessor {
|
||||
|
||||
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 = 2.0;
|
||||
|
||||
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
|
||||
private double highlyRelevantThreshold = 0.75;
|
||||
|
||||
@Value("${retrieval.normalization.reference-threshold:0.5}")
|
||||
private double referenceThreshold = 0.5;
|
||||
|
||||
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
|
||||
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
|
||||
List<ScoredCandidate> ranked = safeCandidates.stream()
|
||||
.map(candidate -> score(query, candidate))
|
||||
.sorted(Comparator.comparingDouble(ScoredCandidate::finalScore).reversed())
|
||||
.toList();
|
||||
|
||||
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
||||
List<RerankTrace.Item> traceItems = new ArrayList<>();
|
||||
int finalRank = 1;
|
||||
for (ScoredCandidate scored : ranked) {
|
||||
RetrievedEvidenceCandidate candidate = scored.candidate();
|
||||
EvidenceBlock block = EvidenceBlock.builder()
|
||||
.source(candidate.getSource())
|
||||
.title(candidate.getTitle())
|
||||
.breadcrumb(candidate.getBreadcrumb())
|
||||
.retrievalLayer(candidate.getRetrievalLayer())
|
||||
.content(truncate(candidate.getContent(), 800))
|
||||
.score(candidate.getScore())
|
||||
.hitReasons(mergeReasons(candidate.getHitReasons(), scored.boostReasons()))
|
||||
.build();
|
||||
String key = sourceKey(block, "candidate-" + candidate.getOriginalRank());
|
||||
if (!deduped.containsKey(key)) {
|
||||
deduped.put(key, block);
|
||||
traceItems.add(RerankTrace.Item.builder()
|
||||
.finalRank(finalRank++)
|
||||
.source(candidate.getSource())
|
||||
.baseScore(scored.baseScore())
|
||||
.finalScore(scored.finalScore())
|
||||
.boostReasons(scored.boostReasons())
|
||||
.build());
|
||||
} else {
|
||||
mergeEvidence(deduped.get(key), block);
|
||||
}
|
||||
}
|
||||
|
||||
List<EvidenceBlock> blocks = new ArrayList<>(deduped.values());
|
||||
Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).baseScore();
|
||||
RelevanceAssessment assessment = computeRelevance(query, ranked);
|
||||
return EvidencePostprocessResult.builder()
|
||||
.candidateCount(safeCandidates.size())
|
||||
.evidenceBlockCount(blocks.size())
|
||||
.evidenceBlocks(blocks)
|
||||
.relevanceLevel(assessment.level())
|
||||
.completenessHint(assessment.hint())
|
||||
.rerankTrace(RerankTrace.builder().items(traceItems).build())
|
||||
.topSimilarity(topSimilarity)
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isLowQuality(EvidencePostprocessResult result) {
|
||||
if (result == null || !result.hasUsableEvidence()) {
|
||||
return true;
|
||||
}
|
||||
Double topSimilarity = result.getTopSimilarity();
|
||||
return topSimilarity == null || topSimilarity < referenceThreshold;
|
||||
}
|
||||
|
||||
public double normalizeL2(Double l2Score) {
|
||||
if (l2Score == null) {
|
||||
return 0.0;
|
||||
}
|
||||
double clamped = Math.min(l2Score, maxL2Distance);
|
||||
return Math.max(0.0, 1.0 - clamped / maxL2Distance);
|
||||
}
|
||||
|
||||
public double getReferenceThreshold() {
|
||||
return referenceThreshold;
|
||||
}
|
||||
|
||||
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate) {
|
||||
double baseScore = normalizeL2(candidate.getScore());
|
||||
double finalScore = baseScore;
|
||||
List<String> boosts = new ArrayList<>();
|
||||
|
||||
if (matchesAny(candidate, query.getDomainHints())) {
|
||||
finalScore += 0.15;
|
||||
boosts.add("domain_match:+0.15");
|
||||
}
|
||||
if (matchesAny(candidate, query.getEntities())) {
|
||||
finalScore += 0.20;
|
||||
boosts.add("entity_match:+0.20");
|
||||
}
|
||||
if (matchesAny(candidate, query.getMatchedKeywords())) {
|
||||
finalScore += 0.10;
|
||||
boosts.add("keyword_match:+0.10");
|
||||
}
|
||||
if (isPreferredSourceType(candidate)) {
|
||||
finalScore += 0.05;
|
||||
boosts.add("source_type:+0.05");
|
||||
}
|
||||
|
||||
return new ScoredCandidate(candidate, baseScore, finalScore, boosts);
|
||||
}
|
||||
|
||||
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
|
||||
if (hints == null || hints.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String haystack = String.join(" ",
|
||||
nullToEmpty(candidate.getSource()),
|
||||
nullToEmpty(candidate.getTitle()),
|
||||
nullToEmpty(candidate.getBreadcrumb()),
|
||||
nullToEmpty(candidate.getContent()),
|
||||
candidate.getMetadata() == null ? "" : candidate.getMetadata().toString()
|
||||
).toLowerCase(Locale.ROOT);
|
||||
for (String hint : hints) {
|
||||
if (hint != null && !hint.isBlank() && haystack.contains(hint.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isPreferredSourceType(RetrievedEvidenceCandidate candidate) {
|
||||
Map<String, String> metadata = candidate.getMetadata();
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String type = firstNonBlank(metadata.get("source_type"), metadata.get("documentType"), metadata.get("type"));
|
||||
if (type == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = type.toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("runbook") || normalized.contains("guide") || normalized.contains("case");
|
||||
}
|
||||
|
||||
private RelevanceAssessment computeRelevance(KnowledgeQuery query, List<ScoredCandidate> ranked) {
|
||||
if (ranked.isEmpty()) {
|
||||
return new RelevanceAssessment(null, null);
|
||||
}
|
||||
ScoredCandidate top = ranked.get(0);
|
||||
if (top.baseScore() >= highlyRelevantThreshold && hasHintSupport(query, top)) {
|
||||
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
|
||||
}
|
||||
if (top.baseScore() >= highlyRelevantThreshold) {
|
||||
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
|
||||
}
|
||||
if (top.baseScore() >= referenceThreshold) {
|
||||
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
|
||||
}
|
||||
return new RelevanceAssessment(null, null);
|
||||
}
|
||||
|
||||
private boolean hasHintSupport(KnowledgeQuery query, ScoredCandidate top) {
|
||||
return matchesAny(top.candidate(), query.getDomainHints())
|
||||
|| matchesAny(top.candidate(), query.getEntities())
|
||||
|| matchesAny(top.candidate(), query.getMatchedKeywords());
|
||||
}
|
||||
|
||||
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
|
||||
Set<String> reasons = new LinkedHashSet<>();
|
||||
if (existing.getHitReasons() != null) {
|
||||
reasons.addAll(existing.getHitReasons());
|
||||
}
|
||||
if (incoming.getHitReasons() != null) {
|
||||
reasons.addAll(incoming.getHitReasons());
|
||||
}
|
||||
existing.setHitReasons(new ArrayList<>(reasons));
|
||||
if ((existing.getBreadcrumb() == null || existing.getBreadcrumb().isBlank())
|
||||
&& incoming.getBreadcrumb() != null) {
|
||||
existing.setBreadcrumb(incoming.getBreadcrumb());
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> mergeReasons(List<String> base, List<String> boosts) {
|
||||
Set<String> merged = new LinkedHashSet<>();
|
||||
if (base != null) {
|
||||
merged.addAll(base);
|
||||
}
|
||||
if (boosts != null) {
|
||||
merged.addAll(boosts);
|
||||
}
|
||||
return new ArrayList<>(merged);
|
||||
}
|
||||
|
||||
private String sourceKey(EvidenceBlock block, String fallback) {
|
||||
return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback);
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text == null || text.length() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private record ScoredCandidate(RetrievedEvidenceCandidate candidate,
|
||||
double baseScore,
|
||||
double finalScore,
|
||||
List<String> boostReasons) {
|
||||
}
|
||||
|
||||
private record RelevanceAssessment(String level, String hint) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.KnowledgeQuery;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Converts a raw Agent query into retrieval-control hints.
|
||||
*/
|
||||
@Service
|
||||
public class KnowledgeQueryTransformer {
|
||||
|
||||
private final KnowledgeIndexService knowledgeIndexService;
|
||||
|
||||
public KnowledgeQueryTransformer(KnowledgeIndexService knowledgeIndexService) {
|
||||
this.knowledgeIndexService = knowledgeIndexService;
|
||||
}
|
||||
|
||||
public KnowledgeQuery transform(String rawQuery) {
|
||||
String normalized = rawQuery == null ? "" : rawQuery.trim();
|
||||
KnowledgeIndexService.L0Hint hint = knowledgeIndexService.analyzeQuery(normalized);
|
||||
return KnowledgeQuery.builder()
|
||||
.originalQuery(normalized)
|
||||
.rewrittenQuery(normalized)
|
||||
.domainHints(safeList(hint.domains()))
|
||||
.matchedKeywords(safeList(hint.matchedKeywords()))
|
||||
.entities(safeList(hint.entities()))
|
||||
.categoryFilter(hint.singleDomainOrNull())
|
||||
.l0Titles(safeList(hint.titles()))
|
||||
.l0MatchCount(hint.matches() == null ? 0 : hint.matches().size())
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<String> safeList(List<String> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.ContextPack;
|
||||
import com.superbiz.agent.dto.EvidencePostprocessResult;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.dto.RetrievalTrace;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Assembles evidence-first LookupResult instances.
|
||||
*/
|
||||
@Service
|
||||
public class LookupResultAssembler {
|
||||
|
||||
public LookupResult assemble(EvidencePostprocessResult evidence,
|
||||
ContextPack contextPack,
|
||||
RetrievalTrace retrievalTrace) {
|
||||
boolean found = evidence != null && evidence.hasUsableEvidence();
|
||||
return LookupResult.builder()
|
||||
.found(found)
|
||||
.evidenceBlocks(evidence != null ? evidence.getEvidenceBlocks() : List.of())
|
||||
.evidenceCandidateCount(evidence != null ? evidence.getCandidateCount() : 0)
|
||||
.evidenceBlockCount(evidence != null ? evidence.getEvidenceBlockCount() : 0)
|
||||
.contextPack(contextPack)
|
||||
.retrievalTrace(retrievalTrace)
|
||||
.rerankTrace(evidence != null ? evidence.getRerankTrace() : null)
|
||||
.relevanceLevel(evidence != null ? evidence.getRelevanceLevel() : null)
|
||||
.completenessHint(evidence != null ? evidence.getCompletenessHint() : null)
|
||||
.message(found ? null : "知识库未检索到可用证据,请结合日志、指标、告警继续排查")
|
||||
.build();
|
||||
}
|
||||
|
||||
public LookupResult deduped(LookupResult original, List<String> retrievedDomains, String docKey) {
|
||||
return LookupResult.builder()
|
||||
.found(false)
|
||||
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
|
||||
.evidenceBlocks(List.of())
|
||||
.evidenceCandidateCount(0)
|
||||
.evidenceBlockCount(0)
|
||||
.retrievalTrace(original.getRetrievalTrace())
|
||||
.rerankTrace(original.getRerankTrace())
|
||||
.relevanceLevel(original.getRelevanceLevel())
|
||||
.completenessHint(original.getCompletenessHint())
|
||||
.retrievedDomainsThisSession(retrievedDomains)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package com.superbiz.agent.service;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||
import com.superbiz.agent.dto.ContextPack;
|
||||
import com.superbiz.agent.dto.EvidenceBlock;
|
||||
import com.superbiz.agent.dto.KnowledgeQuery;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.dto.RerankTrace;
|
||||
import com.superbiz.agent.dto.RetrievalTrace;
|
||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.util.SessionContextHolder;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -138,6 +141,21 @@ public class ToolInvocationRecorder {
|
||||
if (record.evidenceBlocks() != null && !record.evidenceBlocks().isEmpty()) {
|
||||
details.put("evidence_blocks", record.evidenceBlocks());
|
||||
}
|
||||
if (record.queryTransform() != null && !record.queryTransform().isEmpty()) {
|
||||
details.put("query_transform", record.queryTransform());
|
||||
}
|
||||
if (record.retrievalTrace() != null && !record.retrievalTrace().isEmpty()) {
|
||||
details.put("retrieval_trace", record.retrievalTrace());
|
||||
}
|
||||
if (record.contextPack() != null && !record.contextPack().isEmpty()) {
|
||||
details.put("context_pack_summary", record.contextPack());
|
||||
}
|
||||
if (record.rerankTrace() != null && !record.rerankTrace().isEmpty()) {
|
||||
details.put("rerank_trace", record.rerankTrace());
|
||||
}
|
||||
if (record.fallbackReason() != null && !record.fallbackReason().isBlank()) {
|
||||
details.put("fallback_reason", record.fallbackReason());
|
||||
}
|
||||
if (record.relevanceLevel() != null) {
|
||||
details.put("relevance_level", record.relevanceLevel());
|
||||
}
|
||||
@@ -222,40 +240,33 @@ public class ToolInvocationRecorder {
|
||||
List<Double> l1Scores,
|
||||
Integer evidenceCandidateCount,
|
||||
Integer evidenceBlockCount,
|
||||
List<Map<String, Object>> evidenceBlocks
|
||||
List<Map<String, Object>> evidenceBlocks,
|
||||
Map<String, Object> queryTransform,
|
||||
Map<String, Object> retrievalTrace,
|
||||
Map<String, Object> contextPack,
|
||||
Map<String, Object> rerankTrace,
|
||||
String fallbackReason
|
||||
) {
|
||||
public static LookupKnowledgeRecord from(String query,
|
||||
KnowledgeIndexService.L0Hint l0Hint,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence,
|
||||
public static LookupKnowledgeRecord from(KnowledgeQuery query,
|
||||
LookupResult result,
|
||||
String domain,
|
||||
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;
|
||||
if (hasL0 && !highConfidence) {
|
||||
layer = "L0+L1";
|
||||
} else if (hasL0) {
|
||||
layer = "L0";
|
||||
} else if (hasL1) {
|
||||
layer = "L1";
|
||||
} else {
|
||||
layer = null;
|
||||
}
|
||||
int durationMs) {
|
||||
RetrievalTrace trace = result != null ? result.getRetrievalTrace() : null;
|
||||
String layer = trace != null ? trace.getSelectedAttempt() : null;
|
||||
|
||||
String outputPreview = null;
|
||||
int outputLength = 0;
|
||||
boolean truncated = false;
|
||||
if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
|
||||
outputPreview = result.getPrimary().getContent();
|
||||
if (result != null && result.getContextPack() != null
|
||||
&& result.getContextPack().getPackedText() != null) {
|
||||
outputPreview = result.getContextPack().getPackedText();
|
||||
outputLength = outputPreview.length();
|
||||
truncated = outputLength > OUTPUT_PREVIEW_LIMIT;
|
||||
} else if (hasL1 && l1Results.get(0).getContent() != null) {
|
||||
outputPreview = l1Results.get(0).getContent();
|
||||
} else if (result != null && result.getEvidenceBlocks() != null
|
||||
&& !result.getEvidenceBlocks().isEmpty()
|
||||
&& result.getEvidenceBlocks().get(0).getContent() != null) {
|
||||
outputPreview = result.getEvidenceBlocks().get(0).getContent();
|
||||
outputLength = outputPreview.length();
|
||||
truncated = outputLength > OUTPUT_PREVIEW_LIMIT;
|
||||
}
|
||||
@@ -265,29 +276,19 @@ public class ToolInvocationRecorder {
|
||||
evidenceStatus = EVIDENCE_STATUS_DEDUPED;
|
||||
} else if (result == null || !result.isFound()) {
|
||||
evidenceStatus = EVIDENCE_STATUS_NO_EVIDENCE;
|
||||
} else if (trace != null && trace.getEvidenceStatus() != null) {
|
||||
evidenceStatus = trace.getEvidenceStatus();
|
||||
}
|
||||
|
||||
List<String> l0Titles = new ArrayList<>();
|
||||
if (hasL0) {
|
||||
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
|
||||
l0Titles.add(l0Matches.get(i).getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
List<Double> l1Scores = new ArrayList<>();
|
||||
if (hasL1) {
|
||||
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
|
||||
l1Scores.add((double) l1Results.get(i).getScore());
|
||||
}
|
||||
}
|
||||
List<Double> l1Scores = collectAttemptScores(trace);
|
||||
|
||||
return LookupKnowledgeRecord.builder()
|
||||
.query(query)
|
||||
.query(query != null ? query.getOriginalQuery() : null)
|
||||
.outputPreview(outputPreview)
|
||||
.outputLength(outputLength)
|
||||
.retrievalLayer(layer)
|
||||
.l0MatchCount(hasL0 ? l0Matches.size() : null)
|
||||
.l1MatchCount(hasL1 ? l1Results.size() : null)
|
||||
.l0MatchCount(query != null ? query.getL0MatchCount() : null)
|
||||
.l1MatchCount(totalCandidateCount(trace))
|
||||
.truncated(truncated)
|
||||
.relevanceLevel(result != null ? result.getRelevanceLevel() : null)
|
||||
.completenessHint(result != null ? result.getCompletenessHint() : null)
|
||||
@@ -296,16 +297,21 @@ public class ToolInvocationRecorder {
|
||||
.durationMs(durationMs)
|
||||
.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)
|
||||
.l0Titles(query != null ? query.getL0Titles() : List.of())
|
||||
.l0MatchedKeywords(query != null ? query.getMatchedKeywords() : List.of())
|
||||
.l0Domains(query != null ? query.getDomainHints() : List.of())
|
||||
.l0Entities(query != null ? query.getEntities() : List.of())
|
||||
.l1TopScore(firstAttemptScore(trace))
|
||||
.l1TopSimilarity(firstAttemptSimilarity(trace))
|
||||
.l1Scores(l1Scores)
|
||||
.evidenceCandidateCount(result != null ? result.getEvidenceCandidateCount() : null)
|
||||
.evidenceBlockCount(result != null ? result.getEvidenceBlockCount() : null)
|
||||
.evidenceBlocks(result != null ? summarizeEvidenceBlocks(result.getEvidenceBlocks()) : List.of())
|
||||
.queryTransform(summarizeQueryTransform(query))
|
||||
.retrievalTrace(summarizeRetrievalTrace(trace))
|
||||
.contextPack(summarizeContextPack(result != null ? result.getContextPack() : null))
|
||||
.rerankTrace(summarizeRerankTrace(result != null ? result.getRerankTrace() : null))
|
||||
.fallbackReason(trace != null ? trace.getFallbackReason() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -331,5 +337,130 @@ public class ToolInvocationRecorder {
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static Map<String, Object> summarizeQueryTransform(KnowledgeQuery query) {
|
||||
if (query == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("original_query", query.getOriginalQuery());
|
||||
summary.put("rewritten_query", query.getRewrittenQuery());
|
||||
summary.put("category_filter", query.getCategoryFilter());
|
||||
summary.put("domain_hints", query.getDomainHints());
|
||||
summary.put("matched_keywords", query.getMatchedKeywords());
|
||||
summary.put("entities", query.getEntities());
|
||||
summary.put("l0_titles", query.getL0Titles());
|
||||
summary.put("l0_match_count", query.getL0MatchCount());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static Map<String, Object> summarizeRetrievalTrace(RetrievalTrace trace) {
|
||||
if (trace == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("selected_attempt", trace.getSelectedAttempt());
|
||||
summary.put("fallback_reason", trace.getFallbackReason());
|
||||
summary.put("evidence_status", trace.getEvidenceStatus());
|
||||
summary.put("category_filter", trace.getCategoryFilter());
|
||||
List<Map<String, Object>> attempts = new ArrayList<>();
|
||||
if (trace.getAttempts() != null) {
|
||||
for (RetrievalTrace.Attempt attempt : trace.getAttempts()) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("name", attempt.getName());
|
||||
item.put("category_filter", attempt.getCategoryFilter());
|
||||
item.put("candidate_count", attempt.getCandidateCount());
|
||||
item.put("usable", attempt.getUsable());
|
||||
item.put("duration_ms", attempt.getDurationMs());
|
||||
item.put("top_score", attempt.getTopScore());
|
||||
item.put("top_similarity", attempt.getTopSimilarity());
|
||||
item.put("error_message", attempt.getErrorMessage());
|
||||
attempts.add(item);
|
||||
}
|
||||
}
|
||||
summary.put("attempts", attempts);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static Map<String, Object> summarizeContextPack(ContextPack contextPack) {
|
||||
if (contextPack == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("strategy", contextPack.getStrategy());
|
||||
summary.put("char_budget", contextPack.getCharBudget());
|
||||
summary.put("used_chars", contextPack.getUsedChars());
|
||||
summary.put("included_sources", contextPack.getIncludedSources());
|
||||
summary.put("omitted_sources", contextPack.getOmittedSources());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static Map<String, Object> summarizeRerankTrace(RerankTrace trace) {
|
||||
if (trace == null || trace.getItems() == null || trace.getItems().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (int i = 0; i < Math.min(5, trace.getItems().size()); i++) {
|
||||
RerankTrace.Item item = trace.getItems().get(i);
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("final_rank", item.getFinalRank());
|
||||
summary.put("source", item.getSource());
|
||||
summary.put("base_score", item.getBaseScore());
|
||||
summary.put("final_score", item.getFinalScore());
|
||||
summary.put("boost_reasons", item.getBoostReasons());
|
||||
items.add(summary);
|
||||
}
|
||||
return Map.of("items", items);
|
||||
}
|
||||
|
||||
private static Integer totalCandidateCount(RetrievalTrace trace) {
|
||||
if (trace == null || trace.getAttempts() == null || trace.getAttempts().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int total = 0;
|
||||
for (RetrievalTrace.Attempt attempt : trace.getAttempts()) {
|
||||
if (attempt.getCandidateCount() != null) {
|
||||
total += attempt.getCandidateCount();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Double firstAttemptScore(RetrievalTrace trace) {
|
||||
if (trace == null || trace.getAttempts() == null || trace.getAttempts().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (RetrievalTrace.Attempt attempt : trace.getAttempts()) {
|
||||
if (attempt.getTopScore() != null) {
|
||||
return attempt.getTopScore();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Double firstAttemptSimilarity(RetrievalTrace trace) {
|
||||
if (trace == null || trace.getAttempts() == null || trace.getAttempts().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (RetrievalTrace.Attempt attempt : trace.getAttempts()) {
|
||||
if (attempt.getTopSimilarity() != null) {
|
||||
return attempt.getTopSimilarity();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Double> collectAttemptScores(RetrievalTrace trace) {
|
||||
if (trace == null || trace.getAttempts() == null || trace.getAttempts().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Double> scores = new ArrayList<>();
|
||||
for (RetrievalTrace.Attempt attempt : trace.getAttempts()) {
|
||||
if (attempt.getTopScore() != null) {
|
||||
scores.add(attempt.getTopScore());
|
||||
}
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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.service.KnowledgeIndexService;
|
||||
import com.superbiz.agent.dto.ContextPack;
|
||||
import com.superbiz.agent.dto.EvidenceBlock;
|
||||
import com.superbiz.agent.dto.EvidencePostprocessResult;
|
||||
import com.superbiz.agent.dto.KnowledgeQuery;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.dto.RetrievalTrace;
|
||||
import com.superbiz.agent.service.KnowledgeContextPacker;
|
||||
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
|
||||
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
|
||||
import com.superbiz.agent.service.KnowledgeQueryTransformer;
|
||||
import com.superbiz.agent.service.LookupResultAssembler;
|
||||
import com.superbiz.agent.service.ToolInvocationRecorder;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.superbiz.agent.util.SessionContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
@@ -16,41 +22,39 @@ import org.springframework.stereotype.Component;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 知识库查询工具
|
||||
* 提供给 Agent 的混合检索工具(L0 + L1)
|
||||
* 内置归一化层:将 L0 匹配数 + L1 L2 距离归一化为统一质量等级
|
||||
* Agent-facing explicit knowledge retrieval tool.
|
||||
*/
|
||||
@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 ATTEMPT_FILTERED_VECTOR = "FILTERED_VECTOR";
|
||||
private static final String ATTEMPT_UNFILTERED_VECTOR = "UNFILTERED_VECTOR";
|
||||
private static final String ATTEMPT_UNFILTERED_VECTOR_RETRY = "UNFILTERED_VECTOR_RETRY";
|
||||
private static final String FALLBACK_NO_EVIDENCE = "filtered_vector_no_evidence";
|
||||
private static final String FALLBACK_LOW_QUALITY = "filtered_vector_low_quality";
|
||||
|
||||
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 = 2.0;
|
||||
|
||||
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
|
||||
private double highlyRelevantThreshold = 0.75;
|
||||
|
||||
@Value("${retrieval.normalization.reference-threshold:0.5}")
|
||||
private double referenceThreshold = 0.5;
|
||||
@Value("${rag.top-k:3}")
|
||||
private int topK = 3;
|
||||
|
||||
@Autowired
|
||||
private KnowledgeIndexService knowledgeIndexService;
|
||||
private KnowledgeQueryTransformer queryTransformer;
|
||||
|
||||
@Autowired
|
||||
private VectorSearchService vectorSearchService;
|
||||
private KnowledgeDocumentRetriever documentRetriever;
|
||||
|
||||
@Autowired
|
||||
private KnowledgeEvidencePostProcessor evidencePostProcessor;
|
||||
|
||||
@Autowired
|
||||
private KnowledgeContextPacker contextPacker;
|
||||
|
||||
@Autowired
|
||||
private LookupResultAssembler resultAssembler;
|
||||
|
||||
@Autowired
|
||||
private ToolInvocationRecorder toolInvocationRecorder;
|
||||
@@ -58,26 +62,20 @@ public class LookupKnowledgeTool {
|
||||
@Autowired
|
||||
private RetrievedDocTracker retrievedDocTracker;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 查询知识库文档
|
||||
* 查询知识库文档。
|
||||
*
|
||||
* @param query 查询关键词
|
||||
* @return 查询结果
|
||||
*/
|
||||
@Tool(description = "查询内部知识库文档,获取错误码定义、接口文档、排障步骤、配置说明等背景信息。" +
|
||||
"采用两阶段检索:L0 精确匹配关键词(< 10ms),L1 语义检索补充(200-500ms)。" +
|
||||
"IMPORTANT: 遇到错误码、接口名、配置项、排障问题时,优先使用此工具。" +
|
||||
"支持的查询场景:" +
|
||||
"1) 错误码定义 - 查询错误码的含义和处理方法,例如 'ERR_TIMEOUT'、'ERR_CONNECTION_REFUSED';" +
|
||||
"2) 接口文档 - 查询 API 接口定义、参数说明、返回格式,例如 'payment-gateway'、'/api/v1/orders';" +
|
||||
"3) 排障步骤 - 查询故障诊断流程、最佳实践,例如 '支付超时排查'、'数据库连接池配置';" +
|
||||
"4) 配置说明 - 查询系统配置、中间件参数,例如 'HikariCP'、'Redis 集群配置'。" +
|
||||
@Tool(description = "查询内部知识库文档,获取错误码定义、接口文档、排障步骤、配置说明和历史案例等背景知识。" +
|
||||
"工具会执行 query understanding、向量检索、证据去重、轻量重排和上下文打包,并返回 evidenceBlocks、contextPack、retrievalTrace、rerankTrace。" +
|
||||
"L0 仅用于领域/关键词/实体 hint 和可选 metadata filter,不作为事实证据兜底。" +
|
||||
"当带 filter 的向量检索低质量或无结果时,会跳过 L0 filter,用原始 query 再做一次无过滤语义检索。" +
|
||||
"不要用此工具查询实时运行状态;日志、指标、告警等实时事实应使用 query_logs、query_metrics 或告警工具。" +
|
||||
"参数 query: 查询关键词或描述")
|
||||
public LookupResult lookupKnowledge(String query) {
|
||||
String requestId = java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||
String requestId = UUID.randomUUID().toString().substring(0, 8);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
log.info("========================================");
|
||||
@@ -86,566 +84,179 @@ public class LookupKnowledgeTool {
|
||||
log.info(">>> RequestId: {}", requestId);
|
||||
log.info("----------------------------------------");
|
||||
|
||||
// Step 1: L0 hint 分析
|
||||
long l0Start = System.currentTimeMillis();
|
||||
KnowledgeIndexService.L0Hint l0Hint = knowledgeIndexService.analyzeQuery(query);
|
||||
List<KnowledgeEntry> l0Matches = l0Hint.matches();
|
||||
long l0Time = System.currentTimeMillis() - l0Start;
|
||||
log.info("[L0 Hint] 完成: matches={}, domains={}, keywords={}, time={}ms",
|
||||
l0Matches.size(), l0Hint.domains(), l0Hint.matchedKeywords(), l0Time);
|
||||
if (!l0Matches.isEmpty()) {
|
||||
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());
|
||||
}
|
||||
KnowledgeQuery knowledgeQuery = queryTransformer.transform(query);
|
||||
log.info("[QueryTransformer] rewrittenQuery={}, categoryFilter={}, domains={}, keywords={}",
|
||||
knowledgeQuery.getRewrittenQuery(),
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
knowledgeQuery.getDomainHints(),
|
||||
knowledgeQuery.getMatchedKeywords());
|
||||
|
||||
List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
|
||||
String fallbackReason = null;
|
||||
|
||||
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
|
||||
? ATTEMPT_UNFILTERED_VECTOR
|
||||
: ATTEMPT_FILTERED_VECTOR;
|
||||
KnowledgeDocumentRetriever.RetrievalAttemptResult firstAttempt =
|
||||
documentRetriever.retrieve(firstAttemptName,
|
||||
knowledgeQuery.getRewrittenQuery(),
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
topK);
|
||||
EvidencePostprocessResult selectedEvidence = evidencePostProcessor.process(
|
||||
knowledgeQuery,
|
||||
firstAttempt.candidates());
|
||||
enrichAttempt(firstAttempt.attempt(), selectedEvidence);
|
||||
attempts.add(firstAttempt.attempt());
|
||||
String selectedAttemptName = firstAttemptName;
|
||||
|
||||
if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) {
|
||||
fallbackReason = selectedEvidence.hasUsableEvidence()
|
||||
? FALLBACK_LOW_QUALITY
|
||||
: FALLBACK_NO_EVIDENCE;
|
||||
log.info("[RetrievalFallback] {} -> retry without category filter", fallbackReason);
|
||||
|
||||
KnowledgeDocumentRetriever.RetrievalAttemptResult retryAttempt =
|
||||
documentRetriever.retrieve(ATTEMPT_UNFILTERED_VECTOR_RETRY,
|
||||
knowledgeQuery.getOriginalQuery(),
|
||||
null,
|
||||
topK);
|
||||
EvidencePostprocessResult retryEvidence = evidencePostProcessor.process(
|
||||
knowledgeQuery,
|
||||
retryAttempt.candidates());
|
||||
enrichAttempt(retryAttempt.attempt(), retryEvidence);
|
||||
attempts.add(retryAttempt.attempt());
|
||||
selectedEvidence = retryEvidence;
|
||||
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
|
||||
}
|
||||
|
||||
// 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, l0CategoryFilter);
|
||||
long l1Time = System.currentTimeMillis() - l1Start;
|
||||
log.info("[L1 语义检索] 完成: matches={}, time={}ms",
|
||||
l1Results != null ? l1Results.size() : 0, l1Time);
|
||||
if (l1Results != null && !l1Results.isEmpty()) {
|
||||
log.info("[L1 语义检索] 找到文档:");
|
||||
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
|
||||
VectorSearchService.SearchResult result = l1Results.get(i);
|
||||
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[L1 语义检索] 调用失败,保留L0 fallback: {}", e.getMessage());
|
||||
l1Results = List.of();
|
||||
}
|
||||
ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks());
|
||||
RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName,
|
||||
fallbackReason, selectedEvidence);
|
||||
LookupResult result = resultAssembler.assemble(selectedEvidence, contextPack, retrievalTrace);
|
||||
|
||||
// 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 4: 组装结果
|
||||
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
|
||||
result.setRelevanceLevel(assessment.level);
|
||||
result.setCompletenessHint(assessment.hint);
|
||||
|
||||
// Step 5: session 级去重过滤 + 域级行动记忆
|
||||
String sessionId = SessionContextHolder.getSessionId();
|
||||
String domain = extractDomain(l0Matches, l1Results);
|
||||
|
||||
String domain = extractDomain(knowledgeQuery);
|
||||
if (sessionId != null && result.isFound()) {
|
||||
String docKey = extractDocKey(result);
|
||||
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
|
||||
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
|
||||
List<String> retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
|
||||
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
|
||||
return LookupResult.builder()
|
||||
.found(false)
|
||||
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
|
||||
.relevanceLevel(assessment.level)
|
||||
.completenessHint(assessment.hint)
|
||||
.retrievedDomainsThisSession(retrievedDomains)
|
||||
.build();
|
||||
saveToolInvocation(knowledgeQuery, startTime, result, domain, "doc_retrieved");
|
||||
LookupResult deduped = resultAssembler.deduped(
|
||||
result,
|
||||
retrievedDocTracker.getRetrievedDomains(sessionId),
|
||||
docKey);
|
||||
logReturn(deduped, startTime);
|
||||
return deduped;
|
||||
}
|
||||
if (docKey != null) {
|
||||
retrievedDocTracker.markRetrieved(sessionId, domain, docKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 附加行动记忆
|
||||
if (sessionId != null) {
|
||||
result.setRetrievedDomainsThisSession(retrievedDocTracker.getRetrievedDomains(sessionId));
|
||||
}
|
||||
|
||||
// 记录结构化结果摘要
|
||||
long totalTime = System.currentTimeMillis() - startTime;
|
||||
log.info("----------------------------------------");
|
||||
log.info("<<< [工具返回] lookup_knowledge");
|
||||
log.info("<<< 结果: found={}, relevanceLevel={}, 耗时: {}ms",
|
||||
result.isFound(), result.getRelevanceLevel(), totalTime);
|
||||
log.info("<<< 行动记忆: retrievedDomainsThisSession={}", result.getRetrievedDomainsThisSession());
|
||||
|
||||
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());
|
||||
}
|
||||
String content = result.getPrimary() != null ? result.getPrimary().getContent() : null;
|
||||
if (content != null) {
|
||||
int headingCount = countMdHeadings(content);
|
||||
log.info("<<< [L0 主结果] 内容: {} 字符, {} 个章节", content.length(), headingCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (l1Results != null && !l1Results.isEmpty()) {
|
||||
VectorSearchService.SearchResult topL1 = l1Results.get(0);
|
||||
log.info("<<< [L1 补充] 来源: {}", topL1.getMetadata() != null ? topL1.getMetadata() : topL1.getId());
|
||||
log.info("<<< [L1 补充] L2距离: {}, similarity: {}",
|
||||
String.format("%.4f", topL1.getScore()),
|
||||
String.format("%.4f", normalizeL2(topL1.getScore())));
|
||||
}
|
||||
|
||||
log.info("========================================");
|
||||
|
||||
// 记录 tool_invocation
|
||||
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, null);
|
||||
|
||||
saveToolInvocation(knowledgeQuery, startTime, result, domain, null);
|
||||
logReturn(result, startTime);
|
||||
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;
|
||||
private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) {
|
||||
attempt.setTopSimilarity(evidence.getTopSimilarity());
|
||||
attempt.setUsable(evidence.hasUsableEvidence()
|
||||
&& evidence.getTopSimilarity() != null
|
||||
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化质量等级判定
|
||||
*
|
||||
* @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;
|
||||
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
|
||||
List<RetrievalTrace.Attempt> attempts,
|
||||
String selectedAttempt,
|
||||
String fallbackReason,
|
||||
EvidencePostprocessResult evidence) {
|
||||
Map<String, Object> queryHints = new LinkedHashMap<>();
|
||||
queryHints.put("domains", query.getDomainHints());
|
||||
queryHints.put("matched_keywords", query.getMatchedKeywords());
|
||||
queryHints.put("entities", query.getEntities());
|
||||
queryHints.put("l0_titles", query.getL0Titles());
|
||||
queryHints.put("l0_match_count", query.getL0MatchCount());
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 仅 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);
|
||||
return RetrievalTrace.builder()
|
||||
.originalQuery(query.getOriginalQuery())
|
||||
.rewrittenQuery(query.getRewrittenQuery())
|
||||
.categoryFilter(query.getCategoryFilter())
|
||||
.selectedAttempt(selectedAttempt)
|
||||
.fallbackReason(fallbackReason)
|
||||
.evidenceStatus(evidence.hasUsableEvidence()
|
||||
? ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED
|
||||
: ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE)
|
||||
.queryHints(queryHints)
|
||||
.attempts(attempts)
|
||||
.build();
|
||||
}
|
||||
|
||||
boolean isHighConfidence(int l0MatchCount, float l1TopScore) {
|
||||
if (l0MatchCount != 1) {
|
||||
return false;
|
||||
private String extractDomain(KnowledgeQuery query) {
|
||||
if (query.getCategoryFilter() != null && !query.getCategoryFilter().isBlank()) {
|
||||
return query.getCategoryFilter();
|
||||
}
|
||||
if (l1TopScore == Float.MAX_VALUE) {
|
||||
return true;
|
||||
if (query.getDomainHints() != null && !query.getDomainHints().isEmpty()) {
|
||||
return query.getDomainHints().get(0);
|
||||
}
|
||||
return normalizeL2(l1TopScore) >= highlyRelevantThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化评估结果
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 入库 ====================
|
||||
private String extractDocKey(LookupResult result) {
|
||||
if (result.getEvidenceBlocks() == null || result.getEvidenceBlocks().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
EvidenceBlock first = result.getEvidenceBlocks().get(0);
|
||||
if (first.getSource() != null && !first.getSource().isBlank()) {
|
||||
return first.getSource();
|
||||
}
|
||||
if (first.getTitle() != null && !first.getTitle().isBlank()) {
|
||||
return first.getTitle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存工具调用明细到 tool_invocation 表
|
||||
*/
|
||||
private void saveToolInvocation(String query, KnowledgeIndexService.L0Hint l0Hint,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence, long startTime,
|
||||
LookupResult result, String domain, String dedupReason) {
|
||||
private void saveToolInvocation(KnowledgeQuery query,
|
||||
long startTime,
|
||||
LookupResult result,
|
||||
String domain,
|
||||
String dedupReason) {
|
||||
try {
|
||||
String sessionId = SessionContextHolder.getSessionId();
|
||||
if (sessionId == null) return;
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
double l1TopSimilarity = (l1Results != null && !l1Results.isEmpty())
|
||||
? normalizeL2(l1Results.get(0).getScore())
|
||||
: -1;
|
||||
|
||||
ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
|
||||
query,
|
||||
l0Hint,
|
||||
l1Results,
|
||||
highConfidence,
|
||||
result,
|
||||
domain,
|
||||
dedupReason,
|
||||
(int) duration,
|
||||
l1TopSimilarity
|
||||
);
|
||||
if (SessionContextHolder.getSessionId() == null) {
|
||||
return;
|
||||
}
|
||||
ToolInvocationRecorder.LookupKnowledgeRecord record =
|
||||
ToolInvocationRecorder.LookupKnowledgeRecord.from(
|
||||
query,
|
||||
result,
|
||||
domain,
|
||||
dedupReason,
|
||||
(int) Math.max(0, System.currentTimeMillis() - startTime)
|
||||
);
|
||||
toolInvocationRecorder.recordLookupKnowledge(record);
|
||||
log.debug("tool_invocation 已保存: sessionId={}, layer={}, relevanceLevel={}, duration={}ms",
|
||||
sessionId, record.retrievalLayer(), record.relevanceLevel(), duration);
|
||||
} catch (Exception e) {
|
||||
log.error("保存 tool_invocation 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 结果组装 ====================
|
||||
|
||||
private LookupResult buildResult(
|
||||
List<KnowledgeEntry> l0Matches,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence
|
||||
) {
|
||||
LookupResult.LookupResultBuilder builder = LookupResult.builder();
|
||||
|
||||
// 构建 primary(L0 结果)
|
||||
PrimaryResult primary = null;
|
||||
if (l0Matches != null && !l0Matches.isEmpty()) {
|
||||
KnowledgeEntry first = l0Matches.get(0);
|
||||
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
|
||||
boolean needFullContent = highConfidence || !hasL1;
|
||||
String content = needFullContent
|
||||
? buildCompactSummary(first)
|
||||
: buildMetadataOnlySummary(first);
|
||||
|
||||
if (content != null) {
|
||||
primary = PrimaryResult.builder()
|
||||
.content(content)
|
||||
.source(first.getFilePath())
|
||||
.matchType("exact_L0")
|
||||
.confidence(highConfidence ? "high" : "low")
|
||||
.availableSections(null)
|
||||
.build();
|
||||
log.debug("L0结果已构建: source={}, contentLength={}", first.getFilePath(), content.length());
|
||||
} else {
|
||||
log.warn("L0匹配但文件读取失败: {}", first.getFilePath());
|
||||
}
|
||||
private void logReturn(LookupResult result, long startTime) {
|
||||
long totalTime = System.currentTimeMillis() - startTime;
|
||||
log.info("----------------------------------------");
|
||||
log.info("<<< [工具返回] lookup_knowledge");
|
||||
log.info("<<< 结果: found={}, relevanceLevel={}, evidenceBlocks={}, 耗时: {}ms",
|
||||
result.isFound(),
|
||||
result.getRelevanceLevel(),
|
||||
result.getEvidenceBlockCount(),
|
||||
totalTime);
|
||||
log.info("<<< 行动记忆: retrievedDomainsThisSession={}", result.getRetrievedDomainsThisSession());
|
||||
if (result.getRetrievalTrace() != null) {
|
||||
log.info("<<< 检索路径: selectedAttempt={}, fallbackReason={}",
|
||||
result.getRetrievalTrace().getSelectedAttempt(),
|
||||
result.getRetrievalTrace().getFallbackReason());
|
||||
}
|
||||
builder.primary(primary);
|
||||
|
||||
// 构建 supplement(L1 结果)
|
||||
SupplementResult supplement = null;
|
||||
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
|
||||
if (hasL1) {
|
||||
VectorSearchService.SearchResult firstL1 = l1Results.get(0);
|
||||
supplement = SupplementResult.builder()
|
||||
.content(firstL1.getContent())
|
||||
.source(firstL1.getMetadata())
|
||||
.matchType("semantic_L1")
|
||||
.build();
|
||||
log.debug("L1结果已构建: source={}, score={}", firstL1.getMetadata(), firstL1.getScore());
|
||||
}
|
||||
builder.supplement(supplement);
|
||||
|
||||
EvidencePostprocessResult evidence = buildEvidenceBlocks(l0Matches, l1Results);
|
||||
builder.evidenceBlocks(evidence.blocks());
|
||||
builder.evidenceCandidateCount(evidence.candidateCount());
|
||||
builder.evidenceBlockCount(evidence.blocks().size());
|
||||
|
||||
boolean found = (primary != null) || (supplement != null);
|
||||
builder.found(found);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private EvidencePostprocessResult buildEvidenceBlocks(
|
||||
List<KnowledgeEntry> l0Matches,
|
||||
List<VectorSearchService.SearchResult> l1Results) {
|
||||
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
||||
int candidateCount = 0;
|
||||
|
||||
if (l0Matches != null) {
|
||||
for (int i = 0; i < l0Matches.size(); i++) {
|
||||
KnowledgeEntry entry = l0Matches.get(i);
|
||||
candidateCount++;
|
||||
EvidenceBlock block = EvidenceBlock.builder()
|
||||
.source(entry.getFilePath())
|
||||
.title(entry.getTitle())
|
||||
.breadcrumb(null)
|
||||
.retrievalLayer("L0")
|
||||
.content(buildMetadataOnlySummary(entry))
|
||||
.score(null)
|
||||
.hitReasons(buildL0HitReasons(entry, i + 1))
|
||||
.build();
|
||||
mergeEvidence(deduped, sourceKey(block, "l0-" + i), block);
|
||||
}
|
||||
}
|
||||
|
||||
if (l1Results != null) {
|
||||
for (int i = 0; i < l1Results.size(); i++) {
|
||||
VectorSearchService.SearchResult result = l1Results.get(i);
|
||||
candidateCount++;
|
||||
Map<String, String> metadata = parseMetadata(result.getMetadata());
|
||||
String source = firstNonBlank(
|
||||
metadata.get("_source"),
|
||||
metadata.get("docId"),
|
||||
result.getMetadata(),
|
||||
result.getId()
|
||||
);
|
||||
EvidenceBlock block = EvidenceBlock.builder()
|
||||
.source(source)
|
||||
.title(metadata.get("title"))
|
||||
.breadcrumb(metadata.get("breadcrumb"))
|
||||
.retrievalLayer("L1")
|
||||
.content(truncate(result.getContent(), 800))
|
||||
.score((double) result.getScore())
|
||||
.hitReasons(List.of("semantic_rank:" + (i + 1)))
|
||||
.build();
|
||||
mergeEvidence(deduped, sourceKey(block, "l1-" + i), block);
|
||||
}
|
||||
}
|
||||
|
||||
return new EvidencePostprocessResult(candidateCount, new ArrayList<>(deduped.values()));
|
||||
}
|
||||
|
||||
private void mergeEvidence(Map<String, EvidenceBlock> deduped, String key, EvidenceBlock incoming) {
|
||||
EvidenceBlock existing = deduped.get(key);
|
||||
if (existing == null) {
|
||||
deduped.put(key, incoming);
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> mergedReasons = new ArrayList<>();
|
||||
if (existing.getHitReasons() != null) {
|
||||
mergedReasons.addAll(existing.getHitReasons());
|
||||
}
|
||||
if (incoming.getHitReasons() != null) {
|
||||
for (String reason : incoming.getHitReasons()) {
|
||||
if (!mergedReasons.contains(reason)) {
|
||||
mergedReasons.add(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String mergedLayer = existing.getRetrievalLayer();
|
||||
if (incoming.getRetrievalLayer() != null && !incoming.getRetrievalLayer().equals(mergedLayer)) {
|
||||
mergedLayer = "L0+L1";
|
||||
}
|
||||
|
||||
existing.setRetrievalLayer(mergedLayer);
|
||||
existing.setHitReasons(mergedReasons);
|
||||
if (existing.getScore() == null && incoming.getScore() != null) {
|
||||
existing.setScore(incoming.getScore());
|
||||
}
|
||||
if ((existing.getBreadcrumb() == null || existing.getBreadcrumb().isBlank())
|
||||
&& incoming.getBreadcrumb() != null) {
|
||||
existing.setBreadcrumb(incoming.getBreadcrumb());
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> buildL0HitReasons(KnowledgeEntry entry, int rank) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
reasons.add("l0_rank:" + rank);
|
||||
if (entry.getKeywords() != null && !entry.getKeywords().isEmpty()) {
|
||||
reasons.add("l0_keywords:" + String.join(",", entry.getKeywords()));
|
||||
}
|
||||
if (entry.getCategory() != null && !entry.getCategory().isBlank()) {
|
||||
reasons.add("domain:" + entry.getCategory());
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private String sourceKey(EvidenceBlock block, String fallback) {
|
||||
return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback);
|
||||
}
|
||||
|
||||
private Map<String, String> parseMetadata(String metadata) {
|
||||
if (metadata == null || metadata.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
Map<?, ?> raw = objectMapper.readValue(metadata, Map.class);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : raw.entrySet()) {
|
||||
if (entry.getKey() != null && entry.getValue() != null) {
|
||||
result.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text == null || text.length() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private record EvidencePostprocessResult(int candidateCount, List<EvidenceBlock> blocks) {}
|
||||
|
||||
private int countMdHeadings(String content) {
|
||||
if (content == null) return 0;
|
||||
return (int) content.lines()
|
||||
.filter(l -> l.trim().startsWith("##"))
|
||||
.count();
|
||||
}
|
||||
|
||||
private String buildCompactSummary(KnowledgeEntry entry) {
|
||||
String rawContent = knowledgeIndexService.readDocument(entry.getFilePath(), 2000);
|
||||
if (rawContent == null) return null;
|
||||
|
||||
String body = rawContent;
|
||||
if (body.startsWith("---")) {
|
||||
int end = body.indexOf("---", 3);
|
||||
if (end != -1) {
|
||||
body = body.substring(end + 3).trim();
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("文档: ").append(entry.getTitle()).append("\n");
|
||||
if (entry.getSummary() != null) {
|
||||
sb.append("摘要: ").append(entry.getSummary()).append("\n");
|
||||
}
|
||||
|
||||
String headings = body.lines()
|
||||
.filter(l -> l.trim().startsWith("##"))
|
||||
.map(l -> " - " + l.trim().replaceAll("^#+\\s*", ""))
|
||||
.collect(Collectors.joining("\n"));
|
||||
if (!headings.isEmpty()) {
|
||||
sb.append("章节:\n").append(headings).append("\n");
|
||||
}
|
||||
sb.append("---\n");
|
||||
|
||||
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("...");
|
||||
} else {
|
||||
sb.append(textContent);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildMetadataOnlySummary(KnowledgeEntry entry) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("文档: ").append(entry.getTitle()).append("\n");
|
||||
if (entry.getSummary() != null) {
|
||||
sb.append("摘要: ").append(entry.getSummary()).append("\n");
|
||||
}
|
||||
if (entry.getKeywords() != null && !entry.getKeywords().isEmpty()) {
|
||||
sb.append("关键词: ").append(String.join(", ", entry.getKeywords())).append("\n");
|
||||
}
|
||||
sb.append("来源: ").append(entry.getFilePath()).append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String extractFirstMeaningfulLine(String content, int maxLen) {
|
||||
if (content == null || content.isBlank()) return "(空)";
|
||||
|
||||
String text = content.trim();
|
||||
if (text.startsWith("---")) {
|
||||
int end = text.indexOf("---", 3);
|
||||
if (end != -1) {
|
||||
text = text.substring(end + 3);
|
||||
}
|
||||
}
|
||||
|
||||
String[] lines = text.split("\n");
|
||||
for (String line : lines) {
|
||||
String tl = line.trim();
|
||||
if (!tl.isEmpty() && !tl.startsWith("#")) {
|
||||
return tl.length() <= maxLen ? tl : tl.substring(0, maxLen) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
for (String line : lines) {
|
||||
if (!line.trim().isEmpty()) {
|
||||
String tl = line.trim();
|
||||
return tl.length() <= maxLen ? tl : tl.substring(0, maxLen) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
return "(无有效内容)";
|
||||
}
|
||||
|
||||
private String extractDocKey(LookupResult result) {
|
||||
if (result.getPrimary() != null && result.getPrimary().getSource() != null) {
|
||||
return result.getPrimary().getSource();
|
||||
}
|
||||
if (result.getSupplement() != null && result.getSupplement().getSource() != null) {
|
||||
return result.getSupplement().getSource();
|
||||
}
|
||||
return null;
|
||||
log.info("========================================");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user