1use crate::{MetadataFilter, SearchResult, VectorStoreError};
17use async_trait::async_trait;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FusionMethod {
22 Rrf,
24 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#[derive(Debug, Clone)]
39pub struct NativeHybridQuery {
40 pub query_vectors: Vec<Vec<f32>>,
43 pub limit: usize,
45 pub prefetch_limit: Option<usize>,
47 pub fusion: FusionMethod,
49 pub filter: Option<MetadataFilter>,
51}
52
53impl NativeHybridQuery {
54 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 pub fn with_prefetch_limit(mut self, prefetch_limit: usize) -> Self {
79 self.prefetch_limit = Some(prefetch_limit);
80 self
81 }
82
83 pub fn with_fusion(mut self, fusion: FusionMethod) -> Self {
85 self.fusion = fusion;
86 self
87 }
88
89 pub fn effective_prefetch_limit(&self) -> u64 {
91 self.prefetch_limit.unwrap_or((self.limit * 2).max(10)) as u64
92 }
93}
94
95#[async_trait]
104pub trait NativeHybridSearch: Send + Sync {
105 fn supports_native_hybrid(&self) -> bool {
107 false
108 }
109
110 async fn native_hybrid_search(
112 &self,
113 query: &NativeHybridQuery,
114 ) -> Result<Vec<SearchResult>, VectorStoreError> {
115 let _ = query;
116 Err(VectorStoreError::ConfigError(
117 "this vector store does not support engine-native hybrid fusion; \
118 use the client-side RRF fallback (lc_rag::UnifiedHybridIndex)"
119 .to_string(),
120 ))
121 }
122}
123
124#[cfg(test)]
127pub(crate) mod fixtures {
128 use qdrant_client::qdrant::{PointId, ScoredPoint};
129 use std::collections::HashMap;
130
131 pub(crate) fn scored_point(id: u64, score: f32, content: &str, source: &str) -> ScoredPoint {
132 let mut point = ScoredPoint::default();
133 point.id = Some(PointId::from(id.to_string()));
134 point.score = score;
135 let mut payload = HashMap::new();
136 payload.insert(
137 "content".to_string(),
138 qdrant_client::qdrant::Value::from(content),
139 );
140 payload.insert(
141 "source".to_string(),
142 qdrant_client::qdrant::Value::from(source),
143 );
144 point.payload = payload;
145 point
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::hybrid_native::fixtures::scored_point;
153
154 #[test]
157 fn scored_point_maps_to_search_result() {
158 let result =
159 crate::qdrant::scored_point_to_result(scored_point(1, 0.98, "rust doc", "docs"));
160 assert_eq!(result.document.content, "rust doc");
161 assert_eq!(result.document.id, None, "no doc_id payload → no id");
162 assert_eq!(result.score, 0.98);
163 assert_eq!(
164 result
165 .document
166 .metadata
167 .get("source")
168 .and_then(|v| v.as_str()),
169 Some("docs")
170 );
171 assert!(
172 !result.document.metadata.contains_key("content"),
173 "content is not duplicated into metadata"
174 );
175 }
176
177 #[test]
179 fn doc_id_payload_becomes_document_id() {
180 let mut point = scored_point(2, 0.9, "hello", "docs");
181 point.payload.insert(
182 "doc_id".to_string(),
183 qdrant_client::qdrant::Value::from("doc-42"),
184 );
185 let result = crate::qdrant::scored_point_to_result(point);
186 assert_eq!(result.document.id.as_deref(), Some("doc-42"));
187 }
188
189 #[test]
190 fn fusion_maps_to_proto() {
191 assert_eq!(
192 qdrant_client::qdrant::Fusion::from(FusionMethod::Rrf),
193 qdrant_client::qdrant::Fusion::Rrf
194 );
195 assert_eq!(
196 qdrant_client::qdrant::Fusion::from(FusionMethod::Dbsf),
197 qdrant_client::qdrant::Fusion::Dbsf
198 );
199 }
200
201 #[test]
203 fn query_validates_branches() {
204 assert!(
205 NativeHybridQuery::new(vec![vec![1.0]], 5).is_err(),
206 "1 branch"
207 );
208 assert!(
209 NativeHybridQuery::new(vec![vec![1.0, 0.0], vec![1.0, 0.0, 0.0]], 5).is_err(),
210 "mixed dimensions"
211 );
212 let ok = NativeHybridQuery::new(vec![vec![1.0, 0.0], vec![0.0, 1.0]], 5).unwrap();
213 assert_eq!(ok.limit, 5);
214 assert_eq!(ok.fusion, FusionMethod::Rrf, "default fusion");
215 }
216
217 #[test]
219 fn prefetch_limit_defaults() {
220 let small = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 3).unwrap();
221 assert_eq!(small.effective_prefetch_limit(), 10, "floor applies");
222 let large = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 20).unwrap();
223 assert_eq!(large.effective_prefetch_limit(), 40, "limit * 2");
224 let explicit = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 20)
225 .unwrap()
226 .with_prefetch_limit(7);
227 assert_eq!(explicit.effective_prefetch_limit(), 7);
228 }
229
230 #[tokio::test]
233 async fn default_capability_is_unsupported() {
234 struct Noop;
235 #[async_trait]
236 impl NativeHybridSearch for Noop {}
237 assert!(!Noop.supports_native_hybrid());
238 let query = NativeHybridQuery::new(vec![vec![1.0], vec![0.0]], 5).unwrap();
239 let err = Noop.native_hybrid_search(&query).await.unwrap_err();
240 assert!(
241 err.to_string().contains("client-side RRF fallback"),
242 "error must point to the fallback: {err}"
243 );
244 }
245}