feat(rag): chunk evidence identity, dedup, and search port

Preserve same-document multi-chunk evidence with evidenceKey identity,
per-document caps, retrieve-k/return-n split, and a dense KnowledgeSearchPort.
Archives Delivery 1 OpenSpec change as the foundation for hybrid retrieval.
This commit is contained in:
zhuyongxin
2026-07-27 18:26:15 +08:00
parent 99d4f6f216
commit ac1f831903
34 changed files with 2880 additions and 298 deletions
@@ -16,14 +16,14 @@ class RagResultProjectorTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void projectsOnlyDeduplicatedBoundedEvidence() throws Exception {
void projectsDistinctChunkEvidenceEvenWhenSourceMatches() throws Exception {
RagResultProjector projector = new RagResultProjector(objectMapper,
new ToolProjectionLimits(2, 8, 2, 2, 20, 30, 4096));
new ToolProjectionLimits(3, 8, 2, 2, 20, 30, 4096));
String raw = """
{"found":true,"evidenceBlocks":[
{"source":"doc-1","title":"One","breadcrumb":"a","content":"123456789","score":0.99},
{"source":"doc-1","title":"Duplicate","content":"ignored"},
{"source":"doc-2","title":"Two","content":"second"}],
{"source":"doc-1","evidenceKey":"doc-1#chunk-0","title":"One","breadcrumb":"a","content":"123456789","score":0.99},
{"source":"doc-1","evidenceKey":"doc-1#chunk-1","title":"Two","content":"second-chunk"},
{"source":"doc-2","evidenceKey":"doc-2#chunk-0","title":"Other","content":"third"}],
"contextPack":{"packedText":"secret internal context"},"retrievalTrace":{"attempts":[]},"rerankTrace":{}}
""";
@@ -32,7 +32,9 @@ class RagResultProjectorTest {
assertEquals(EvidenceStatus.EVIDENCE_FOUND, projected.evidenceStatus());
assertEquals("framework-1", json.path("tool_call_id").asText());
assertEquals(2, json.path("returned_count").asInt());
assertEquals(3, json.path("returned_count").asInt());
assertEquals("doc-1#chunk-0", json.path("evidence").get(0).path("document_id").asText());
assertEquals("doc-1#chunk-1", json.path("evidence").get(1).path("document_id").asText());
assertEquals("12345678", json.path("evidence").get(0).path("excerpt").asText());
assertTrue(json.path("truncated").asBoolean());
assertFalse(projected.agentResult().contains("contextPack"));
@@ -40,12 +42,31 @@ class RagResultProjectorTest {
assertFalse(projected.agentResult().contains("score"));
}
@Test
void deduplicatesOnlySameEvidenceKey() throws Exception {
RagResultProjector projector = new RagResultProjector(objectMapper, ToolProjectionLimits.defaults());
String raw = """
{"found":true,"evidenceBlocks":[
{"source":"doc","evidenceKey":"doc#chunk-0","content":"first"},
{"source":"doc","evidenceKey":"doc#chunk-0","content":"dup"},
{"source":"doc","evidenceKey":"doc#chunk-1","content":"second"}]}
""";
JsonNode json = objectMapper.readTree(
projector.project(new RagToolRequest("q"), "call-1", raw).agentResult());
assertEquals(2, json.path("evidence").size());
assertEquals("doc#chunk-0", json.path("evidence").get(0).path("document_id").asText());
assertEquals("doc#chunk-1", json.path("evidence").get(1).path("document_id").asText());
assertTrue(json.path("truncated").asBoolean());
}
@Test
void preservesCamelAndSnakeCaseReferenceLevel() throws Exception {
RagResultProjector projector = new RagResultProjector(objectMapper, ToolProjectionLimits.defaults());
String camel = """
{"found":true,"relevanceLevel":"REFERENCE",
"evidenceBlocks":[{"source":"doc","content":"generic guidance"}]}
"evidenceBlocks":[{"source":"doc","evidenceKey":"doc#chunk-0","content":"generic guidance"}]}
""";
String snake = camel.replace("relevanceLevel", "relevance_level");
@@ -0,0 +1,122 @@
package com.superbiz.agent.service;
import com.superbiz.agent.dto.EvidencePostprocessResult;
import com.superbiz.agent.dto.KnowledgeQuery;
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class KnowledgeEvidencePostProcessorTest {
private KnowledgeEvidencePostProcessor processor;
@BeforeEach
void setUp() {
processor = new KnowledgeEvidencePostProcessor();
ReflectionTestUtils.setField(processor, "maxChunksPerDocument", 2);
ReflectionTestUtils.setField(processor, "returnN", 5);
}
@Test
void keepsDistinctChunksFromSameDocument() {
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "c0", 0.2),
candidate("shared", 1, "shared#chunk-1", "c1", 0.3)
));
assertEquals(2, result.getEvidenceBlockCount());
assertEquals("shared#chunk-0", result.getEvidenceBlocks().get(0).getEvidenceKey());
assertEquals("shared#chunk-1", result.getEvidenceBlocks().get(1).getEvidenceKey());
assertEquals("c0", result.getEvidenceBlocks().get(0).getContent());
assertEquals("c1", result.getEvidenceBlocks().get(1).getContent());
}
@Test
void mergesTrueDuplicateEvidenceKeysWithoutReplacingContent() {
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "keep-me", 0.2),
RetrievedEvidenceCandidate.builder()
.id("dup")
.docId("shared")
.chunkIndex(0)
.evidenceKey("shared#chunk-0")
.source("shared.md")
.content("drop-me")
.score(0.25)
.originalRank(2)
.hitReasons(List.of("extra"))
.metadata(Map.of())
.build()
));
assertEquals(1, result.getEvidenceBlockCount());
assertEquals("keep-me", result.getEvidenceBlocks().get(0).getContent());
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().contains("extra"));
}
@Test
void enforcesMaxChunksPerDocument() {
ReflectionTestUtils.setField(processor, "maxChunksPerDocument", 2);
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "c0", 0.1),
candidate("shared", 1, "shared#chunk-1", "c1", 0.2),
candidate("shared", 2, "shared#chunk-2", "c2", 0.3),
candidate("other", 0, "other#chunk-0", "o0", 0.15)
));
assertEquals(3, result.getEvidenceBlockCount());
assertEquals(2, result.getEvidenceBlocks().stream()
.filter(block -> "shared".equals(block.getDocId()))
.count());
assertTrue(result.getEvidenceBlocks().stream()
.noneMatch(block -> "shared#chunk-2".equals(block.getEvidenceKey())));
}
@Test
void enforcesReturnN() {
ReflectionTestUtils.setField(processor, "returnN", 1);
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("a", 0, "a#chunk-0", "a0", 0.1),
candidate("b", 0, "b#chunk-0", "b0", 0.2)
));
assertEquals(1, result.getEvidenceBlockCount());
assertEquals("a#chunk-0", result.getEvidenceBlocks().get(0).getEvidenceKey());
}
private static KnowledgeQuery query() {
return KnowledgeQuery.builder()
.originalQuery("q")
.rewrittenQuery("q")
.domainHints(List.of())
.matchedKeywords(List.of())
.entities(List.of())
.build();
}
private static RetrievedEvidenceCandidate candidate(String docId,
int chunkIndex,
String evidenceKey,
String content,
double score) {
return RetrievedEvidenceCandidate.builder()
.id(evidenceKey)
.docId(docId)
.chunkIndex(chunkIndex)
.evidenceKey(evidenceKey)
.source(docId + ".md")
.content(content)
.score(score)
.originalRank(chunkIndex + 1)
.hitReasons(List.of("base"))
.metadata(Map.of("docId", docId, "chunkIndex", String.valueOf(chunkIndex)))
.build();
}
}
@@ -10,6 +10,7 @@ import com.superbiz.agent.service.KnowledgeIndexService;
import com.superbiz.agent.service.KnowledgeQueryTransformer;
import com.superbiz.agent.service.LookupResultAssembler;
import com.superbiz.agent.service.VectorSearchService;
import com.superbiz.agent.service.retrieval.VectorKnowledgeSearchAdapter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
@@ -45,15 +46,17 @@ class LookupKnowledgeToolTest {
MockitoAnnotations.openMocks(this);
KnowledgeEvidencePostProcessor postProcessor = new KnowledgeEvidencePostProcessor();
ReflectionTestUtils.setField(postProcessor, "returnN", 5);
ReflectionTestUtils.setField(postProcessor, "maxChunksPerDocument", 2);
KnowledgeContextPacker contextPacker = new KnowledgeContextPacker();
tool = new LookupKnowledgeTool();
ReflectionTestUtils.setField(tool, "queryTransformer", new KnowledgeQueryTransformer(knowledgeIndexService));
ReflectionTestUtils.setField(tool, "documentRetriever",
new KnowledgeDocumentRetriever(vectorSearchService, new ObjectMapper()));
new KnowledgeDocumentRetriever(new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper())));
ReflectionTestUtils.setField(tool, "evidencePostProcessor", postProcessor);
ReflectionTestUtils.setField(tool, "contextPacker", contextPacker);
ReflectionTestUtils.setField(tool, "resultAssembler", new LookupResultAssembler());
ReflectionTestUtils.setField(tool, "topK", 3);
ReflectionTestUtils.setField(tool, "retrieveK", 3);
}
@Test
@@ -61,8 +64,7 @@ class LookupKnowledgeToolTest {
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
VectorSearchService.SearchResult result = searchResult(
"vec-1",
"db.md",
"{\"_source\":\"db.md\",\"title\":\"Database Doc\",\"category\":\"database\"}",
"{\"_source\":\"db.md\",\"docId\":\"db\",\"chunkIndex\":0,\"title\":\"Database Doc\",\"category\":\"database\"}",
"mysql timeout runbook",
0.2f);
@@ -75,6 +77,7 @@ class LookupKnowledgeToolTest {
assertTrue(lookup.isFound());
assertEquals(1, lookup.getEvidenceBlockCount());
assertEquals("db.md", lookup.getEvidenceBlocks().get(0).getSource());
assertEquals("db#chunk-0", lookup.getEvidenceBlocks().get(0).getEvidenceKey());
assertNotNull(lookup.getContextPack());
assertTrue(lookup.getContextPack().getPackedText().contains("mysql timeout runbook"));
assertEquals("FILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
@@ -89,14 +92,12 @@ class LookupKnowledgeToolTest {
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
VectorSearchService.SearchResult weak = searchResult(
"weak",
"weak.md",
"{\"_source\":\"weak.md\",\"title\":\"Weak\"}",
"{\"_source\":\"weak.md\",\"docId\":\"weak\",\"chunkIndex\":0,\"title\":\"Weak\"}",
"weak candidate",
1.4f);
VectorSearchService.SearchResult strong = searchResult(
"strong",
"strong.md",
"{\"_source\":\"strong.md\",\"title\":\"Strong\"}",
"{\"_source\":\"strong.md\",\"docId\":\"strong\",\"chunkIndex\":0,\"title\":\"Strong\"}",
"mysql timeout strong runbook",
0.2f);
@@ -122,8 +123,7 @@ class LookupKnowledgeToolTest {
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
VectorSearchService.SearchResult strong = searchResult(
"strong",
"strong.md",
"{\"_source\":\"strong.md\",\"title\":\"Strong\"}",
"{\"_source\":\"strong.md\",\"docId\":\"strong\",\"chunkIndex\":0,\"title\":\"Strong\"}",
"mysql timeout strong runbook",
0.2f);
@@ -164,8 +164,7 @@ class LookupKnowledgeToolTest {
void noL0HintUsesUnfilteredVectorSearch() {
VectorSearchService.SearchResult result = searchResult(
"vec-1",
"perf.md",
"{\"_source\":\"perf.md\",\"title\":\"Perf\"}",
"{\"_source\":\"perf.md\",\"docId\":\"perf\",\"chunkIndex\":0,\"title\":\"Perf\"}",
"performance tuning guide",
0.3f);
@@ -187,14 +186,12 @@ class LookupKnowledgeToolTest {
KnowledgeEntry entry = entry("payment.md", "Payment", "ERR_TIMEOUT", "payment");
VectorSearchService.SearchResult first = searchResult(
"a",
"a.md",
"{\"_source\":\"a.md\",\"title\":\"Generic\",\"category\":\"other\"}",
"{\"_source\":\"a.md\",\"docId\":\"a\",\"chunkIndex\":0,\"title\":\"Generic\",\"category\":\"other\"}",
"generic troubleshooting",
0.4f);
VectorSearchService.SearchResult second = searchResult(
"b",
"b.md",
"{\"_source\":\"b.md\",\"title\":\"Payment ERR_TIMEOUT\",\"breadcrumb\":\"Payment > Timeout\",\"category\":\"payment\"}",
"{\"_source\":\"b.md\",\"docId\":\"b\",\"chunkIndex\":0,\"title\":\"Payment ERR_TIMEOUT\",\"breadcrumb\":\"Payment > Timeout\",\"category\":\"payment\"}",
"payment ERR_TIMEOUT timeout diagnosis",
0.45f);
@@ -213,18 +210,16 @@ class LookupKnowledgeToolTest {
}
@Test
void deduplicatesEvidenceBlocksBySource() {
void keepsDistinctChunksFromSameSource() {
KnowledgeEntry entry = entry("shared.md", "Shared", "shared", "payment");
VectorSearchService.SearchResult first = searchResult(
"a",
"shared.md",
"{\"_source\":\"shared.md\",\"title\":\"Shared\"}",
"{\"_source\":\"shared.md\",\"docId\":\"shared\",\"chunkIndex\":0,\"title\":\"Shared\"}",
"shared content 1",
0.2f);
VectorSearchService.SearchResult second = searchResult(
"b",
"shared.md",
"{\"_source\":\"shared.md\",\"title\":\"Shared\"}",
"{\"_source\":\"shared.md\",\"docId\":\"shared\",\"chunkIndex\":1,\"title\":\"Shared\"}",
"shared content 2",
0.25f);
@@ -236,8 +231,11 @@ class LookupKnowledgeToolTest {
assertTrue(lookup.isFound());
assertEquals(2, lookup.getEvidenceCandidateCount());
assertEquals(1, lookup.getEvidenceBlockCount());
assertEquals("shared.md", lookup.getEvidenceBlocks().get(0).getSource());
assertEquals(2, lookup.getEvidenceBlockCount());
assertEquals("shared#chunk-0", lookup.getEvidenceBlocks().get(0).getEvidenceKey());
assertEquals("shared#chunk-1", lookup.getEvidenceBlocks().get(1).getEvidenceKey());
assertEquals("shared content 1", lookup.getEvidenceBlocks().get(0).getContent());
assertEquals("shared content 2", lookup.getEvidenceBlocks().get(1).getContent());
}
private KnowledgeEntry entry(String filePath, String title, String keyword, String category) {
@@ -251,7 +249,6 @@ class LookupKnowledgeToolTest {
}
private VectorSearchService.SearchResult searchResult(String id,
String source,
String metadata,
String content,
float score) {