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::{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            // M-21: preserve the original value (number / bool / object), not just
260            // strings — previously numeric/bool metadata was silently dropped on
261            // round-trip (server-side filtering matched, but the returned type was lost).
262            metadata.insert(key.clone(), value.clone());
263        }
264    }
265
266    SearchResult {
267        document: Document {
268            content,
269            metadata,
270            id,
271        },
272        score: scored_point.score,
273    }
274}
275
276// ============================================================================
277// S3: MetadataFilter → Qdrant payload Filter translation
278// ============================================================================
279
280/// Match condition for a single scalar value: strings/integers/booleans go through `Match`
281/// directly, anything else returns [`UnsupportedFilter`](VectorStoreError::UnsupportedFilter).
282///
283/// Qdrant's `Match` only supports integer exact matching (no floats); floats with an integral
284/// value (e.g. `2020.0`) are normalized to i64, and true decimals cannot be expressed exactly,
285/// so they error out honestly.
286fn match_condition(key: &str, value: &Value) -> Result<Condition, VectorStoreError> {
287    match value {
288        Value::String(s) => Ok(Condition::matches(key, s.clone())),
289        Value::Bool(b) => Ok(Condition::matches(key, *b)),
290        Value::Number(n) => {
291            let int = n
292                .as_i64()
293                .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64));
294            match int {
295                Some(i) => Ok(Condition::matches(key, i)),
296                None => Err(VectorStoreError::UnsupportedFilter(format!(
297                    "Qdrant match condition requires an integer, string, or boolean value, got {n}"
298                ))),
299            }
300        }
301        other => Err(VectorStoreError::UnsupportedFilter(format!(
302            "Qdrant match condition requires a scalar value, got {other}"
303        ))),
304    }
305}
306
307/// Single-field condition → Qdrant `Condition`.
308///
309/// - `Eq` → a `must` match; `Ne` → a `must_not` match.
310/// - `Gt/Gte/Lt/Lte` → numeric range [`Condition::range`].
311/// - `In` → a `should` set of matches (any hit); `Nin` → a `must_not` set (all excluded).
312fn field_to_condition(
313    key: &str,
314    op: FilterOp,
315    value: &Value,
316) -> Result<Condition, VectorStoreError> {
317    match op {
318        FilterOp::Eq => match_condition(key, value),
319        FilterOp::Ne => Ok(Condition::from(Filter::must_not([match_condition(
320            key, value,
321        )?]))),
322        FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
323            let num = value.as_f64().ok_or_else(|| {
324                VectorStoreError::UnsupportedFilter(format!(
325                    "Qdrant range condition requires a numeric value, got {value}"
326                ))
327            })?;
328            let mut range = Range::default();
329            match op {
330                FilterOp::Gt => range.gt = Some(num),
331                FilterOp::Gte => range.gte = Some(num),
332                FilterOp::Lt => range.lt = Some(num),
333                FilterOp::Lte => range.lte = Some(num),
334                _ => unreachable!(),
335            }
336            Ok(Condition::range(key, range))
337        }
338        FilterOp::In | FilterOp::Nin => {
339            let set = value.as_array().ok_or_else(|| {
340                VectorStoreError::UnsupportedFilter(format!(
341                    "Qdrant {:?} condition requires an array value, got {value}",
342                    op
343                ))
344            })?;
345            // Qdrant treats an empty should as always-true, so an empty In ("always false") cannot be expressed; reject explicitly.
346            if set.is_empty() && op == FilterOp::In {
347                return Err(VectorStoreError::UnsupportedFilter(
348                    "Qdrant In condition with an empty array cannot be expressed".to_string(),
349                ));
350            }
351            let conds: Result<Vec<Condition>, _> =
352                set.iter().map(|v| match_condition(key, v)).collect();
353            match op {
354                FilterOp::In => Ok(Condition::from(Filter::should(conds?))),
355                FilterOp::Nin => Ok(Condition::from(Filter::must_not(conds?))),
356                _ => unreachable!(),
357            }
358        }
359    }
360}
361
362/// [`MetadataFilter`] subtree → a single `Condition` (And/Or expressed as nested Filters).
363///
364/// Qdrant's `Condition` natively supports a `Filter` variant (`From<Filter> for Condition`),
365/// so arbitrary boolean nesting lands correctly in the underlying payload filter, rather than
366/// simply concatenating a should vector at the top level (which would lose semantics in
367/// AND(OR, OR) scenarios).
368fn to_condition(filter: &MetadataFilter) -> Result<Condition, VectorStoreError> {
369    match filter {
370        MetadataFilter::Field { key, op, value } => field_to_condition(key, *op, value),
371        MetadataFilter::And(items) => {
372            let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
373            Ok(Condition::from(Filter::must(conds?)))
374        }
375        MetadataFilter::Or(items) => {
376            let conds: Result<Vec<Condition>, _> = items.iter().map(to_condition).collect();
377            Ok(Condition::from(Filter::should(conds?)))
378        }
379    }
380}
381
382/// [`MetadataFilter`] → Qdrant payload `Filter`.
383///
384/// The top level is always wrapped in `must` (an empty `And` is always true, a single condition
385/// matches directly, `Or` goes through a nested should).
386pub fn filter_to_qdrant(filter: &MetadataFilter) -> Result<Filter, VectorStoreError> {
387    Ok(Filter::must([to_condition(filter)?]))
388}
389
390#[async_trait]
391impl VectorStore for QdrantVectorStore {
392    async fn add_documents(
393        &self,
394        documents: Vec<Document>,
395        embeddings: Vec<Vec<f32>>,
396    ) -> Result<Vec<String>, VectorStoreError> {
397        if documents.len() != embeddings.len() {
398            return Err(VectorStoreError::StorageError(
399                "document count and embedding count mismatch".to_string(),
400            ));
401        }
402
403        if documents.is_empty() {
404            return Ok(Vec::new());
405        }
406
407        for embedding in &embeddings {
408            if embedding.len() != self.config.vector_size {
409                return Err(VectorStoreError::StorageError(format!(
410                    "vector dimension mismatch: expected {}, got {}",
411                    self.config.vector_size,
412                    embedding.len()
413                )));
414            }
415        }
416
417        let mut ids = Vec::new();
418        let mut points = Vec::new();
419
420        for (doc, embedding) in documents.into_iter().zip(embeddings) {
421            let user_id = doc.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
422
423            // 0.22.0 C5 fix: the internal point id is **deterministic** —
424            // UUIDv5 over the user doc id. Previously it was a fresh random
425            // UUID per insert, so re-adding the same document created parallel
426            // duplicate vectors that crowded out top-k (the payload `doc_id`
427            // alone cannot dedupe because point ids never match).
428            let internal_uuid = deterministic_point_uuid(&self.config.collection_name, &user_id);
429            let point_id = PointId::from(internal_uuid.to_string());
430
431            let mut payload = Payload::new();
432            payload.insert("content", doc.content.clone());
433            payload.insert("doc_id", user_id.clone()); // the user ID is stored in the payload
434
435            for (key, value) in &doc.metadata {
436                payload.insert(key.clone(), value.clone());
437            }
438
439            let point = PointStruct::new(point_id, embedding, payload);
440            points.push(point);
441            ids.push(user_id);
442        }
443
444        self.client
445            .upsert_points(UpsertPointsBuilder::new(
446                &self.config.collection_name,
447                points,
448            ))
449            .await
450            .map_err(|e| {
451                VectorStoreError::StorageError(format!("failed to insert documents: {}", e))
452            })?;
453
454        Ok(ids)
455    }
456
457    async fn similarity_search(
458        &self,
459        query_embedding: &[f32],
460        k: usize,
461    ) -> Result<Vec<SearchResult>, VectorStoreError> {
462        let builder = self.build_query_builder(query_embedding, k, None)?;
463        self.search_impl(builder).await
464    }
465
466    /// S3: similarity search with metadata filtering — filtering is delegated to the server (payload filter).
467    async fn similarity_search_with_filter(
468        &self,
469        query_embedding: &[f32],
470        k: usize,
471        filter: Option<&MetadataFilter>,
472    ) -> Result<Vec<SearchResult>, VectorStoreError> {
473        let builder = self.build_query_builder(query_embedding, k, filter)?;
474        self.search_impl(builder).await
475    }
476
477    async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
478        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
479
480        let results = self
481            .client
482            .query(
483                QueryPointsBuilder::new(&self.config.collection_name)
484                    .query(vec![0.0; self.config.vector_size])
485                    .filter(filter)
486                    .limit(1)
487                    .with_payload(true),
488            )
489            .await
490            .map_err(|e| {
491                VectorStoreError::StorageError(format!("failed to get document: {}", e))
492            })?;
493
494        if let Some(point) = results.result.first() {
495            let payload_map = point.payload.clone();
496
497            let content = payload_map
498                .get("content")
499                .and_then(|v| v.as_str())
500                .map(|s| s.as_str())
501                .unwrap_or("")
502                .to_string();
503
504            let doc_id = payload_map
505                .get("doc_id")
506                .and_then(|v| v.as_str())
507                .map(|s| s.to_string());
508
509            let mut metadata = HashMap::new();
510            for (key, value) in &payload_map {
511                if key != "content" && key != "doc_id" {
512                    // M-21: preserve original number/bool/object values, not only strings.
513                    metadata.insert(key.clone(), value.clone());
514                }
515            }
516
517            Ok(Some(Document {
518                content,
519                metadata,
520                id: doc_id,
521            }))
522        } else {
523            Ok(None)
524        }
525    }
526
527    async fn get_embedding(&self, id: &str) -> Result<Option<Vec<f32>>, VectorStoreError> {
528        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
529
530        let results = self
531            .client
532            .query(
533                QueryPointsBuilder::new(&self.config.collection_name)
534                    .query(vec![0.0; self.config.vector_size])
535                    .filter(filter)
536                    .limit(1)
537                    .with_payload(true),
538            )
539            .await
540            .map_err(|e| VectorStoreError::StorageError(format!("failed to get vector: {}", e)))?;
541
542        if let Some(point) = results.result.first() {
543            if let Some(vectors) = &point.vectors {
544                if let Some(qdrant_client::qdrant::vector_output::Vector::Dense(dense)) =
545                    vectors.get_vector()
546                {
547                    return Ok(Some(dense.data.clone()));
548                }
549            }
550        }
551        Ok(None)
552    }
553
554    async fn delete_document(&self, id: &str) -> Result<(), VectorStoreError> {
555        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
556
557        self.client
558            .delete_points(DeletePointsBuilder::new(&self.config.collection_name).points(filter))
559            .await
560            .map_err(|e| {
561                VectorStoreError::StorageError(format!("failed to delete document: {}", e))
562            })?;
563
564        Ok(())
565    }
566
567    async fn count(&self) -> usize {
568        let info = self
569            .client
570            .collection_info(&self.config.collection_name)
571            .await;
572
573        info.map(|i| i.result.and_then(|r| r.points_count).unwrap_or(0) as usize)
574            .unwrap_or(0)
575    }
576
577    async fn clear(&self) -> Result<(), VectorStoreError> {
578        let collection_name = self.config.collection_name.clone();
579
580        self.client
581            .delete_collection(&collection_name)
582            .await
583            .map_err(|e| {
584                VectorStoreError::StorageError(format!("failed to delete collection: {}", e))
585            })?;
586
587        self.client
588            .create_collection(
589                CreateCollectionBuilder::new(&collection_name).vectors_config(
590                    VectorParamsBuilder::new(
591                        self.config.vector_size as u64,
592                        Distance::from(self.config.distance),
593                    ),
594                ),
595            )
596            .await
597            .map_err(|e| {
598                VectorStoreError::StorageError(format!("failed to recreate collection: {}", e))
599            })?;
600
601        Ok(())
602    }
603}
604
605/// 0.22.0 C5: deterministic internal point id — UUIDv5 over
606/// `(collection_name, user_doc_id)` in a fixed namespace. Same document in
607/// the same collection always maps to the same point, so re-ingesting a
608/// document **upserts** it instead of creating parallel duplicate vectors.
609fn deterministic_point_uuid(collection: &str, doc_id: &str) -> Uuid {
610    // Fixed namespace (any constant UUID works; this one is a reserved
611    // example namespace so we are not colliding with DNS/OID/URL names).
612    let namespace = uuid::uuid!("167f0e54-3d37-4a72-a58b-8b2a1c6d5f10");
613    Uuid::new_v5(&namespace, format!("{collection}:{doc_id}").as_bytes())
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_config_default() {
622        let config = QdrantConfig::default();
623        assert_eq!(config.url, "http://localhost:6334");
624        assert_eq!(config.collection_name, "langchainrust");
625        assert_eq!(config.vector_size, 1536);
626    }
627
628    #[test]
629    fn test_config_builder() {
630        let config = QdrantConfig::new("http://custom:6334", "test_collection")
631            .with_vector_size(3072)
632            .with_distance(QdrantDistance::Euclid);
633
634        assert_eq!(config.url, "http://custom:6334");
635        assert_eq!(config.collection_name, "test_collection");
636        assert_eq!(config.vector_size, 3072);
637        assert!(matches!(config.distance, QdrantDistance::Euclid));
638    }
639
640    /// S3: single-field Eq → must match condition.
641    #[test]
642    fn test_filter_to_qdrant_eq() {
643        let f = filter_to_qdrant(&MetadataFilter::field("lang", FilterOp::Eq, "rust")).unwrap();
644        let expected = Filter::must([Condition::matches("lang", "rust".to_string())]);
645        assert_eq!(f, expected);
646    }
647
648    /// S3: integral floats normalized to i64; Ne → must_not.
649    #[test]
650    fn test_filter_to_qdrant_ne_number() {
651        let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Ne, 2020.0)).unwrap();
652        let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
653            "year", 2020_i64,
654        )]))]);
655        assert_eq!(f, expected);
656    }
657
658    /// S3: Gt/Gte/Lt/Lte → numeric range.
659    #[test]
660    fn test_filter_to_qdrant_range() {
661        let f = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gte, 2020)).unwrap();
662        let expected = Filter::must([Condition::range(
663            "year",
664            Range {
665                gte: Some(2020.0),
666                ..Default::default()
667            },
668        )]);
669        assert_eq!(f, expected);
670    }
671
672    /// S3: In → a should set of matches; Nin → a must_not set.
673    #[test]
674    fn test_filter_to_qdrant_in_nin() {
675        let f =
676            filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::In, vec!["a", "b"])).unwrap();
677        let expected = Filter::must([Condition::from(Filter::should([
678            Condition::matches("tag", "a".to_string()),
679            Condition::matches("tag", "b".to_string()),
680        ]))]);
681        assert_eq!(f, expected);
682
683        let f = filter_to_qdrant(&MetadataFilter::field("tag", FilterOp::Nin, vec!["a"])).unwrap();
684        let expected = Filter::must([Condition::from(Filter::must_not([Condition::matches(
685            "tag",
686            "a".to_string(),
687        )]))]);
688        assert_eq!(f, expected);
689    }
690
691    /// S3: AND/OR combination → nested Filter conditions (not flattened, preserving AND(OR,OR) semantics).
692    #[test]
693    fn test_filter_to_qdrant_and_or() {
694        let f = MetadataFilter::and(vec![
695            MetadataFilter::field("lang", FilterOp::Eq, "rust"),
696            MetadataFilter::or(vec![
697                MetadataFilter::field("year", FilterOp::Gte, 2020),
698                MetadataFilter::field("tag", FilterOp::In, vec!["ml"]),
699            ]),
700        ]);
701        let expected = Filter::must([Condition::from(Filter::must([
702            Condition::matches("lang", "rust".to_string()),
703            Condition::from(Filter::should([
704                Condition::range(
705                    "year",
706                    Range {
707                        gte: Some(2020.0),
708                        ..Default::default()
709                    },
710                ),
711                Condition::from(Filter::should([Condition::matches(
712                    "tag",
713                    "ml".to_string(),
714                )])),
715            ])),
716        ]))]);
717        assert_eq!(filter_to_qdrant(&f).unwrap(), expected);
718    }
719
720    /// S3: inexpressible constructs honestly report UnsupportedFilter.
721    #[test]
722    fn test_filter_to_qdrant_unsupported() {
723        // Qdrant Match does not support float exact matching.
724        let float_eq = filter_to_qdrant(&MetadataFilter::field("score", FilterOp::Eq, 0.5));
725        assert!(matches!(
726            float_eq,
727            Err(VectorStoreError::UnsupportedFilter(_))
728        ));
729
730        // range conditions require a numeric value.
731        let range_on_str = filter_to_qdrant(&MetadataFilter::field("year", FilterOp::Gt, "abc"));
732        assert!(matches!(
733            range_on_str,
734            Err(VectorStoreError::UnsupportedFilter(_))
735        ));
736
737        // an empty In cannot be expressed (Qdrant treats an empty should as always-true).
738        let empty_in = filter_to_qdrant(&MetadataFilter::field(
739            "tag",
740            FilterOp::In,
741            Vec::<String>::new(),
742        ));
743        assert!(matches!(
744            empty_in,
745            Err(VectorStoreError::UnsupportedFilter(_))
746        ));
747    }
748}
749
750// ============================================================================
751// 0.21.0 S4.3: engine-native hybrid fusion (Query API, server-side RRF/DBSF)
752// ============================================================================
753
754#[async_trait]
755impl NativeHybridSearch for QdrantVectorStore {
756    /// Qdrant >= 1.10 serves the Query API with server-side fusion. The client
757    /// crate is version-locked (1.18) so the request shape is compile-time
758    /// verified; a too-old *server* surfaces its own error from the query.
759    fn supports_native_hybrid(&self) -> bool {
760        true
761    }
762
763    async fn native_hybrid_search(
764        &self,
765        query: &NativeHybridQuery,
766    ) -> Result<Vec<SearchResult>, VectorStoreError> {
767        // One prefetch branch per query vector; the engine fuses them.
768        let prefetch_limit = query.effective_prefetch_limit();
769        let prefetches: Vec<PrefetchQuery> = query
770            .query_vectors
771            .iter()
772            .map(|vector| {
773                PrefetchQueryBuilder::default()
774                    .query(vector.clone())
775                    .limit(prefetch_limit)
776                    .build()
777            })
778            .collect();
779
780        let mut builder = QueryPointsBuilder::new(&self.config.collection_name)
781            .prefetch(prefetches)
782            .query(Query::new_fusion(Fusion::from(query.fusion)))
783            .limit(query.limit as u64)
784            .with_payload(true);
785
786        if let Some(filter) = &query.filter {
787            builder = builder.filter(filter_to_qdrant(filter)?);
788        }
789
790        self.search_impl(builder).await
791    }
792}
793
794#[cfg(test)]
795mod hybrid_native_tests {
796    use super::*;
797    use crate::hybrid_native::fixtures::scored_point as fixture_point;
798
799    /// Mapping follows the same payload conventions as plain search:
800    /// content -> content, doc_id -> id, other string fields -> metadata.
801    #[test]
802    fn scored_point_maps_to_search_result() {
803        let result = scored_point_to_result(fixture_point(1, 0.98, "rust doc", "docs"));
804        assert_eq!(result.document.content, "rust doc");
805        assert_eq!(result.document.id, None, "no doc_id payload -> no id");
806        assert_eq!(result.score, 0.98);
807        assert_eq!(
808            result
809                .document
810                .metadata
811                .get("source")
812                .and_then(|v| v.as_str()),
813            Some("docs")
814        );
815        assert!(
816            !result.document.metadata.contains_key("content"),
817            "content is not duplicated into metadata"
818        );
819    }
820
821    /// doc_id payload becomes the document id.
822    #[test]
823    fn doc_id_payload_becomes_document_id() {
824        let mut point = fixture_point(2, 0.9, "hello", "docs");
825        point.payload.insert(
826            "doc_id".to_string(),
827            qdrant_client::qdrant::Value::from("doc-42"),
828        );
829        let result = scored_point_to_result(point);
830        assert_eq!(result.document.id.as_deref(), Some("doc-42"));
831    }
832
833    /// Live end-to-end native fusion (needs a real Qdrant; run with
834    /// `--ignored` and `QDRANT_URL` set, e.g. `http://localhost:6334`).
835    /// Verifies the Query API path (prefetch branches + server-side RRF)
836    /// returns results shaped like the plain-search path.
837    #[tokio::test]
838    #[ignore = "requires a live Qdrant server (QDRANT_URL)"]
839    async fn native_hybrid_search_end_to_end() {
840        let url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".into());
841        let store = QdrantVectorStore::new(
842            crate::QdrantConfig::new(url, "lc-native-hybrid-test")
843                .with_vector_size(4)
844                .with_distance(crate::QdrantDistance::Dot),
845        )
846        .await
847        .expect("connect to Qdrant");
848
849        let docs = vec![
850            Document::new("alpha document"),
851            Document::new("beta document"),
852            Document::new("gamma document"),
853        ];
854        let embeddings = vec![
855            vec![1.0, 0.0, 0.0, 0.0],
856            vec![0.0, 1.0, 0.0, 0.0],
857            vec![0.0, 0.0, 1.0, 0.0],
858        ];
859        store.add_documents(docs, embeddings).await.unwrap();
860
861        let query =
862            NativeHybridQuery::new(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.9, 0.1, 0.0, 0.0]], 2)
863                .unwrap();
864        assert!(store.supports_native_hybrid());
865        let results = store.native_hybrid_search(&query).await.unwrap();
866        assert_eq!(results.len(), 2, "fused top-2");
867        assert_eq!(results[0].document.content, "alpha document");
868    }
869}