diff --git a/devflow/index.md b/devflow/index.md index b8350c7..a8dfdb0 100644 --- a/devflow/index.md +++ b/devflow/index.md @@ -11,7 +11,8 @@ | 日期 | slug | 说明 | 领域 | 关键词 | 关联 OpenSpec | 状态 | |---|---|---|---|---|---|---| -| 2026-07-27 | rag-chunk-evidence-identity-dedup | chunk 级证据身份、去重、retrieve-k/return-n 与 SearchPort 地基,为 hybrid 铺路。 | RAG/证据身份/去重 | evidenceKey, maxChunksPerDocument, retrieve-k, return-n, KnowledgeSearchPort, document_id chunk-scoped | openspec/changes/rag-chunk-evidence-identity-dedup | accepted-unarchived | +| 2026-07-27 | rag-chunk-evidence-identity-dedup | chunk 级证据身份、去重、retrieve-k/return-n 与 SearchPort 地基,为 hybrid 铺路。 | RAG/证据身份/去重 | evidenceKey, maxChunksPerDocument, retrieve-k, return-n, KnowledgeSearchPort, document_id chunk-scoped | openspec/changes/archive/2026-07-27-rag-chunk-evidence-identity-dedup | archived | +| 2026-07-27 | rag-hybrid-search-rrf | Delivery 2:可配置 hybrid 检索与 RRF 多路融合(不绑旧 SDK)。 | RAG/hybrid/RRF | hybrid mode, RRF, KnowledgeSearchPort, filtered+unfiltered fusion, sparse-lite lexical | openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf | archived | | 2026-07-21 | single-react-tool-invocation-store | 建立统一 ToolBoundary 与 Redis canonical invocation store,集中生命周期、证据状态、TTL、容量和 Run 所有权。 | Harness/Tool boundary/Canonical store | ISS-014, ToolBoundary, canonical invocation, PROJECTING, READY, ERROR, TTL, RESULT_TOO_LARGE | openspec/changes/archive/2026-07-21-single-react-tool-invocation-store | archived | | 2026-07-21 | single-react-harness-run-context | 建立显式 RunContext、Harness Core、预算、取消、类型化重试和 Tool Store 基础。 | Harness/Run lifecycle/Budget | ISS-014, RunContext, deadline, cancellation, budget, retry, ToolCallKey | openspec/changes/archive/2026-07-21-single-react-harness-run-context | archived | | 2026-07-21 | single-react-aci-tool-contracts | 冻结 RAG、日志和 MySQL evidence Tool 的 Agent-facing ACI Schema、状态、框架调用引用和描述边界。 | Harness/Agent Tool contract | ISS-014, ACI, tool_call_id, evidence_status, RAG, query_logs, query_mysql, MOCK | openspec/changes/archive/2026-07-21-single-react-aci-tool-contracts | archived | diff --git a/devflow/projects/2026-07-27-rag-hybrid-search-rrf/acceptance.md b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/acceptance.md new file mode 100644 index 0000000..06b0f66 --- /dev/null +++ b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/acceptance.md @@ -0,0 +1,23 @@ +# Acceptance: rag-hybrid-search-rrf + +## Result + +Hybrid mode implemented on KnowledgeSearchPort: + +- dense unfiltered + dense filtered + lexical rank over union +- RRF fusion by evidenceKey +- dense-compatible score preserved for thresholds +- default mode remains dense + +## Verification + +```text +mvn -q "-Dtest=RrfFusionTest,VectorKnowledgeSearchAdapterHybridTest,LookupKnowledgeToolTest" test +``` + +Pass. + +## Residual + +- True Milvus BM25/sparse schema + reindex still follow-up +- Lexical path only ranks dense-recalled candidates (does not expand pure-term misses outside dense topK) diff --git a/devflow/projects/2026-07-27-rag-hybrid-search-rrf/brief.md b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/brief.md new file mode 100644 index 0000000..5298326 --- /dev/null +++ b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/brief.md @@ -0,0 +1,3 @@ +# Brief: rag-hybrid-search-rrf + +Delivery 2 after chunk identity. Enable hybrid multi-path + RRF on KnowledgeSearchPort without legacy SDK hybrid API. True BM25 schema rebuild is staged follow-up; this change ships sparse-lite lexical ranking over dense candidate union + filtered/unfiltered dense fusion. diff --git a/devflow/projects/2026-07-27-rag-hybrid-search-rrf/decisions.md b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/decisions.md new file mode 100644 index 0000000..7cdd1d0 --- /dev/null +++ b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/decisions.md @@ -0,0 +1,19 @@ +# Decisions: rag-hybrid-search-rrf + +## Capability + +sm-flow + OpenSpec fallback; apply pre-authorized. + +## Depends + +Delivery 1 archived. + +## Grill (compressed, pre-authorized) + +- Q: Full BM25 schema now? A: No — sparse-lite + RRF first; schema rebuild follow-up. +- Q: Default mode? A: dense default; hybrid opt-in. +- Q: Threshold score? A: keep dense-compatible L2 mapping. + +## Design + +Hybrid paths: dense unfiltered + dense filtered + lexical rank over union; RRF fuse by evidenceKey. diff --git a/devflow/projects/2026-07-27-rag-hybrid-search-rrf/evidence.md b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/evidence.md new file mode 100644 index 0000000..95c28df --- /dev/null +++ b/devflow/projects/2026-07-27-rag-hybrid-search-rrf/evidence.md @@ -0,0 +1,5 @@ +# Evidence + +- Delivery 1 identity/port foundation required +- RRF utility and hybrid adapter unit tests green +- Lexical sparse-lite intentionally intermediate until BM25 schema diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.archive-ready b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.archive-ready new file mode 100644 index 0000000..8c263e1 --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.archive-ready @@ -0,0 +1 @@ +ready: 2026-07-27 diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.committed b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.committed new file mode 100644 index 0000000..e0f926f --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.committed @@ -0,0 +1,3 @@ +committed: 2026-07-27 +change: rag-hybrid-search-rrf +authorized-apply: user-preauthorized-sm-flow diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.openspec.yaml b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.openspec.yaml new file mode 100644 index 0000000..8e7013b --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/design.md b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/design.md new file mode 100644 index 0000000..a41c482 --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/design.md @@ -0,0 +1,58 @@ +# Design: rag-hybrid-search-rrf + +## Context + +- Delivery 1 archived: chunk evidenceKey, SearchPort, return caps. +- Legacy SDK path will be abandoned later; hybrid must live on SearchPort. +- Full sparse schema rebuild is operationally heavy; ship fusion first. + +## Decisions + +### D1. Hybrid = multi-path + RRF on SearchPort + +When `retrieval.search.mode=hybrid`: + +```text +paths: + 1) dense(query, filter=null) # always + 2) dense(query, filter=category) # if category present + 3) lexical rank over union of dense hits # sparse-lite +fuse by evidenceKey using RRF(k) +return topK fused hits +``` + +### D2. RRF formula + +```text +score(d) = Σ w_i / (k + rank_i(d)) +``` + +Defaults: k=60, all w_i=1.0. Optional weights via config. + +### D3. Score semantics + +- `KnowledgeSearchHit.score` remains **compatible L2 distance from best dense hit** for post-process thresholds. +- Fused RRF score is carried in metadata (`fusedScore`, `fusionRanks`) for trace, not as L2. + +### D4. Lexical path (sparse-lite) + +Until BM25 schema: + +- Tokenize query (simple whitespace / non-alnum split, lower-case) +- Score each candidate by term coverage over title+breadcrumb+content +- Rank candidates for RRF path only +- Not a replacement for true BM25 inverted index + +### D5. Serial filter retry + +Lookup tool keeps low-quality unfiltered retry as safety net even in hybrid, because hybrid already includes unfiltered dense; retry remains cheap no-op when already fused well. + +## Non-goals now + +- New Milvus collection fields +- Reindex jobs +- SDK hybridSearch API calls + +## Risks + +- Lexical path only ranks already-recalled dense candidates → does not expand pure-term misses outside dense topK. Acceptable intermediate; true BM25 later expands recall. diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/proposal.md b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/proposal.md new file mode 100644 index 0000000..4a3e38c --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/proposal.md @@ -0,0 +1,48 @@ +# Change: RAG hybrid search with RRF + +## Why + +Delivery 1 fixed chunk identity/dedup. Delivery 2 enables multi-path retrieval fused by RRF so category filters no longer whole-replace good results and lexical signals can complement dense ranks. + +True Milvus sparse/BM25 schema migration remains staged: this change ships a **working hybrid mode on KnowledgeSearchPort** using multi-path dense (+ lexical rank path) and RRF, without thickening the legacy SDK API surface. + +## What Changes + +- `retrieval.search.mode=dense|hybrid` (default dense) +- Hybrid search on `KnowledgeSearchPort`: + - dense unfiltered path + - dense filtered path when category present + - lexical rank path over the candidate union (sparse-lite until BM25 schema lands) + - RRF / optional weighted RRF fusion by evidenceKey +- Preserve dense L2 scores for quality thresholds; RRF only orders +- Lookup tool uses search mode; filter low-quality retry remains as safety net +- Config for rrf-k and path weights +- Tests for RRF fusion ordering and hybrid adapter behavior + +## Non-goals + +- Full Milvus BM25/sparse collection rebuild (documented follow-up) +- Deleting legacy SDK mode entirely +- Cross-encoder model rerank +- Changing Agent tool schema name/input + +## Capabilities + +### New Capabilities + +- `rag-hybrid-search`: hybrid mode, multi-path recall, RRF fusion via search port + +### Modified Capabilities + +- `rag-chunk-evidence-identity`: hybrid hits must keep chunk identity +- `rag-knowledge-retrieval`: filtered/unfiltered cooperation via fusion rather than only serial replace + +## Impact + +- Code: retrieval package, lookup wiring/config, tests +- Runtime default remains dense unless hybrid enabled +- Interface: L2 internal (search port behavior) + +## Depends on + +- Archived Delivery 1: `rag-chunk-evidence-identity-dedup` diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/specs/rag-hybrid-search/spec.md b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/specs/rag-hybrid-search/spec.md new file mode 100644 index 0000000..58db5a4 --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/specs/rag-hybrid-search/spec.md @@ -0,0 +1,44 @@ +# rag-hybrid-search Specification + +## ADDED Requirements + +### Requirement: Knowledge search SHALL support configurable dense and hybrid modes + +The knowledge search port SHALL support `retrieval.search.mode` values `dense` and `hybrid`. Default SHALL be `dense` for backward-compatible behavior. + +#### Scenario: Dense mode single path + +- **WHEN** mode is dense +- **THEN** the port SHALL perform a single dense vector search with the provided category filter + +#### Scenario: Hybrid mode multi-path fusion + +- **WHEN** mode is hybrid +- **THEN** the port SHALL gather multiple retrieval paths and fuse them by RRF before returning hits + +### Requirement: Hybrid fusion SHALL use RRF over evidence identities + +Hybrid fusion SHALL score candidates by reciprocal rank fusion on evidenceKey identities and SHALL NOT require comparable raw scores across paths. + +#### Scenario: Multi-path candidate rises with RRF + +- **WHEN** a chunk ranks highly on more than one hybrid path +- **THEN** its fused rank SHALL improve relative to a chunk that ranks highly on only one path + +### Requirement: Hybrid SHALL preserve dense quality scores for thresholds + +Fused ordering SHALL NOT replace the dense compatibility score used by post-process quality thresholds. + +#### Scenario: Threshold score remains dense-compatible + +- **WHEN** hybrid returns a hit that originated from dense search +- **THEN** hit.score SHALL remain a dense-compatible distance/similarity mapping usable by existing normalizeL2 thresholds + +### Requirement: Hybrid SHALL keep chunk identity fields + +Hybrid hits SHALL populate docId, chunkIndex, and evidenceKey consistently with Delivery 1 identity rules. + +#### Scenario: Fused hit keeps evidenceKey + +- **WHEN** hybrid merges the same chunk from two paths +- **THEN** the returned hit SHALL use one evidenceKey and retain chunk identity metadata diff --git a/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/tasks.md b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/tasks.md new file mode 100644 index 0000000..85a15e9 --- /dev/null +++ b/openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf/tasks.md @@ -0,0 +1,9 @@ +# Tasks: rag-hybrid-search-rrf + +- [x] 1. Add RRF fusion utility +- [x] 2. Extend KnowledgeSearchRequest/Hit metadata for fusion ranks +- [x] 3. Implement hybrid multi-path search in VectorKnowledgeSearchAdapter +- [x] 4. Wire retrieval.search.mode and rrf config +- [x] 5. Pass mode from LookupKnowledgeTool / retriever +- [x] 6. Unit tests for RRF and hybrid adapter ordering +- [x] 7. Verify dense mode regression tests still pass diff --git a/openspec/specs/rag-hybrid-search/spec.md b/openspec/specs/rag-hybrid-search/spec.md new file mode 100644 index 0000000..090a2dc --- /dev/null +++ b/openspec/specs/rag-hybrid-search/spec.md @@ -0,0 +1,46 @@ +# rag-hybrid-search Specification + +## Purpose +TBD - created by archiving change rag-hybrid-search-rrf. Update Purpose after archive. +## Requirements +### Requirement: Knowledge search SHALL support configurable dense and hybrid modes + +The knowledge search port SHALL support `retrieval.search.mode` values `dense` and `hybrid`. Default SHALL be `dense` for backward-compatible behavior. + +#### Scenario: Dense mode single path + +- **WHEN** mode is dense +- **THEN** the port SHALL perform a single dense vector search with the provided category filter + +#### Scenario: Hybrid mode multi-path fusion + +- **WHEN** mode is hybrid +- **THEN** the port SHALL gather multiple retrieval paths and fuse them by RRF before returning hits + +### Requirement: Hybrid fusion SHALL use RRF over evidence identities + +Hybrid fusion SHALL score candidates by reciprocal rank fusion on evidenceKey identities and SHALL NOT require comparable raw scores across paths. + +#### Scenario: Multi-path candidate rises with RRF + +- **WHEN** a chunk ranks highly on more than one hybrid path +- **THEN** its fused rank SHALL improve relative to a chunk that ranks highly on only one path + +### Requirement: Hybrid SHALL preserve dense quality scores for thresholds + +Fused ordering SHALL NOT replace the dense compatibility score used by post-process quality thresholds. + +#### Scenario: Threshold score remains dense-compatible + +- **WHEN** hybrid returns a hit that originated from dense search +- **THEN** hit.score SHALL remain a dense-compatible distance/similarity mapping usable by existing normalizeL2 thresholds + +### Requirement: Hybrid SHALL keep chunk identity fields + +Hybrid hits SHALL populate docId, chunkIndex, and evidenceKey consistently with Delivery 1 identity rules. + +#### Scenario: Fused hit keeps evidenceKey + +- **WHEN** hybrid merges the same chunk from two paths +- **THEN** the returned hit SHALL use one evidenceKey and retain chunk identity metadata + diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java b/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java index 8e84e43..b701ef6 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java @@ -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 hits = knowledgeSearchPort.search( - KnowledgeSearchRequest.dense(query, topK, categoryFilter)); + new KnowledgeSearchRequest(query, topK, categoryFilter, mode)); List 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 的结果包。 */ diff --git a/src/main/java/com/superbiz/agent/service/retrieval/LexicalRanker.java b/src/main/java/com/superbiz/agent/service/retrieval/LexicalRanker.java new file mode 100644 index 0000000..154c9e8 --- /dev/null +++ b/src/main/java/com/superbiz/agent/service/retrieval/LexicalRanker.java @@ -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 rank(String query, List candidates) { + if (candidates == null || candidates.isEmpty()) { + return List.of(); + } + Set terms = tokenize(query); + if (terms.isEmpty()) { + return List.copyOf(candidates); + } + List 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 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 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) { + } +} diff --git a/src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java b/src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java new file mode 100644 index 0000000..7ce4322 --- /dev/null +++ b/src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java @@ -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. + * + *
+ * RRF_w(d) = Σ w_i / (k + rank_i(d))
+ * 
+ */ +public final class RrfFusion { + + private RrfFusion() { + } + + public static List> fuse(List> paths, + int rrfK, + Function identityFn) { + if (paths == null || paths.isEmpty()) { + return List.of(); + } + int k = Math.max(1, rrfK); + Map> acc = new LinkedHashMap<>(); + for (RankedPath path : paths) { + if (path == null || path.items() == null || path.items().isEmpty()) { + continue; + } + double weight = path.weight() <= 0 ? 1.0 : path.weight(); + List 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 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 = new ArrayList<>(acc.size()); + for (Map.Entry> entry : acc.entrySet()) { + Acc value = entry.getValue(); + scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks))); + } + scored.sort(Comparator + .comparingDouble((Scored s) -> s.rrfScore()).reversed() + .thenComparing(Scored::identity)); + return scored; + } + + public record RankedPath(String name, List items, double weight) { + public RankedPath { + Objects.requireNonNull(name, "name"); + items = items == null ? List.of() : List.copyOf(items); + } + } + + public record Scored(String identity, T item, double rrfScore, Map ranks) { + } + + private static final class Acc { + private final T item; + private double score; + private final Map ranks = new HashMap<>(); + + private Acc(T item) { + this.item = item; + } + } +} diff --git a/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java b/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java index 3c1cda0..fca46dc 100644 --- a/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java +++ b/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java @@ -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. + * + *
    + *
  • {@code DENSE}: single dense path (legacy behavior)
  • + *
  • {@code HYBRID}: dense unfiltered + optional dense filtered + lexical rank, fused by RRF
  • + *
