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            // 0.22.0 C5 fix: the internal point id is **deterministic** —
423            // UUIDv5 over the user doc id. Previously it was a fresh random
424            // UUID per insert, so re-adding the same document created parallel
425            // duplicate vectors that crowded out top-k (the payload `doc_id`
426            // alone cannot dedupe because point ids never match).
427            let internal_uuid = deterministic_point_uuid(&user_id, &self.config.collection_name);
428            let point_id = PointId::from(internal_uuid.to_string());
429
430            let mut payload = Payload::new();
431            payload.insert("content", doc.content.clone());
432            payload.insert("doc_id", user_id.clone()); // the user ID is stored in the payload
433
434            for (key, value) in &doc.metadata {
435                payload.insert(key.clone(), value.clone());
436            }
437
438            let point = PointStruct::new(point_id, embedding, payload);
439            points.push(point);
440            ids.push(user_id);
441        }
442
443        self.client
444            .upsert_points(UpsertPointsBuilder::new(
445                &self.config.collection_name,
446                points,
447            ))
448            .await
449            .map_err(|e| {
450                VectorStoreError::StorageError(format!("failed to insert documents: {}", e))
451            })?;
452
453        Ok(ids)
454    }
455
456    async fn similarity_search(
457        &self,
458        query_embedding: &[f32],
459        k: usize,
460    ) -> Result<Vec<SearchResult>, VectorStoreError> {
461        let builder = self.build_query_builder(query_embedding, k, None)?;
462        self.search_impl(builder).await
463    }
464
465    /// S3: similarity search with metadata filtering — filtering is delegated to the server (payload filter).
466    async fn similarity_search_with_filter(
467        &self,
468        query_embedding: &[f32],
469        k: usize,
470        filter: Option<&MetadataFilter>,
471    ) -> Result<Vec<SearchResult>, VectorStoreError> {
472        let builder = self.build_query_builder(query_embedding, k, filter)?;
473        self.search_impl(builder).await
474    }
475
476    async fn get_document(&self, id: &str) -> Result<Option<Document>, VectorStoreError> {
477        let filter = Filter::must([Condition::matches("doc_id", id.to_string())]);
478
479        let results = self
480            .client
481            .query(
482                QueryPointsBuilder::new(&self.config.collection_name)
483                    .query(vec![0.0; self.config.vector_size])
484                    .filter(filter)
485                    .limit(1)
486                    .with_payload(true),
487            )
488            .await
489            .map_err(|e| {
490                VectorStoreError::StorageError(format!("failed to get document: {}", e))
491            })?;
492
493        if let Some(point) = results.result.first() {
494            let payload_map = point.payload.clone();
495
496            let content = payload_map
497                .get("content")
498                .and_then(|v| v.as_str())
499                .map(|s| s.as_str())
500                .unwrap_or("")
501                .to_string();
502
503            let doc_id = payload_map
504                .get("doc_id")
505                .and_then(|v| v.as_str())
506                .map(|s| s.to_string());
507
508            let mut metadata = HashMap::new();
509            for (key, value) in &payload_map {
510                if key != "content" && key != "doc_id" {
511                    if let Some(s) = value.as_str() {
512                        metadata.insert(key.clone(), s.clone().into());
513                    }
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(qdrant_client::qdrant::Query::new_fusion(Fusion::from(
783                query.fusion,
784            )))
785            .limit(query.limit as u64)
786            .with_payload(true);
787
788        if let Some(filter) = &query.filter {
789            builder = builder.filter(filter_to_qdrant(filter)?);
790        }
791
792        self.search_impl(builder).await
793    }
794}
795
796#[cfg(test)]
797mod hybrid_native_tests {
798    use super::*;
799    use crate::hybrid_native::fixtures::scored_point as fixture_point;
800
801    /// Mapping follows the same payload conventions as plain search:
802    /// content -> content, doc_id -> id, other string fields -> metadata.
803    #[test]
804    fn scored_point_maps_to_search_result() {
805        let result = scored_point_to_result(fixture_point(1, 0.98, "rust doc", "docs"));
806        assert_eq!(result.document.content, "rust doc");
807        assert_eq!(result.document.id, None, "no doc_id payload -> no id");
808        assert_eq!(result.score, 0.98);
809        assert_eq!(
810            result
811                .document
812                .metadata
813                .get("source")
814                .and_then(|v| v.as_str()),
815            Some("docs")
816        );
817        assert!(
818            !result.document.metadata.contains_key("content"),
819            "content is not duplicated into metadata"
820        );
821    }
822
823    /// doc_id payload becomes the document id.
824    #[test]
825    fn doc_id_payload_becomes_document_id() {
826        let mut point = fixture_point(2, 0.9, "hello", "docs");
827        point.payload.insert(
828            "doc_id".to_string(),
829            qdrant_client::qdrant::Value::from("doc-42"),
830        );
831        let result = scored_point_to_result(point);
832        assert_eq!(result.document.id.as_deref(), Some("doc-42"));
833    }
834
835    /// Live end-to-end native fusion (needs a real Qdrant; run with
836    /// `--ignored` and `QDRANT_URL` set, e.g. `http://localhost:6334`).
837    /// Verifies the Query API path (prefetch branches + server-side RRF)
838    /// returns results shaped like the plain-search path.
839    #[tokio::test]
840    #[ignore = "requires a live Qdrant server (QDRANT_URL)"]
841    async fn native_hybrid_search_end_to_end() {
842        let url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6334".into());
843        let store = QdrantVectorStore::new(
844            crate::QdrantConfig::new(url, "lc-native-hybrid-test")
845                .with_vector_size(4)
846                .with_distance(crate::QdrantDistance::Dot),
847        )
848        .await
849        .expect("connect to Qdrant");
850
851        let docs = vec![
852            Document::new("alpha document"),
853            Document::new("beta document"),
854            Document::new("gamma document"),
855        ];
856        let embeddings = vec![
857            vec![1.0, 0.0, 0.0, 0.0],
858            vec![0.0, 1.0, 0.0, 0.0],
859            vec![0.0, 0.0, 1.0, 0.0],
860        ];
861        store.add_documents(docs, embeddings).await.unwrap();
862
863        let query =
864            NativeHybridQuery::new(vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.9, 0.1, 0.0, 0.0]], 2)
865                .unwrap();
866        assert!(store.supports_native_hybrid());
867        let results = store.native_hybrid_search(&query).await.unwrap();
868        assert_eq!(results.len(), 2, "fused top-2");
869        assert_eq!(results[0].document.content, "alpha document");
870    }
871}