feat: add rag evidence postprocess blocks

This commit is contained in:
aruo
2026-07-05 03:03:18 +08:00
parent 4a94c14feb
commit 5197712719
12 changed files with 497 additions and 1 deletions
@@ -0,0 +1,28 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* Structured evidence returned by knowledge retrieval.
*/
@Data
@Builder
public class EvidenceBlock {
private String source;
private String title;
private String breadcrumb;
private String retrievalLayer;
private String content;
private Double score;
private List<String> hitReasons;
}
@@ -27,6 +27,21 @@ public class LookupResult {
*/
private SupplementResult supplement;
/**
* Structured evidence blocks after retrieval post-processing.
*/
private List<EvidenceBlock> evidenceBlocks;
/**
* Candidate count before evidence deduplication.
*/
private Integer evidenceCandidateCount;
/**
* Evidence block count after post-processing.
*/
private Integer evidenceBlockCount;
/**
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE
*/
@@ -3,6 +3,7 @@ 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.EvidenceBlock;
import com.superbiz.agent.dto.LookupResult;
import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.dto.KnowledgeEntry;
@@ -128,6 +129,15 @@ public class ToolInvocationRecorder {
if (record.l1Scores() != null && !record.l1Scores().isEmpty()) {
details.put("l1_scores", record.l1Scores());
}
if (record.evidenceCandidateCount() != null) {
details.put("evidence_candidate_count", record.evidenceCandidateCount());
}
if (record.evidenceBlockCount() != null) {
details.put("evidence_block_count", record.evidenceBlockCount());
}
if (record.evidenceBlocks() != null && !record.evidenceBlocks().isEmpty()) {
details.put("evidence_blocks", record.evidenceBlocks());
}
if (record.relevanceLevel() != null) {
details.put("relevance_level", record.relevanceLevel());
}
@@ -209,7 +219,10 @@ public class ToolInvocationRecorder {
List<String> l0Entities,
Double l1TopScore,
Double l1TopSimilarity,
List<Double> l1Scores
List<Double> l1Scores,
Integer evidenceCandidateCount,
Integer evidenceBlockCount,
List<Map<String, Object>> evidenceBlocks
) {
public static LookupKnowledgeRecord from(String query,
KnowledgeIndexService.L0Hint l0Hint,
@@ -290,7 +303,33 @@ public class ToolInvocationRecorder {
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
.l1Scores(l1Scores)
.evidenceCandidateCount(result != null ? result.getEvidenceCandidateCount() : null)
.evidenceBlockCount(result != null ? result.getEvidenceBlockCount() : null)
.evidenceBlocks(result != null ? summarizeEvidenceBlocks(result.getEvidenceBlocks()) : List.of())
.build();
}
private static List<Map<String, Object>> summarizeEvidenceBlocks(List<EvidenceBlock> blocks) {
if (blocks == null || blocks.isEmpty()) {
return List.of();
}
List<Map<String, Object>> summaries = new ArrayList<>();
for (int i = 0; i < Math.min(5, blocks.size()); i++) {
EvidenceBlock block = blocks.get(i);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("source", block.getSource());
summary.put("title", block.getTitle());
summary.put("breadcrumb", block.getBreadcrumb());
summary.put("retrieval_layer", block.getRetrievalLayer());
summary.put("score", block.getScore());
summary.put("hit_reasons", block.getHitReasons());
String content = block.getContent();
if (content != null) {
summary.put("content_preview", content.length() <= 180 ? content : content.substring(0, 180) + "...");
}
summaries.add(summary);
}
return summaries;
}
}
}
@@ -13,8 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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;
/**
@@ -397,12 +400,154 @@ public class LookupKnowledgeTool {
}
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()
@@ -2,6 +2,7 @@ package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.EvidenceBlock;
import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.util.SessionContextHolder;
import org.junit.jupiter.api.Test;
@@ -78,6 +79,14 @@ class ToolInvocationRecorderTest {
.l0MatchedKeywords(List.of("ERR_TIMEOUT"))
.l0Domains(List.of("payment"))
.l0Entities(List.of("ERR_TIMEOUT"))
.evidenceCandidateCount(2)
.evidenceBlockCount(1)
.evidenceBlocks(List.of(Map.of(
"source", "payment/errors.md",
"title", "payment/errors.md",
"retrieval_layer", "L0+L1",
"hit_reasons", List.of("l0_keywords:ERR_TIMEOUT", "semantic_rank:1")
)))
.build();
try {
@@ -98,5 +107,45 @@ class ToolInvocationRecorderTest {
assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]"));
assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]"));
assertTrue(saved.getRetrievalDetails().contains("\"l0_entities\":[\"ERR_TIMEOUT\"]"));
assertTrue(saved.getRetrievalDetails().contains("\"evidence_candidate_count\":2"));
assertTrue(saved.getRetrievalDetails().contains("\"evidence_block_count\":1"));
assertTrue(saved.getRetrievalDetails().contains("\"evidence_blocks\""));
}
@Test
void lookupKnowledgeRecordFromSummarizesEvidenceBlocks() {
EvidenceBlock block = EvidenceBlock.builder()
.source("doc.md")
.title("Doc")
.breadcrumb("A > B")
.retrievalLayer("L1")
.score(0.42)
.hitReasons(List.of("semantic_rank:1"))
.content("x".repeat(220))
.build();
com.superbiz.agent.dto.LookupResult result = com.superbiz.agent.dto.LookupResult.builder()
.found(true)
.evidenceCandidateCount(3)
.evidenceBlockCount(1)
.evidenceBlocks(List.of(block))
.build();
ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
"query",
KnowledgeIndexService.L0Hint.empty(),
List.of(),
false,
result,
null,
null,
10,
-1
);
assertEquals(3, record.evidenceCandidateCount());
assertEquals(1, record.evidenceBlockCount());
assertEquals(1, record.evidenceBlocks().size());
assertTrue(String.valueOf(record.evidenceBlocks().get(0).get("content_preview")).endsWith("..."));
}
}
@@ -14,6 +14,7 @@ import org.mockito.MockitoAnnotations;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -83,6 +84,12 @@ class LookupKnowledgeToolTest {
assertTrue(content.contains("Test content"));
assertNotNull(result.getSupplement()); // L0 唯一命中仍调用 L1
assertEquals("PRECISE", result.getRelevanceLevel());
assertNotNull(result.getEvidenceBlocks());
assertEquals(2, result.getEvidenceCandidateCount());
assertEquals(2, result.getEvidenceBlockCount());
assertEquals("L0", result.getEvidenceBlocks().get(0).getRetrievalLayer());
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().stream()
.anyMatch(reason -> reason.contains("ERR_TIMEOUT")));
verify(vectorSearchService).searchSimilarDocuments("ERR_TIMEOUT", 3, null);
}
@@ -290,6 +297,41 @@ class LookupKnowledgeToolTest {
verify(vectorSearchService).searchSimilarDocuments("fallback", 3, null);
}
@Test
void testLookup_deduplicatesEvidenceBlocksBySource() throws Exception {
KnowledgeEntry entry = KnowledgeEntry.builder()
.filePath("shared.md")
.title("Shared Doc")
.keywords(List.of("shared"))
.summary("Shared summary")
.build();
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
l1Result.setContent("Shared semantic content");
String metadata = "{\"docId\":\"doc-1\",\"_source\":\"shared.md\",\"title\":\"Shared Doc\"}";
l1Result.setMetadata(metadata);
l1Result.setScore(0.2f);
when(knowledgeIndexService.analyzeQuery("shared"))
.thenReturn(hint(entry));
when(knowledgeIndexService.readDocument("shared.md", 2000))
.thenReturn("Shared content");
when(vectorSearchService.searchSimilarDocuments("shared", 3, null))
.thenReturn(List.of(l1Result));
when(objectMapper.readValue(metadata, Map.class))
.thenReturn(Map.of("docId", "doc-1", "_source", "shared.md", "title", "Shared Doc"));
LookupResult result = tool.lookupKnowledge("shared");
assertTrue(result.isFound());
assertEquals(2, result.getEvidenceCandidateCount());
assertEquals(1, result.getEvidenceBlockCount());
assertEquals("shared.md", result.getEvidenceBlocks().get(0).getSource());
assertEquals("L0+L1", result.getEvidenceBlocks().get(0).getRetrievalLayer());
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().contains("semantic_rank:1"));
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().stream()
.anyMatch(reason -> reason.startsWith("l0_keywords:")));
}
private KnowledgeIndexService.L0Hint hint(KnowledgeEntry... entries) {
List<KnowledgeEntry> matches = List.of(entries);
List<String> keywords = matches.stream()