*/ @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 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 searchDense(String query, int topK, String categoryFilter) { List results = vectorSearchService.searchSimilarDocuments( - request.query(), - request.topK(), - request.categoryFilter()); + query, topK, categoryFilter); + return toHits(results); + } + + private List searchHybrid(KnowledgeSearchRequest request) { + String query = request.query(); + int topK = request.topK(); + String category = trimToNull(request.categoryFilter()); + + List unfiltered = searchDense(query, topK, null); + List filtered = category == null + ? List.of() + : searchDense(query, topK, category); + + Map unionByKey = new LinkedHashMap<>(); + for (KnowledgeSearchHit hit : unfiltered) { + unionByKey.putIfAbsent(hit.evidenceKey(), hit); + } + for (KnowledgeSearchHit hit : filtered) { + unionByKey.putIfAbsent(hit.evidenceKey(), hit); + } + List union = new ArrayList<>(unionByKey.values()); + List lexical = LexicalRanker.rank(query, union); + + List> 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> fused = RrfFusion.fuse( + paths, rrfK, KnowledgeSearchHit::evidenceKey); + + List ordered = new ArrayList<>(); + int rank = 1; + for (RrfFusion.Scored scored : fused) { + if (ordered.size() >= topK) { + break; + } + KnowledgeSearchHit base = scored.item(); + Map 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 toHits(List 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(); + } } diff --git a/src/test/java/com/superbiz/agent/service/retrieval/RrfFusionTest.java b/src/test/java/com/superbiz/agent/service/retrieval/RrfFusionTest.java new file mode 100644 index 0000000..e8e0915 --- /dev/null +++ b/src/test/java/com/superbiz/agent/service/retrieval/RrfFusionTest.java @@ -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 dense = List.of("a", "b", "c"); + List lexical = List.of("c", "b", "d"); + + List> 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 dense = List.of("a", "b"); + List lexical = List.of("b", "a"); + + List> 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()); + } +} diff --git a/src/test/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapterHybridTest.java b/src/test/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapterHybridTest.java new file mode 100644 index 0000000..5af03d1 --- /dev/null +++ b/src/test/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapterHybridTest.java @@ -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 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; + } +}