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:
+28
-7
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user