Skip to main content

lc_vector_stores/
hybrid_native.rs

1// lc-vector-stores/src/hybrid_native.rs
2//! Engine-native hybrid search (0.21.0 S4.3).
3//!
4//! 2026 direction: hybrid fusion (RRF / DBSF) executed **inside the vector
5//! engine** — one round trip, no client-side merge. Qdrant's Query API
6//! (≥ 1.10) fuses multiple `prefetch` branches server-side. Engines without
7//! the capability keep using the universal client-side fallback
8//! (`lc_rag::UnifiedHybridIndex` / `reciprocal_rank_fusion`).
9//!
10//! Capability is declared per store type via [`NativeHybridSearch`]. The
11//! qdrant-client crate (1.18) speaks the Query API, so the request-shape
12//! contract is enforced by the typed builder; result mapping is unit-tested
13//! against constructed `ScoredPoint`s, and the end-to-end path has an
14//! `#[ignore]`d integration test for environments with a live server.
15
16use crate::{MetadataFilter, SearchResult, VectorStoreError};
17use async_trait::async_trait;
18
19/// Server-side fusion method.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FusionMethod {
22    /// Reciprocal Rank Fusion (default parameters, server-side).
23    Rrf,
24    /// Distribution-Based Score Fusion.
25    Dbsf,
26}
27
28impl From<FusionMethod> for qdrant_client::qdrant::Fusion {
29    fn from(method: FusionMethod) -> Self {
30        match method {
31            FusionMethod::Rrf => qdrant_client::qdrant::Fusion::Rrf,
32            FusionMethod::Dbsf => qdrant_client::qdrant::Fusion::Dbsf,
33        }
34    }
35}
36
37/// Native hybrid query: multiple dense query branches fused by the engine.
38#[derive(Debug, Clone)]
39pub struct NativeHybridQuery {
40    /// One query vector per branch (e.g. dense + a second retriever view).
41    /// At least two branches are required — a single branch has nothing to fuse.
42    pub query_vectors: Vec<Vec<f32>>,
43    /// Final result count.
44    pub limit: usize,
45    /// Per-branch candidate limit (defaults to `limit * 2`, min 10).
46    pub prefetch_limit: Option<usize>,
47    /// Fusion method (default RRF).
48    pub fusion: FusionMethod,
49    /// Optional payload filter applied to every branch.
50    pub filter: Option<MetadataFilter>,
51}
52
53impl NativeHybridQuery {
54    /// Creates a query from ≥ 2 branches with defaults (RRF).
55    pub fn new(query_vectors: Vec<Vec<f32>>, limit: usize) -> Result<Self, VectorStoreError> {
56        if query_vectors.len() < 2 {
57            return Err(VectorStoreError::ConfigError(format!(
58                "native hybrid fusion requires at least 2 query branches, got {}",
59                query_vectors.len()
60            )));
61        }
62        let dim = query_vectors[0].len();
63        if query_vectors.iter().any(|v| v.len() != dim) {
64            return Err(VectorStoreError::ConfigError(
65                "native hybrid branches must share one vector dimension".to_string(),
66            ));
67        }
68        Ok(Self {
69            query_vectors,
70            limit,
71            prefetch_limit: None,
72            fusion: FusionMethod::Rrf,
73            filter: None,
74        })
75    }
76
77    /// Sets the per-branch prefetch limit.
78    pub fn with_prefetch_limit(mut self, prefetch_limit: usize) -> Self {
79        self.prefetch_limit = Some(prefetch_limit);
80        self
81    }
82
83    /// Sets the fusion method.
84    pub fn with_fusion(mut self, fusion: FusionMethod) -> Self {
85        self.fusion = fusion;
86        self
87    }
88
89    /// Effective per-branch prefetch limit.
90    pub fn effective_prefetch_limit(&self) -> u64 {
91        self.prefetch_limit.unwrap_or((self.limit * 2).max(10)) as u64
92    }
93}
94
95/// Capability trait: engine-native hybrid fusion.
96///
97/// Default implementation reports "not supported" so existing store types are
98/// opt-in by construction. Callers check
99/// [`NativeHybridSearch::supports_native_hybrid`] before issuing a query; a
100/// mismatch returns a config error instead of a silent client fallback.
101/// The Qdrant implementation lives on the re-exported
102/// [`crate::QdrantVectorStore`] type (its backing module stays crate-private,
103/// so linking `crate::qdrant` directly trips rustdoc::private_intra_doc_links).
104#[async_trait]
105pub trait NativeHybridSearch: Send + Sync {
106    /// Whether this store can fuse hybrid branches server-side.
107    fn supports_native_hybrid(&self) -> bool {
108        false
109    }
110
111    /// Runs the engine-native fusion query.
112    async fn native_hybrid_search(
113        &self,
114        query: &NativeHybridQuery,
115    ) -> Result<Vec<SearchResult>, VectorStoreError> {
116        let _ = query;
117        Err(VectorStoreError::ConfigError(
118            "this vector store does not support engine-native hybrid fusion; \
119             use the client-side RRF fallback (lc_rag::UnifiedHybridIndex)"
120                .to_string(),
121        ))
122    }
123}
124
125/// Test fixture shared across the crate's test modules: a `ScoredPoint` with
126/// the payload conventions the mapping relies on.
127#[cfg(test)]
128pub(crate) mod fixtures {
129    use qdrant_client::qdrant::{PointId, ScoredPoint};
130    use std::collections::HashMap;
131
132    pub(crate) fn scored_point(id: u64, score: f32, content: &str, source: &str) -> ScoredPoint {
133        let mut payload = HashMap::new();
134        payload.insert(
135            "content".to_string(),
136            qdrant_client::qdrant::Value::from(content),
137        );
138        payload.insert(
139            "source".to_string(),
140            qdrant_client::qdrant::Value::from(source),
141        );
142        ScoredPoint {
143            id: Some(PointId::from(id.to_string())),
144            score,
145            payload,
146            ..Default::default()
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::hybrid_native::fixtures::scored_point;
155
156    /// Mapping follows the same payload conventions as plain search:
157    /// `content` → document content, `doc_id` → id, other string fields → metadata.
158    #[test]
159    fn scored_point_maps_to_search_result() {
160        let result =
161            crate::qdrant::scored_point_to_result(scored_point(1, 0.98, "rust doc", "docs"));
162        assert_eq!(result.document.content, "rust doc");
163        assert_eq!(result.document.id, None, "no doc_id payload → no id");
164        assert_eq!(result.score, 0.98);
165        assert_eq!(
166            result
167                .document
168                .metadata
169                .get("source")
170                .and_then(|v| v.as_str()),
171            Some("docs")
172        );
173        assert!(
174            !result.document.metadata.contains_key("content"),
175            "content is not duplicated into metadata"
176        );
177    }
178
179    /// `doc_id` payload becomes the document id.
180    #[test]
181    fn doc_id_payload_becomes_document_id() {
182        let mut point = scored_point(2, 0.9, "hello", "docs");
183        point.payload.insert(
184            "doc_id".to_string(),
185            qdrant_client::qdrant::Value::from("doc-42"),
186        );
187        let result = crate::qdrant::scored_point_to_result(point);
188        assert_eq!(result.document.id.as_deref(), Some("doc-42"));
189    }
190
191    #[test]
192    fn fusion_maps_to_proto() {
193        assert_eq!(
194            qdrant_client::qdrant::Fusion::from(FusionMethod::Rrf),
195            qdrant_client::qdrant::Fusion::Rrf
196        );
197        assert_eq!(
198            qdrant_client::qdrant::Fusion::from(FusionMethod::Dbsf),
199            qdrant_client::qdrant::Fusion::Dbsf
200        );
201    }
202
203    /// Query validation: ≥ 2 branches, single shared dimension.
204    #[test]
205    fn query_validates_branches() {
206        assert!(
207            NativeHybridQuery::new(vec![vec![1.0]], 5).is_err(),
208            "1 branch"
209        );
210        assert!(
211            NativeHybridQuery::new(vec![vec![1.0, 0.0], vec![1.0, 0.0, 0.0]], 5).is_err(),
212            "mixed dimensions"
213        );
214        let ok = NativeHybridQuery::new(vec![vec![1.0, 0.0], vec![0.0, 1.0]], 5).unwrap();
215        assert_eq!(ok.limit, 5);
216        assert_eq!(ok.fusion, FusionMethod::Rrf, "default fusion");
217    }
218
219    /// Prefetch limit defaults to `max(limit * 2, 10)`.
220    #[test]
221    fn prefetch_limit_defaults() {
222        let small = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 3).unwrap();
223        assert_eq!(small.effective_prefetch_limit(), 10, "floor applies");
224        let large = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 20).unwrap();
225        assert_eq!(large.effective_prefetch_limit(), 40, "limit * 2");
226        let explicit = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 20)
227            .unwrap()
228            .with_prefetch_limit(7);
229        assert_eq!(explicit.effective_prefetch_limit(), 7);
230    }
231
232    /// Default capability: stores that do not implement the capability report
233    /// false and return an explicit config error (never a silent fallback).
234    #[tokio::test]
235    async fn default_capability_is_unsupported() {
236        struct Noop;
237        #[async_trait]
238        impl NativeHybridSearch for Noop {}
239        assert!(!Noop.supports_native_hybrid());
240        let query = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 5).unwrap();
241        let err = Noop.native_hybrid_search(&query).await.unwrap_err();
242        assert!(
243            err.to_string().contains("client-side RRF fallback"),
244            "error must point to the fallback: {err}"
245        );
246    }
247}