Skip to main content

lc_vector_stores/
qdrant.rs

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