feat(rag): hybrid multi-path search with RRF fusion
Add configurable hybrid mode on KnowledgeSearchPort that fuses dense unfiltered, dense filtered, and lexical ranks via RRF while preserving dense-compatible threshold scores. Archives Delivery 2 OpenSpec change.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RrfFusionTest {
|
||||
|
||||
@Test
|
||||
void multiPathAgreementOutranksSinglePathHead() {
|
||||
List<String> dense = List.of("a", "b", "c");
|
||||
List<String> lexical = List.of("c", "b", "d");
|
||||
|
||||
List<RrfFusion.Scored<String>> fused = RrfFusion.fuse(
|
||||
List.of(
|
||||
new RrfFusion.RankedPath<>("dense", dense, 1.0),
|
||||
new RrfFusion.RankedPath<>("lexical", lexical, 1.0)
|
||||
),
|
||||
60,
|
||||
s -> s
|
||||
);
|
||||
|
||||
// c: dense#3 + lexical#1 ; b: dense#2 + lexical#2 ; a: dense#1 only
|
||||
// With k=60, c edges b slightly, and both beat single-path a.
|
||||
assertEquals("c", fused.get(0).identity());
|
||||
assertEquals("b", fused.get(1).identity());
|
||||
assertEquals("a", fused.get(2).identity());
|
||||
assertTrue(fused.get(0).rrfScore() > fused.get(2).rrfScore());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathWeightCanElevateSecondaryPath() {
|
||||
List<String> dense = List.of("a", "b");
|
||||
List<String> lexical = List.of("b", "a");
|
||||
|
||||
List<RrfFusion.Scored<String>> fused = RrfFusion.fuse(
|
||||
List.of(
|
||||
new RrfFusion.RankedPath<>("dense", dense, 1.0),
|
||||
new RrfFusion.RankedPath<>("lexical", lexical, 2.0)
|
||||
),
|
||||
60,
|
||||
s -> s
|
||||
);
|
||||
|
||||
assertEquals("b", fused.get(0).identity());
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class VectorKnowledgeSearchAdapterHybridTest {
|
||||
|
||||
@Test
|
||||
void hybridFusesFilteredAndUnfilteredDensePaths() {
|
||||
VectorSearchService vectorSearchService = mock(VectorSearchService.class);
|
||||
when(vectorSearchService.searchSimilarDocuments("pool timeout", 3, null)).thenReturn(List.of(
|
||||
result("u1", "{\"_source\":\"a.md\",\"docId\":\"a\",\"chunkIndex\":0,\"title\":\"generic\"}", "generic pool", 0.4f),
|
||||
result("u2", "{\"_source\":\"b.md\",\"docId\":\"b\",\"chunkIndex\":0,\"title\":\"other\"}", "other", 0.5f)
|
||||
));
|
||||
when(vectorSearchService.searchSimilarDocuments("pool timeout", 3, "mysql")).thenReturn(List.of(
|
||||
result("f1", "{\"_source\":\"c.md\",\"docId\":\"c\",\"chunkIndex\":0,\"title\":\"mysql pool timeout\"}", "mysql pool timeout runbook", 0.35f)
|
||||
));
|
||||
|
||||
VectorKnowledgeSearchAdapter adapter = new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper());
|
||||
ReflectionTestUtils.setField(adapter, "configuredMode", "hybrid");
|
||||
ReflectionTestUtils.setField(adapter, "rrfK", 60);
|
||||
ReflectionTestUtils.setField(adapter, "weightDenseUnfiltered", 1.0);
|
||||
ReflectionTestUtils.setField(adapter, "weightDenseFiltered", 1.0);
|
||||
ReflectionTestUtils.setField(adapter, "weightLexical", 1.0);
|
||||
|
||||
List<KnowledgeSearchHit> hits = adapter.search(
|
||||
new KnowledgeSearchRequest("pool timeout", 3, "mysql", KnowledgeSearchMode.HYBRID));
|
||||
|
||||
assertEquals(3, hits.size());
|
||||
assertTrue(hits.stream().anyMatch(hit -> "c#chunk-0".equals(hit.evidenceKey())));
|
||||
assertTrue(hits.get(0).metadata().containsKey("fusedScore"));
|
||||
}
|
||||
|
||||
private static VectorSearchService.SearchResult result(String id, String metadata, String content, float score) {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId(id);
|
||||
result.setMetadata(metadata);
|
||||
result.setContent(content);
|
||||
result.setScore(score);
|
||||
result.setRawScore((double) score);
|
||||
result.setScoreLabel("l2_distance");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user