Skip to main content

lc_vector_stores/
qdrant.rs

1// lc-vector-stores/src/qdrant.rs
2//! Qdrant vector store implementation
3
4use crate::hybrid_native::{FusionMethod, NativeHybridQuery, NativeHybridSearch};
5use crate::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore, VectorStoreError};
6use async_trait::async_trait;
7use qdrant_client::{
8    qdrant::{
9        Condition, CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter, Fusion, PointId,
10        PointStruct, PrefetchQuery, PrefetchQueryBuilder, Query, QueryPointsBuilder, Range,
11        ScoredPoint, UpsertPointsBuilder, VectorParamsBuilder,
12    },
13    Payload, Qdrant,
14};
15use serde_json::Value;
16use std::collections::HashMap;
17use std::sync::Arc;
18use uuid::Uuid;
19
20/// Qdrant configuration
21#[derive(Debug, Clone)]
22pub struct QdrantConfig {
23    /// Qdrant server URL
24    pub url: String,
25    /// Collection name
26    pub collection_name: String,
27    /// Vector dimension
28    pub vector_size: usize,
29    /// Distance metric
30    pub distance: QdrantDistance,
31}
32
33/// Qdrant distance metric type
34#[derive(Debug, Clone, Copy)]
35pub enum QdrantDistance {
36    /// Cosine similarity
37    Cosine,
38    /// Euclidean distance
39    Euclid,
40    /// Dot product
41    Dot,
42}
43
44impl From<QdrantDistance> for Distance {
45    fn from(dist: QdrantDistance) -> Self {
46        match dist {
47            QdrantDistance::Cosine => Distance::Cosine,
48            QdrantDistance::Euclid => Distance::Euclid,
49            QdrantDistance::Dot => Distance::Dot,
50        }
51    }
52}
53
54impl Default for QdrantConfig {
55    fn default() -> Self {
56        Self {
57            url: "http://localhost:6334".to_string(),
58            collection_name: "langchainrust".to_string(),
59            vector_size: 1536,
60            distance: QdrantDistance::Cosine,
61        }
62    }
63}
64
65impl QdrantConfig {
66    /// Creates a config from a server URL and collection name; remaining fields use defaults.
67    pub fn new(url: impl Into<String>, collection_name: impl Into<String>) -> Self {
68        Self {
69            url: url.into(),
70            collection_name: collection_name.into(),
71            ..Default::default()
72        }
73    }
74
75    /// Sets the vector dimension.
76    pub fn with_vector_size(mut self, size: usize) -> Self {
77        self.vector_size = size;
78        self
79    }
80
81    /// Sets the distance metric.
82    pub fn with_distance(mut self, distance: QdrantDistance) -> Self {
83        self.distance = distance;
84        self
85    }
86}
87
88/// Qdrant vector store
89pub struct QdrantVectorStore {
90    client: Arc<Qdrant>,
91    config: QdrantConfig,
92}
93
94impl QdrantVectorStore {
95    /// Connects to Qdrant per the config, auto-creating the collection if it does not exist.
96    pub async fn new(config: QdrantConfig) -> Result<Self, VectorStoreError> {
97        let client = Qdrant::from_url(&config.url).build().map_err(|e| {
98            VectorStoreError::ConnectionError(format!("failed to connect to Qdrant: {}", e))
99        })?;
100
101        let client = Arc::new(client);
102
103        let exists = client
104            .collection_exists(&config.collection_name)
105            .await
106            .map_err(|e| {
107                VectorStoreError::StorageError(format!("failed to check collection: {}", e))
108            })?;
109
110        if !exists {
111            client
112                .create_collection(
113                    CreateCollectionBuilder::new(&config.collection_name).vectors_config(
114                        VectorParamsBuilder::new(
115                            config.vector_size as u64,
116                            Distance::from(config.distance),
117                        ),
118                    ),
119                )
120                .await
121                .map_err(|e| {
122                    VectorStoreError::StorageError(format!("failed to create collection: {}", e))
123                })?;
124        }
125
126        Ok(Self { client, config })
127    }
128
129    /// Creates a store from the `QDRANT_URL` and `QDRANT_COLLECTION` environment variables.
130    pub async fn from_env() -> Result<Self, VectorStoreError> {
131        let url =
132            std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".to_string());
133        let collection_name =
134            std::env::var("QDRANT_COLLECTION").unwrap_or_else(|_| "langchainrust".to_string());
135
136        Self::new(QdrantConfig::new(url, collection_name)).await
137    }
138
139    /// Deletes points matching a metadata key-value pair, returning the actual deleted count.
140    pub async fn delete_by_metadata(
141        &self,
142        key: &str,
143        value: &str,
144    ) -> Result<usize, VectorStoreError> {
145        let filter = Filter::must([Condition::matches(key, value.to_string())]);
146
147        // Q4: count matching points by metadata first, then delete, returning the true deleted count.
148        // the old implementation returned Ok(0) after deleting — callers wrongly assumed "nothing was deleted".
149        let total = self.count().await as u64;
150        let matched = self
151            .client
152            .query(
153                QueryPointsBuilder::new(&self.config.collection_name)
154                    .query(vec![0.0; self.config.vector_size])
155                    .filter(filter.clone())
156                    .limit(total.max(1))
157                    .with_payload(false),
158            )
159            .await
160            .map_err(|e| {
161                VectorStoreError::StorageError(format!(
162                    "failed to count matching points by metadata: {}",
163                    e
164                ))
165            })?;
166
167        let deleted = matched.result.len();
168
169        if deleted > 0 {
170            self.client
171                .delete_points(
172                    DeletePointsBuilder::new(&self.config.collection_name).points(filter),
173                )
174                .await
175                .map_err(|e| {
176                    VectorStoreError::StorageError(format!(
177                        "failed to delete points by metadata: {}",
178                        e
179                    ))
180                })?;
181        }
182
183        Ok(deleted)
184    }
185
186    /// Builds the similarity-query builder, optionally attaching a metadata filter (S3).
187    ///
188    /// Plain and filtered retrieval share the same result parsing; this only translates
189    /// [`MetadataFilter`] into a Qdrant payload `Filter` attached to the builder.
190    fn build_query_builder(
191        &self,
192        query_embedding: &[f32],
193        k: usize,
194        filter: Option<&MetadataFilter>,
195    ) -> Result<QueryPointsBuilder, VectorStoreError> {
196        if query_embedding.len() != self.config.vector_size {
197            return Err(VectorStoreError::StorageError(format!(
198                "query vector dimension mismatch: expected {}, got {}",
199                self.config.vector_size,
200                query_embedding.len()
201            )));
202        }
203
204        let mut builder = QueryPointsBuilder::new(&self.config.collection_name)
205            .query(query_embedding.to_vec())
206            .limit(k as u64)
207            .with_payload(true);
208
209        if let Some(f) = filter {
210            builder = builder.filter(filter_to_qdrant(f)?);
211        }
212
213        Ok(builder)
214    }
215
216    /// Runs the query and parses the payload → [`SearchResult`] (shared by plain, filtered,
217    /// and native-hybrid retrieval).
218    async fn search_impl(
219        &self,
220        builder: QueryPointsBuilder,
221    ) -> Result<Vec<SearchResult>, VectorStoreError> {
222        let search_result = self
223            .client
224            .query(builder)
225            .await
226            .map_err(|e| VectorStoreError::StorageError(format!("search failed: {}", e)))?;
227
228        Ok(search_result
229            .result
230            .into_iter()
231            .map(scored_point_to_result)
232            .collect())
233    }
234}
235
236/// Maps a Qdrant `ScoredPoint` to a [`SearchResult`].
237///
238/// Pure (0.21.0 S4.3): extracted from the inline map so the native-hybrid path
239/// shares the exact same payload conventions (`content` / `doc_id` / metadata
240/// pass-through) and can be unit-tested without a server.
241pub(crate) fn scored_point_to_result(scored_point: ScoredPoint) -> SearchResult {
242    let payload = scored_point.payload;
243
244    let content = payload
245        .get("content")
246        .and_then(|v| v.as_str())
247        .map(|s| s.as_str())
248        .unwrap_or("")
249        .to_string();
250
251    let id = payload
252        .get("doc_id")
253        .and_then(|v| v.as_str())
254        .map(|s| s.to_string());
255
256    let mut metadata = HashMap::new();
257    for (key, value) in &payload {
258        if key != "content" && key != "doc_id" {
259            if let Some(s) = value.as_str() {
260                metadata.insert(key.clone(), s.clone().into());
261            }
262        }
263    }
264
265    SearchResult {
266        document: Document {
267            content,
268            metadata,
269            id,
270        },
271        score: scored_point.score,
272    }
273}
274
275// ============================================================================
276// S3: MetadataFilter → Qdrant payload Filter translation
277// ============================================================================
278
279/// Match condition for a single scalar value: strings/integers/booleans go through `Match`
280/// directly, anything else returns [`UnsupportedFilter`](VectorStoreError::UnsupportedFilter).
281///
282/// Qdrant's `Match` only supports integer exact matching (no floats); floats with an integral
283/// value (e.g. `2020.0`) are normalized to i64, and true decimals cannot be expressed exactly,
284/// so they error out honestly.
285fn match_condition(key: &str, value: &Value) -> Result<Condition, VectorStoreError> {
286    match value {
287        Value::String(s) => Ok(Condition::matches(key, s.clone())),
288        Value::Bool(b) => Ok(Condition::matches(key, *b)),
289        Value::Number(n) => {
290            let int = n
291                .as_i64()
292                .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64));
293            match int {
294                Some(i) => Ok(Condition::matches(key, i)),
295                None => Err(VectorStoreError::UnsupportedFilter(format!(
296                    "Qdrant match condition requires an integer, string, or boolean value, got {n}"
297                ))),
298            }
299        }
300        other => Err(VectorStoreError::UnsupportedFilter(format!(
301            "Qdrant match condition requires a scalar value, got {other}"
302        ))),
303    }
304}
305
306/// Single-field condition → Qdrant `Condition`.
307///
308/// - `Eq` → a `must` match; `Ne` → a `must_not` match.
309/// - `Gt/Gte/Lt/Lte` → numeric range [`Condition::range`].
310/// - `In` → a `should` set of matches (any hit); `Nin` → a `must_not` set (all excluded).
311fn field_to_condition(
312    key: &str,
313    op: FilterOp,
314    value: &Value,
315) -> Result<Condition, VectorStoreError> {
316    match op {
317        FilterOp::Eq => match_condition(key, value),
318        FilterOp::Ne => Ok(Condition::from(Filter::must_not([match_condition(
319            key, value,
320        )?]))),
321        FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
322            let num = value.as_f64().ok_or_else(|| {
323                VectorStoreError::UnsupportedFilter(format!(
324                    "Qdrant range condition requires a numeric value, got {value}"
325                ))
326            })?;
327            let mut range = Range::default();
328            match op {
329                FilterOp::Gt => range.gt = Some(num),
330                FilterOp::Gte => range.gte = Some(num),
331                FilterOp::Lt => range.lt = Some(num),
332                FilterOp::Lte => range.lte = Some(num),
333                _ => unreachable!(),
334            }
335            Ok(Condition::range(key, range))
336        }
337        FilterOp::In | FilterOp::Nin => {
338            let set = value.as_array().ok_or_else(|| {
339                VectorStoreError::UnsupportedFilter(format!(
340                    "Qdrant {:?} condition requires an array value, got {value}",
341                    op
342                ))
343            })?;
344            // Qdrant treats an empty should as always-true, so an empty In ("always false") cannot be expressed; reject explicitly.
345            if set.is_empty() && op == FilterOp::In {
346                return Err(VectorStoreError::UnsupportedFilter(
347                    "Qdrant In condition with an empty array cannot be expressed".to_string(),
348                ));
349            }
350            let conds: Result<Vec<Condition>, _> =
351                set.iter().map(|v| match_condition(key, v)).collect();
352            match op {
353                FilterOp::In => Ok(Condition::from(Filter::should(conds?))),
354                FilterOp::Nin => Ok(Condition::from(Filter::must_not(conds?))),
355                _ => unreachable!(),
356            }
357        }
358    }
359}
360
361/// [`MetadataFilter`] subtree → a single `Condition` (And/Or expressed as nested Filters).
362///
363/// Qdrant's `Condition` natively supports a `Filter` variant (`From<Filter> for Condition`),
364/// so arbitrary boolean nesting lands correctly in the underlying payload filter, rather than
365/// simply concatenating a should vector at the top level (which would lose semantics in
366/// AND(OR, OR) scenarios).
367fn to_condition(filter: &MetadataFilter) -> Result<Condition, VectorStoreError> {
368    match filter {
369        MetadataFilter::Field { key, op, value } => field_to_condition(key, *op, value),
370        MetadataFilter::And(items) => {
371            let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
372            Ok(Condition::from(Filter::must(conds?)))
373        }
374        MetadataFilter::Or(items) => {
375            let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
376            Ok(Condition::from(Filter::should(conds?)))
377        }
378    }
379}
380
381/// [`MetadataFilter`] → Qdrant payload `Filter`.
382///
383/// The top level is always wrapped in `must` (an empty `And` is always true, a single condition
384/// matches directly, `Or` goes through a nested should).
385pub fn filter_to_qdrant(filter: &MetadataFilter) -> Result<Filter, VectorStoreError> {
386    Ok(Filter::must([to_condition(filter)?]))
387}
388
389#[async_trait]
390impl VectorStore for QdrantVectorStore {
391    async fn add_documents(
392        &self,
393        documents: Vec<Document>,
394        embeddings: Vec<Vec<f32>>,
395    ) -> Result<Vec<String>, VectorStoreError> {
396        if documents.len() != embeddings.len() {
397            return Err(VectorStoreError::StorageError(
398                "document count and embedding count mismatch".to_string(),
399            ));
400        }
401
402        if documents.is_empty() {
403            return Ok(Vec::new());
404        }
405
406        for embedding in &embeddings {
407            if embedding.len() != self.config.vector_size {
408                return Err(VectorStoreError::StorageError(format!(
409                    "vector dimension mismatch: expected {}, got {}",
410                    self.config.vector_size,
411                    embedding.len()
412                )));
413            }
414        }
415
416        let mut ids = Vec::new();
417        let mut points = Vec::new();
418
419        for (doc, embedding) in documents.into_iter().zip(embeddings) {
420            let user_id = doc.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
421
422            // Qdrant PointId only accepts a UUID or a number, so generate an internal UUID
423            let internal_uuid = Uuid::new_v4();
424            let point_id = PointId::from(internal_uuid.to_string());
425
426            let mut payload = Payload::new();
427            payload.insert("content", doc.content.clone());
428            payload.insert("doc_id", user_id.clone()); // the user ID is stored in the payload
429
430            for (key, value) in &doc.metadata {
431                payload.insert(key.clone(), value.clone());
432            }
433
434            let point = PointStruct::new(point_id, embedding, payload);
435            points.push(point);
436            ids.push(user_id);
437        }
438
439        self.client
440            .upsert_points(UpsertPointsBuilder::new(
441                &self.config.collection_name,
442                points,
443            ))
444            .await
445            .map_err(|e| {
446                VectorStoreError::StorageError(format!("failed to insert documents: {}", e))
447            })?;
448
449        Ok(ids)
450    }
451
452    async fn similarity_search(
453        &self,
454        query_embedding: &[f32],
455        k: usize,
456    ) -> Result<Vec<SearchResult>, VectorStoreError> {
457        let builder = self.build_query_builder(query_embedding, k, None)?;
458        self.search_impl(builder).await
459    }
460
461    /// S3: similarity search with metadata filtering — filtering is delegated to the server (payload filter).
462    async fn similarity_search_with_filter(
463        &self,
464        query_embedding: &[f32],
465        k: usize,
466        filter: Option<&MetadataFilter>,
467    ) -> Result<Vec<SearchResult>, VectorStoreError> {
468        let builder = self.build_query_builder(query_embedding, k, filter)?;
469        self.search_impl(builder).await
470    }
471
472    async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
473        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
474
475        let results = self
476            .client
477            .query(
478                QueryPointsBuilder::new(&self.config.collection_name)
479                    .query(vec![0.0; self.config.vector_size])
480                    .filter(filter)
481                    .limit(1)
482                    .with_payload(true),
483            )
484            .await
485            .map_err(|e| {
486                VectorStoreError::StorageError(format!("failed to get document: {}", e))
487            })?;
488
489        if let Some(point) = results.result.first() {
490            let payload_map = point.payload.clone();
491
492            let content = payload_map
493                .get("content")
494                .and_then(|v| v.as_str())
495                .map(|s| s.as_str())
496                .unwrap_or("")
497                .to_string();
498
499            let doc_id = payload_map
500                .get("doc_id")
501                .and_then(|v| v.as_str())
502                .map(|s| s.to_string());
503
504            let mut metadata = HashMap::new();
505            for (key, value) in &payload_map {
506                if key != "content" && key != "doc_id" {
507                    if let Some(s) = value.as_str() {
508                        metadata.insert(key.clone(), s.clone().into());
509                    }
510                }
511            }
512
513            Ok(Some(Document {
514                content,
515                metadata,
516                id: doc_id,
517            }))
518        } else {
519            Ok(None)
520        }
521    }
522
523    async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
524        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
525
526        let results = self
527            .client
528            .query(
529                QueryPointsBuilder::new(&self.config.collection_name)
530                    .query(vec![0.0; self.config.vector_size])
531                    .filter(filter)
532                    .limit(1)
533                    .with_payload(true),
534            )
535            .await
536            .map_err(|e| VectorStoreError::StorageError(format!("failed to get vector: {}", e)))?;
537
538        if let Some(point) = results.result.first() {
539            if let Some(vectors) = &point.vectors {
540                if let Some(qdrant_client::qdrant::vector_output::Vector::Dense(dense)) =
541                    vectors.get_vector()
542                {
543                    return Ok(Some(dense.data.clone()));
544                }
545            }
546        }
547        Ok(None)
548    }
549
550    async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
551        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
552
553        self.client
554            .delete_points(DeletePointsBuilder::new(&self.config.collection_name).points(filter))
555            .await
556            .map_err(|e| {
557                VectorStoreError::StorageError(format!("failed to delete document: {}", e))
558            })?;
559
560        Ok(())
561    }
562
563    async fn count(&self) -> usize {
564        let info = self
565            .client
566            .collection_info(&self.config.collection_name)
567            .await;
568
569        info.map(|i| i.result.and_then(|r| r.points_count).unwrap_or(0) as usize)
570            .unwrap_or(0)
571    }
572
573    async fn clear(&self) -> Result<(), VectorStoreError> {
574        let collection_name = self.config.collection_name.clone();
575
576        self.client
577            .delete_collection(&collection_name)
578            .await
579            .map_err(|e| {
580                VectorStoreError::StorageError(format!("failed to delete collection: {}", e))
581            })?;
582
583        self.client
584            .create_collection(
585                CreateCollectionBuilder::new(&collection_name).vectors_config(
586                    VectorParamsBuilder::new(
587                        self.config.vector_size as u64,
588                        Distance::from(self.config.distance),
589                    ),
590                ),
591            )
592            .await
593            .map_err(|e| {
594                VectorStoreError::StorageError(format!("failed to recreate collection: {}", e))
595            })?;
596
597        Ok(())
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_config_default() {
607        let config = QdrantConfig::default();
608        assert_eq!(config.url, "http://localhost:6334");
609        assert_eq!(config.collection_name, "langchainrust");
610        assert_eq!(config.vector_size, 1536);
611    }
612
613    #[test]
614    fn test_config_builder() {
615        let config = QdrantConfig::new("http://custom:6334", "test_collection")
616            .with_vector_size(3072)
617            .with_distance(QdrantDistance::Euclid);
618
619        assert_eq!(config.url, "http://custom:6334");
620        assert_eq!(config.collection_name, "test_collection");
621        assert_eq!(config.vector_size, 3072);
622        assert!(matches!(config.distance, QdrantDistance::Euclid));
623    }
624
625    /// S3: single-field Eq → must match condition.
626    #[test]
627    fn test_filter_to_qdrant_eq() {
628        let f = filter_to_qdrant(&MetadataFilter::field("lang", FilterOp::Eq, "rust")).unwrap();
629        let expected = Filter::must([Condition::matches("lang", "rust".to_string())]);
630        assert_eq!(f, expected);
631    }
632
633    /// S3: integral floats normalized to i64; Ne → must_not.
634    #[test]
635    fn test_filter_to_qdrant_ne_number() {
636        let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Ne, 2020.0)).unwrap();
637        let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
638            "year", 2020_i64,
639        )]))]);
640        assert_eq!(f, expected);
641    }
642
643    /// S3: Gt/Gte/Lt/Lte → numeric range.
644    #[test]
645    fn test_filter_to_qdrant_range() {
646        let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gte, 2020)).unwrap();
647        let expected = Filter::must([Condition::range(
648            "year",
649            Range {
650                gte: Some(2020.0),
651                ..Default::default()
652            },
653        )]);
654        assert_eq!(f, expected);
655    }
656
657    /// S3: In → a should set of matches; Nin → a must_not set.
658    #[test]
659    fn test_filter_to_qdrant_in_nin() {
660        let f =
661            filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::In, vec!["a", "b"])).unwrap();
662        let expected = Filter::must([Condition::from(Filter::should([
663            Condition::matches("tag", "a".to_string()),
664            Condition::matches("tag", "b".to_string()),
665        ]))]);
666        assert_eq!(f, expected);
667
668        let f = filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::Nin, vec!["a"])).unwrap();
669        let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
670            "tag",
671            "a".to_string(),
672        )]))]);
673        assert_eq!(f, expected);
674    }
675
676    /// S3: AND/OR combination → nested Filter conditions (not flattened, preserving AND(OR,OR) semantics).
677    #[test]
678    fn test_filter_to_qdrant_and_or() {
679        let f = MetadataFilter::and(vec![
680            MetadataFilter::field("lang", FilterOp::Eq, "rust"),
681            MetadataFilter::or(vec![
682                MetadataFilter::field("year", FilterOp::Gte, 2020),
683                MetadataFilter::field("tag", FilterOp::In, vec!["ml"]),
684            ]),
685        ]);
686        let expected = Filter::must([Condition::from(Filter::must([
687            Condition::matches("lang", "rust".to_string()),
688            Condition::from(Filter::should([
689                Condition::range(
690                    "year",
691                    Range {
692                        gte: Some(2020.0),
693                        ..Default::default()
694                    },
695                ),
696                Condition::from(Filter::should([Condition::matches(
697                    "tag",
698                    "ml".to_string(),
699                )])),
700            ])),
701        ]))]);
702        assert_eq!(filter_to_qdrant(&f).unwrap(), expected);
703    }
704
705    /// S3: inexpressible constructs honestly report UnsupportedFilter.
706    #[test]
707    fn test_filter_to_qdrant_unsupported() {
708        // Qdrant Match does not support float exact matching.
709        let float_eq = filter_to_qdrant(&MetadataFilter::field("score", FilterOp::Eq, 0.5));
710        assert!(matches!(
711            float_eq,
712            Err(VectorStoreError::UnsupportedFilter(_))
713        ));
714
715        // range conditions require a numeric value.
716        let range_on_str = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gt, "abc"));
717        assert!(matches!(
718            range_on_str,
719            Err(VectorStoreError::UnsupportedFilter(_))
720        ));
721
722        // an empty In cannot be expressed (Qdrant treats an empty should as always-true).
723        let empty_in = filter_to_qdrant(&MetadataFilter::field(
724            "tag",
725            FilterOp::In,
726            Vec::<String>::new(),
727        ));
728        assert!(matches!(
729            empty_in,
730            Err(VectorStoreError::UnsupportedFilter(_))
731        ));
732    }
733}
734
735// ============================================================================
736// 0.21.0 S4.3: engine-native hybrid fusion (Query API, server-side RRF/DBSF)
737// ============================================================================
738
739#[async_trait]
740impl NativeHybridSearch for QdrantVectorStore {
741    /// Qdrant >= 1.10 serves the Query API with server-side fusion. The client
742    /// crate is version-locked (1.18) so the request shape is compile-time
743    /// verified; a too-old *server* surfaces its own error from the query.
744    fn supports_native_hybrid(&self) -> bool {
745        true
746    }
747
748    async fn native_hybrid_search(
749        &self,
750        query: &NativeHybridQuery,
751    ) -> Result<Vec<SearchResult>, VectorStoreError> {
752        // One prefetch branch per query vector; the engine fuses them.
753        let prefetch_limit = query.effective_prefetch_limit();
754        let prefetches: Vec<PrefetchQuery> = query
755            .query_vectors
756            .iter()
757            .map(|vector| {
758                PrefetchQueryBuilder::default()
759                    .query(vector.clone())
760                    .limit(prefetch_limit)
761                    .build()
762            })
763            .collect();
764
765        let mut builder = QueryPointsBuilder::new(&self.config.collection_name)
766            .prefetch(prefetches)
767            .query(qdrant_client::qdrant::Query::new_fusion(Fusion::from(
768                query.fusion,
769            )))
770            .limit(query.limit as u64)
771            .with_payload(true);
772
773        if let Some(filter) = &query.filter {
774            builder = builder.filter(filter_to_qdrant(filter)?);
775        }
776
777        self.search_impl(builder).await
778    }
779}
780
781#[cfg(test)]
782mod hybrid_native_tests {
783    use super::*;
784    use crate::hybrid_native::fixtures::scored_point as fixture_point;
785
786    /// Mapping follows the same payload conventions as plain search:
787    /// content -> content, doc_id -> id, other string fields -> metadata.
788    #[test]
789    fn scored_point_maps_to_search_result() {
790        let result = scored_point_to_result(fixture_point(1, 0.98, "rust doc", "docs"));
791        assert_eq!(result.document.content, "rust doc");
792        assert_eq!(result.document.id, None, "no doc_id payload -> no id");
793        assert_eq!(result.score, 0.98);
794        assert_eq!(
795            result
796                .document
797                .metadata
798                .get("source")
799                .and_then(|v| v.as_str()),
800            Some("docs")
801        );
802        assert!(
803            !result.document.metadata.contains_key("content"),
804            "content is not duplicated into metadata"
805        );
806    }
807
808    /// doc_id payload becomes the document id.
809    #[test]
810    fn doc_id_payload_becomes_document_id() {
811        let mut point = fixture_point(2, 0.9, "hello", "docs");
812        point.payload.insert(
813            "doc_id".to_string(),
814            qdrant_client::qdrant::Value::from("doc-42"),
815        );
816        let result = scored_point_to_result(point);
817        assert_eq!(result.document.id.as_deref(), Some("doc-42"));
818    }
819
820    /// Live end-to-end native fusion (needs a real Qdrant; run with
821    /// `--ignored` and `QDRANT_URL` set, e.g. `http://localhost:6334`).
822    /// Verifies the Query API path (prefetch branches + server-side RRF)
823    /// returns results shaped like the plain-search path.
824    #[tokio::test]
825    #[ignore = "requires a live Qdrant server (QDRANT_URL)"]
826    async fn native_hybrid_search_end_to_end() {
827        let url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".into());
828        let store = QdrantVectorStore::new(
829            crate::QdrantConfig::new(url, "lc-native-hybrid-test")
830                .with_vector_size(4)
831                .with_distance(crate::QdrantDistance::Dot),
832        )
833        .await
834        .expect("connect to Qdrant");
835
836        let docs = vec![
837            Document::new("alpha document"),
838            Document::new("beta document"),
839            Document::new("gamma document"),
840        ];
841        let embeddings = vec![
842            vec![1.0, 0.0, 0.0, 0.0],
843            vec![0.0, 1.0, 0.0, 0.0],
844            vec![0.0, 0.0, 1.0, 0.0],
845        ];
846        store.add_documents(docs, embeddings).await.unwrap();
847
848        let query =
849            NativeHybridQuery::new(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.9, 0.1, 0.0, 0.0]], 2)
850                .unwrap();
851        assert!(store.supports_native_hybrid());
852        let results = store.native_hybrid_search(&query).await.unwrap();
853        assert_eq!(results.len(), 2, "fused top-2");
854        assert_eq!(results[0].document.content, "alpha document");
855    }
856}