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:
@@ -3,12 +3,15 @@ package com.superbiz.agent.service;
|
||||
import com.superbiz.agent.dto.RetrievalTrace;
|
||||
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchHit;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchMode;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchPort;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* L1 向量检索适配器。
|
||||
@@ -23,6 +26,9 @@ public class KnowledgeDocumentRetriever {
|
||||
|
||||
private final KnowledgeSearchPort knowledgeSearchPort;
|
||||
|
||||
@Value("${retrieval.search.mode:dense}")
|
||||
private String searchMode = "dense";
|
||||
|
||||
public KnowledgeDocumentRetriever(KnowledgeSearchPort knowledgeSearchPort) {
|
||||
this.knowledgeSearchPort = knowledgeSearchPort;
|
||||
}
|
||||
@@ -39,8 +45,11 @@ public class KnowledgeDocumentRetriever {
|
||||
public RetrievalAttemptResult retrieve(String attemptName, String query, String categoryFilter, int topK) {
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
KnowledgeSearchMode mode = "hybrid".equalsIgnoreCase(trim(searchMode))
|
||||
? KnowledgeSearchMode.HYBRID
|
||||
: KnowledgeSearchMode.DENSE;
|
||||
List<KnowledgeSearchHit> hits = knowledgeSearchPort.search(
|
||||
KnowledgeSearchRequest.dense(query, topK, categoryFilter));
|
||||
new KnowledgeSearchRequest(query, topK, categoryFilter, mode));
|
||||
List<RetrievedEvidenceCandidate> candidates = toCandidates(attemptName, hits);
|
||||
return new RetrievalAttemptResult(
|
||||
attempt(attemptName, query, categoryFilter, candidates.size(), null,
|
||||
@@ -110,6 +119,10 @@ public class KnowledgeDocumentRetriever {
|
||||
return hits.get(0).score();
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次检索 attempt 的结果包。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Sparse-lite lexical ranking over already recalled candidates.
|
||||
* Not a substitute for inverted-index BM25; expands ordering signal only.
|
||||
*/
|
||||
public final class LexicalRanker {
|
||||
|
||||
private LexicalRanker() {
|
||||
}
|
||||
|
||||
public static List<KnowledgeSearchHit> rank(String query, List<KnowledgeSearchHit> candidates) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> terms = tokenize(query);
|
||||
if (terms.isEmpty()) {
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
List<ScoredHit> scored = new ArrayList<>(candidates.size());
|
||||
for (KnowledgeSearchHit hit : candidates) {
|
||||
String haystack = (nullToEmpty(hit.title()) + " "
|
||||
+ nullToEmpty(hit.breadcrumb()) + " "
|
||||
+ nullToEmpty(hit.content())).toLowerCase(Locale.ROOT);
|
||||
int hits = 0;
|
||||
for (String term : terms) {
|
||||
if (haystack.contains(term)) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
double coverage = hits / (double) terms.size();
|
||||
scored.add(new ScoredHit(hit, coverage, hits));
|
||||
}
|
||||
scored.sort(Comparator
|
||||
.comparingDouble((ScoredHit s) -> s.coverage).reversed()
|
||||
.thenComparingInt((ScoredHit s) -> s.hits).reversed()
|
||||
.thenComparingInt(s -> s.hit.originalRank()));
|
||||
return scored.stream().map(s -> s.hit).toList();
|
||||
}
|
||||
|
||||
static Set<String> tokenize(String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
String normalized = query.toLowerCase(Locale.ROOT);
|
||||
String[] parts = normalized.split("[^\\p{IsAlphabetic}\\p{IsDigit}]+");
|
||||
Set<String> terms = new LinkedHashSet<>();
|
||||
for (String part : parts) {
|
||||
if (part == null) {
|
||||
continue;
|
||||
}
|
||||
String term = part.trim();
|
||||
if (term.length() >= 2) {
|
||||
terms.add(term);
|
||||
}
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private record ScoredHit(KnowledgeSearchHit hit, double coverage, int hits) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Reciprocal Rank Fusion helpers.
|
||||
*
|
||||
* <pre>
|
||||
* RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||
* </pre>
|
||||
*/
|
||||
public final class RrfFusion {
|
||||
|
||||
private RrfFusion() {
|
||||
}
|
||||
|
||||
public static <T> List<Scored<T>> fuse(List<RankedPath<T>> paths,
|
||||
int rrfK,
|
||||
Function<T, String> identityFn) {
|
||||
if (paths == null || paths.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
int k = Math.max(1, rrfK);
|
||||
Map<String, Acc<T>> acc = new LinkedHashMap<>();
|
||||
for (RankedPath<T> path : paths) {
|
||||
if (path == null || path.items() == null || path.items().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
double weight = path.weight() <= 0 ? 1.0 : path.weight();
|
||||
List<T> items = path.items();
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
T item = items.get(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String id = identityFn.apply(item);
|
||||
if (id == null || id.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
int rank = i + 1;
|
||||
double contrib = weight / (k + rank);
|
||||
Acc<T> bucket = acc.computeIfAbsent(id, ignored -> new Acc<>(item));
|
||||
bucket.score += contrib;
|
||||
bucket.ranks.put(path.name(), rank);
|
||||
// Prefer first-seen item payload; callers should put preferred path first if needed.
|
||||
}
|
||||
}
|
||||
List<Scored<T>> scored = new ArrayList<>(acc.size());
|
||||
for (Map.Entry<String, Acc<T>> entry : acc.entrySet()) {
|
||||
Acc<T> value = entry.getValue();
|
||||
scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks)));
|
||||
}
|
||||
scored.sort(Comparator
|
||||
.comparingDouble((Scored<T> s) -> s.rrfScore()).reversed()
|
||||
.thenComparing(Scored::identity));
|
||||
return scored;
|
||||
}
|
||||
|
||||
public record RankedPath<T>(String name, List<T> items, double weight) {
|
||||
public RankedPath {
|
||||
Objects.requireNonNull(name, "name");
|
||||
items = items == null ? List.of() : List.copyOf(items);
|
||||
}
|
||||
}
|
||||
|
||||
public record Scored<T>(String identity, T item, double rrfScore, Map<String, Integer> ranks) {
|
||||
}
|
||||
|
||||
private static final class Acc<T> {
|
||||
private final T item;
|
||||
private double score;
|
||||
private final Map<String, Integer> ranks = new HashMap<>();
|
||||
|
||||
private Acc(T item) {
|
||||
this.item = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
-6
@@ -2,15 +2,22 @@ package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Dense-only {@link KnowledgeSearchPort} backed by the existing vector search facade.
|
||||
* {@link KnowledgeSearchPort} backed by the existing vector search facade.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code DENSE}: single dense path (legacy behavior)</li>
|
||||
* <li>{@code HYBRID}: dense unfiltered + optional dense filtered + lexical rank, fused by RRF</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
|
||||
@@ -18,6 +25,21 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
|
||||
private final VectorSearchService vectorSearchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${retrieval.search.mode:dense}")
|
||||
private String configuredMode = "dense";
|
||||
|
||||
@Value("${retrieval.hybrid.rrf-k:60}")
|
||||
private int rrfK = 60;
|
||||
|
||||
@Value("${retrieval.hybrid.weight.dense-unfiltered:1.0}")
|
||||
private double weightDenseUnfiltered = 1.0;
|
||||
|
||||
@Value("${retrieval.hybrid.weight.dense-filtered:1.0}")
|
||||
private double weightDenseFiltered = 1.0;
|
||||
|
||||
@Value("${retrieval.hybrid.weight.lexical:1.0}")
|
||||
private double weightLexical = 1.0;
|
||||
|
||||
public VectorKnowledgeSearchAdapter(VectorSearchService vectorSearchService, ObjectMapper objectMapper) {
|
||||
this.vectorSearchService = vectorSearchService;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -25,13 +47,101 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
|
||||
|
||||
@Override
|
||||
public List<KnowledgeSearchHit> search(KnowledgeSearchRequest request) {
|
||||
if (request.mode() == KnowledgeSearchMode.HYBRID) {
|
||||
// Delivery 2 will implement true hybrid. Until then, fall back to dense.
|
||||
KnowledgeSearchMode mode = resolveMode(request.mode());
|
||||
if (mode == KnowledgeSearchMode.HYBRID) {
|
||||
return searchHybrid(request);
|
||||
}
|
||||
return searchDense(request.query(), request.topK(), request.categoryFilter());
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchHit> searchDense(String query, int topK, String categoryFilter) {
|
||||
List<VectorSearchService.SearchResult> results = vectorSearchService.searchSimilarDocuments(
|
||||
request.query(),
|
||||
request.topK(),
|
||||
request.categoryFilter());
|
||||
query, topK, categoryFilter);
|
||||
return toHits(results);
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchHit> searchHybrid(KnowledgeSearchRequest request) {
|
||||
String query = request.query();
|
||||
int topK = request.topK();
|
||||
String category = trimToNull(request.categoryFilter());
|
||||
|
||||
List<KnowledgeSearchHit> unfiltered = searchDense(query, topK, null);
|
||||
List<KnowledgeSearchHit> filtered = category == null
|
||||
? List.of()
|
||||
: searchDense(query, topK, category);
|
||||
|
||||
Map<String, KnowledgeSearchHit> unionByKey = new LinkedHashMap<>();
|
||||
for (KnowledgeSearchHit hit : unfiltered) {
|
||||
unionByKey.putIfAbsent(hit.evidenceKey(), hit);
|
||||
}
|
||||
for (KnowledgeSearchHit hit : filtered) {
|
||||
unionByKey.putIfAbsent(hit.evidenceKey(), hit);
|
||||
}
|
||||
List<KnowledgeSearchHit> union = new ArrayList<>(unionByKey.values());
|
||||
List<KnowledgeSearchHit> lexical = LexicalRanker.rank(query, union);
|
||||
|
||||
List<RrfFusion.RankedPath<KnowledgeSearchHit>> paths = new ArrayList<>();
|
||||
paths.add(new RrfFusion.RankedPath<>("dense_unfiltered", unfiltered, weightDenseUnfiltered));
|
||||
if (!filtered.isEmpty()) {
|
||||
paths.add(new RrfFusion.RankedPath<>("dense_filtered", filtered, weightDenseFiltered));
|
||||
}
|
||||
if (!lexical.isEmpty()) {
|
||||
paths.add(new RrfFusion.RankedPath<>("lexical", lexical, weightLexical));
|
||||
}
|
||||
|
||||
List<RrfFusion.Scored<KnowledgeSearchHit>> fused = RrfFusion.fuse(
|
||||
paths, rrfK, KnowledgeSearchHit::evidenceKey);
|
||||
|
||||
List<KnowledgeSearchHit> ordered = new ArrayList<>();
|
||||
int rank = 1;
|
||||
for (RrfFusion.Scored<KnowledgeSearchHit> scored : fused) {
|
||||
if (ordered.size() >= topK) {
|
||||
break;
|
||||
}
|
||||
KnowledgeSearchHit base = scored.item();
|
||||
Map<String, String> metadata = new LinkedHashMap<>(
|
||||
base.metadata() == null ? Map.of() : base.metadata());
|
||||
metadata.put("fusedScore", Double.toString(scored.rrfScore()));
|
||||
metadata.put("fusionRanks", scored.ranks().toString());
|
||||
metadata.put("fusionRank", Integer.toString(rank));
|
||||
ordered.add(new KnowledgeSearchHit(
|
||||
base.id(),
|
||||
base.content(),
|
||||
base.score(),
|
||||
base.rawScore(),
|
||||
base.scoreLabel(),
|
||||
base.metadataJson(),
|
||||
metadata,
|
||||
base.docId(),
|
||||
base.chunkIndex(),
|
||||
base.evidenceKey(),
|
||||
base.source(),
|
||||
base.title(),
|
||||
base.breadcrumb(),
|
||||
rank
|
||||
));
|
||||
rank++;
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private KnowledgeSearchMode resolveMode(KnowledgeSearchMode requestMode) {
|
||||
if (requestMode == KnowledgeSearchMode.HYBRID) {
|
||||
return KnowledgeSearchMode.HYBRID;
|
||||
}
|
||||
if (requestMode == KnowledgeSearchMode.DENSE) {
|
||||
// Allow global config to force hybrid even if caller passes DENSE default.
|
||||
String configured = configuredMode == null ? "dense" : configuredMode.trim().toLowerCase(Locale.ROOT);
|
||||
if ("hybrid".equals(configured)) {
|
||||
return KnowledgeSearchMode.HYBRID;
|
||||
}
|
||||
return KnowledgeSearchMode.DENSE;
|
||||
}
|
||||
String configured = configuredMode == null ? "dense" : configuredMode.trim().toLowerCase(Locale.ROOT);
|
||||
return "hybrid".equals(configured) ? KnowledgeSearchMode.HYBRID : KnowledgeSearchMode.DENSE;
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchHit> toHits(List<VectorSearchService.SearchResult> results) {
|
||||
if (results == null || results.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -91,4 +201,11 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user