Skip to main content

zeph_memory/
qdrant_ops.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Low-level Qdrant operations shared across crates.
5//!
6//! [`QdrantOps`] is the single point of contact with the `qdrant-client` crate.
7//! All higher-level stores ([`crate::embedding_store::EmbeddingStore`],
8//! [`crate::embedding_registry::EmbeddingRegistry`]) route through this type.
9
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::time::Duration;
14
15use crate::vector_store::BoxFuture;
16use qdrant_client::Qdrant;
17use qdrant_client::qdrant::vector_output::Vector as VectorVariant;
18use qdrant_client::qdrant::{
19    CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter, GetPointsBuilder, PointId,
20    PointStruct, PointsIdsList, QueryPointsBuilder, ScoredPoint, ScrollPointsBuilder,
21    UpsertPointsBuilder, VectorParamsBuilder, value::Kind,
22};
23
24type QdrantResult<T> = Result<T, Box<qdrant_client::QdrantError>>;
25
26/// Default per-call timeout applied to every Qdrant gRPC operation (#5484).
27///
28/// Qdrant calls normally complete in well under a second; 10s bounds the await against a
29/// hung server or a stalled network path without misfiring on ordinary load spikes.
30/// Override via [`QdrantOps::with_timeout`].
31const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
32
33/// Thin wrapper over [`Qdrant`] client encapsulating common collection operations.
34#[derive(Clone)]
35pub struct QdrantOps {
36    client: Qdrant,
37    timeout: Duration,
38}
39
40impl std::fmt::Debug for QdrantOps {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("QdrantOps")
43            .field("timeout", &self.timeout)
44            .finish_non_exhaustive()
45    }
46}
47
48impl QdrantOps {
49    /// Create a new `QdrantOps` connected to the given URL with optional API key.
50    ///
51    /// When `api_key` is `Some(k)` and `k` is non-empty, the key is attached to the gRPC
52    /// client builder. Empty strings are treated as `None` so callers can pass empty config
53    /// values without accidentally disabling auth.
54    ///
55    /// The key is consumed by `qdrant-client` during `.build()` and is never stored on
56    /// `QdrantOps` itself.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the Qdrant client cannot be created (e.g. malformed URL).
61    pub fn new(url: &str, api_key: Option<&str>) -> QdrantResult<Self> {
62        let mut builder = Qdrant::from_url(url);
63        if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) {
64            builder = builder.api_key(key.trim());
65        }
66        let client = builder.build().map_err(Box::new)?;
67        Ok(Self {
68            client,
69            timeout: DEFAULT_TIMEOUT,
70        })
71    }
72
73    /// Override the per-call timeout applied to every Qdrant gRPC operation.
74    ///
75    /// Defaults to 10 seconds. A slow or hung Qdrant server would otherwise block the
76    /// calling async task indefinitely (#5484). The production bootstrap path drives this
77    /// from `MemoryConfig::qdrant_timeout_secs`.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use std::time::Duration;
83    /// use zeph_memory::QdrantOps;
84    ///
85    /// let ops = QdrantOps::new("http://localhost:6334", None)
86    ///     .unwrap()
87    ///     .with_timeout(Duration::from_secs(3));
88    /// ```
89    #[must_use]
90    pub fn with_timeout(mut self, timeout: Duration) -> Self {
91        self.timeout = timeout;
92        self
93    }
94
95    /// Access the underlying Qdrant client for advanced operations.
96    ///
97    /// # Warning
98    ///
99    /// Calls made directly through the returned client bypass the [`Self::with_timeout`]
100    /// guard (#5484) — they are not wrapped by [`Self::timed`]. Prefer the inherent
101    /// `QdrantOps` methods, which are all timeout-guarded, unless the client exposes an
102    /// operation this type does not wrap.
103    #[must_use]
104    pub fn client(&self) -> &Qdrant {
105        &self.client
106    }
107
108    /// Run a Qdrant gRPC future under the configured [`Self::with_timeout`] bound.
109    ///
110    /// Converts an elapsed timeout into `QdrantError::Io` so every existing call site
111    /// (which already returns `Result<T, Box<QdrantError>>`) needs no signature change.
112    ///
113    /// Takes a boxed future (rather than `impl Future`) so the timeout wrapper does not
114    /// inline the callee's gRPC future type into every caller's generated state machine —
115    /// with a generic parameter, that inlining compounds at each layer of async call
116    /// nesting and trips `clippy::large_futures` across the whole call chain.
117    async fn timed<T>(
118        &self,
119        fut: Pin<Box<dyn Future<Output = Result<T, qdrant_client::QdrantError>> + Send + '_>>,
120    ) -> QdrantResult<T> {
121        match tokio::time::timeout(self.timeout, fut).await {
122            Ok(result) => result.map_err(Box::new),
123            Err(_) => Err(Box::new(qdrant_client::QdrantError::Io(
124                std::io::Error::new(
125                    std::io::ErrorKind::TimedOut,
126                    format!("Qdrant gRPC call exceeded {:?}", self.timeout),
127                ),
128            ))),
129        }
130    }
131
132    /// Ensure a collection exists with cosine distance vectors.
133    ///
134    /// If the collection already exists but has a different vector dimension than `vector_size`,
135    /// the collection is deleted and recreated. All existing data in the collection is lost.
136    /// Callers on interactive/destructive-sensitive paths should pre-check via
137    /// [`Self::collection_exists`] + [`Self::get_collection_vector_size`] and gate the call
138    /// behind explicit confirmation instead of relying on this method's silent recreate (see
139    /// `zeph knowledge ingest`'s notes-sink resource setup for the reference pattern, #5444).
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if Qdrant cannot be reached or collection creation fails.
144    #[tracing::instrument(name = "memory.qdrant.ensure_collection", skip_all, err)]
145    pub async fn ensure_collection(&self, collection: &str, vector_size: u64) -> QdrantResult<()> {
146        if self
147            .timed(Box::pin(self.client.collection_exists(collection)))
148            .await?
149        {
150            let existing_size = self.get_collection_vector_size(collection).await?;
151            if existing_size == Some(vector_size) {
152                return Ok(());
153            }
154            tracing::warn!(
155                collection,
156                existing = ?existing_size,
157                required = vector_size,
158                "vector dimension mismatch — recreating collection (existing data will be lost)"
159            );
160            self.timed(Box::pin(self.client.delete_collection(collection)))
161                .await?;
162        }
163        self.timed(Box::pin(
164            self.client.create_collection(
165                CreateCollectionBuilder::new(collection)
166                    .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine)),
167            ),
168        ))
169        .await?;
170        Ok(())
171    }
172
173    /// Returns the configured vector size of an existing collection, or `None` if it cannot be
174    /// determined (e.g. named-vector collections).
175    ///
176    /// # Precondition
177    ///
178    /// The caller must already know the collection exists (e.g. via a prior
179    /// [`Self::collection_exists`] check that returned `true`). This method does **not** treat
180    /// "collection missing" and "dimension unreadable" as the same case: calling it on a
181    /// non-existent collection surfaces the underlying `collection_info` gRPC error (`Err`), it
182    /// does **not** return `Ok(None)`. Every existing call site (this type's own
183    /// [`Self::ensure_collection`], [`crate::EmbeddingRegistry`]) checks `collection_exists`
184    /// first for this reason.
185    ///
186    /// Used by [`crate::EmbeddingRegistry`] to validate query vector dimensions before issuing
187    /// gRPC searches. Qdrant gRPC silently returns near-zero cosine scores when the query vector
188    /// dimension does not match the stored collection dimension (unlike REST which returns 400).
189    /// Also used by callers that need to detect an [`Self::ensure_collection`] dimension mismatch
190    /// *before* invoking it, so a destructive recreate can be gated behind confirmation.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error on hard Qdrant communication failures, including calling this on a
195    /// collection that does not exist (see Precondition above).
196    #[tracing::instrument(
197        name = "memory.qdrant.get_collection_vector_size",
198        skip_all,
199        err,
200        level = "debug"
201    )]
202    pub async fn get_collection_vector_size(&self, collection: &str) -> QdrantResult<Option<u64>> {
203        let info = self
204            .timed(Box::pin(self.client.collection_info(collection)))
205            .await?;
206        let size = info
207            .result
208            .and_then(|r| r.config)
209            .and_then(|cfg| cfg.params)
210            .and_then(|params| params.vectors_config)
211            .and_then(|vc| vc.config)
212            .and_then(|cfg| match cfg {
213                qdrant_client::qdrant::vectors_config::Config::Params(vp) => Some(vp.size),
214                // Named-vector collections are not supported here; treat as unknown.
215                qdrant_client::qdrant::vectors_config::Config::ParamsMap(_) => None,
216            });
217        Ok(size)
218    }
219
220    /// Check whether a collection exists.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if Qdrant cannot be reached.
225    #[tracing::instrument(name = "memory.qdrant.collection_exists", skip_all, err)]
226    pub async fn collection_exists(&self, collection: &str) -> QdrantResult<bool> {
227        self.timed(Box::pin(self.client.collection_exists(collection)))
228            .await
229    }
230
231    /// Delete a collection.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the collection cannot be deleted.
236    #[tracing::instrument(name = "memory.qdrant.delete_collection", skip_all, err)]
237    pub async fn delete_collection(&self, collection: &str) -> QdrantResult<()> {
238        self.timed(Box::pin(self.client.delete_collection(collection)))
239            .await?;
240        Ok(())
241    }
242
243    /// Upsert points into a collection.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the upsert fails.
248    #[tracing::instrument(name = "memory.qdrant.upsert", skip_all, err)]
249    pub async fn upsert(&self, collection: &str, points: Vec<PointStruct>) -> QdrantResult<()> {
250        self.timed(Box::pin(self.client.upsert_points(
251            UpsertPointsBuilder::new(collection, points).wait(true),
252        )))
253        .await?;
254        Ok(())
255    }
256
257    /// Search for similar vectors, returning scored points with payloads.
258    ///
259    /// Uses the Qdrant Query API (`client.query`) which handles query vector normalization
260    /// server-side for Cosine collections, producing correct similarity scores.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the search fails.
265    #[tracing::instrument(name = "memory.qdrant.search", skip_all, err)]
266    pub async fn search(
267        &self,
268        collection: &str,
269        vector: Vec<f32>,
270        limit: u64,
271        filter: Option<Filter>,
272    ) -> QdrantResult<Vec<ScoredPoint>> {
273        let mut builder = QueryPointsBuilder::new(collection)
274            .query(vector)
275            .limit(limit)
276            .with_payload(true);
277        if let Some(f) = filter {
278            builder = builder.filter(f);
279        }
280        let results = self.timed(Box::pin(self.client.query(builder))).await?;
281        Ok(results.result)
282    }
283
284    /// Delete points by their IDs.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the deletion fails.
289    #[tracing::instrument(name = "memory.qdrant.delete_by_ids", skip_all, err)]
290    pub async fn delete_by_ids(&self, collection: &str, ids: Vec<PointId>) -> QdrantResult<()> {
291        if ids.is_empty() {
292            return Ok(());
293        }
294        self.timed(Box::pin(
295            self.client.delete_points(
296                DeletePointsBuilder::new(collection)
297                    .points(PointsIdsList { ids })
298                    .wait(true),
299            ),
300        ))
301        .await?;
302        Ok(())
303    }
304
305    /// Scroll all points in a collection, extracting string payload fields.
306    ///
307    /// Returns a map of `key_field` value -> { `field_name` -> `field_value` }.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the scroll operation fails.
312    #[tracing::instrument(name = "memory.qdrant.scroll_all", skip_all, err)]
313    pub async fn scroll_all(
314        &self,
315        collection: &str,
316        key_field: &str,
317    ) -> QdrantResult<HashMap<String, HashMap<String, String>>> {
318        let mut result = HashMap::new();
319        let mut offset: Option<PointId> = None;
320
321        loop {
322            let mut builder = ScrollPointsBuilder::new(collection)
323                .with_payload(true)
324                .with_vectors(false)
325                .limit(100);
326
327            if let Some(ref off) = offset {
328                builder = builder.offset(off.clone());
329            }
330
331            let response = self.timed(Box::pin(self.client.scroll(builder))).await?;
332
333            for point in &response.result {
334                let Some(key_val) = point.payload.get(key_field) else {
335                    continue;
336                };
337                let Some(Kind::StringValue(key)) = &key_val.kind else {
338                    continue;
339                };
340
341                let mut fields = HashMap::new();
342                for (k, val) in &point.payload {
343                    if let Some(Kind::StringValue(s)) = &val.kind {
344                        fields.insert(k.clone(), s.clone());
345                    }
346                }
347                result.insert(key.clone(), fields);
348            }
349
350            match response.next_page_offset {
351                Some(next) => offset = Some(next),
352                None => break,
353            }
354        }
355
356        Ok(result)
357    }
358
359    /// Scroll all points in a collection, returning `(point_id, string_payload_fields)` pairs.
360    ///
361    /// Only points whose payload contains `key_field` as a `StringValue` are included.
362    /// The Qdrant point ID is preserved as the first tuple element.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if the scroll operation fails.
367    #[tracing::instrument(name = "memory.qdrant.scroll_all_with_point_ids", skip_all, err)]
368    pub async fn scroll_all_with_point_ids(
369        &self,
370        collection: &str,
371        key_field: &str,
372    ) -> QdrantResult<Vec<(String, HashMap<String, String>)>> {
373        let mut result = Vec::new();
374        let mut offset: Option<PointId> = None;
375
376        loop {
377            let mut builder = ScrollPointsBuilder::new(collection)
378                .with_payload(true)
379                .with_vectors(false)
380                .limit(100);
381
382            if let Some(ref off) = offset {
383                builder = builder.offset(off.clone());
384            }
385
386            let response = self.timed(Box::pin(self.client.scroll(builder))).await?;
387
388            for point in &response.result {
389                let Some(key_val) = point.payload.get(key_field) else {
390                    continue;
391                };
392                let Some(Kind::StringValue(_)) = &key_val.kind else {
393                    continue;
394                };
395                let Some(point_id_str) = point_id_to_string(point.id.clone()) else {
396                    continue;
397                };
398
399                let mut fields = HashMap::new();
400                for (k, val) in &point.payload {
401                    if let Some(Kind::StringValue(s)) = &val.kind {
402                        fields.insert(k.clone(), s.clone());
403                    }
404                }
405                result.push((point_id_str, fields));
406            }
407
408            match response.next_page_offset {
409                Some(next) => offset = Some(next),
410                None => break,
411            }
412        }
413
414        Ok(result)
415    }
416
417    /// Create a collection with scalar INT8 quantization if it does not exist,
418    /// then create keyword indexes for the given fields.
419    ///
420    /// If the collection already exists but has a different vector dimension than `vector_size`,
421    /// the collection is deleted and recreated. All existing data in the collection is lost.
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if any Qdrant operation fails.
426    #[tracing::instrument(
427        name = "memory.qdrant.ensure_collection_with_quantization",
428        skip_all,
429        err
430    )]
431    pub async fn ensure_collection_with_quantization(
432        &self,
433        collection: &str,
434        vector_size: u64,
435        keyword_fields: &[&str],
436    ) -> Result<(), crate::VectorStoreError> {
437        use qdrant_client::qdrant::{
438            CreateFieldIndexCollectionBuilder, FieldType, ScalarQuantizationBuilder,
439        };
440        if self
441            .timed(Box::pin(self.client.collection_exists(collection)))
442            .await
443            .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?
444        {
445            let existing_size = self
446                .get_collection_vector_size(collection)
447                .await
448                .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
449            if existing_size == Some(vector_size) {
450                return Ok(());
451            }
452            tracing::warn!(
453                collection,
454                existing = ?existing_size,
455                required = vector_size,
456                "vector dimension mismatch — recreating collection (existing data will be lost)"
457            );
458            self.timed(Box::pin(self.client.delete_collection(collection)))
459                .await
460                .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
461        }
462        self.timed(Box::pin(
463            self.client.create_collection(
464                CreateCollectionBuilder::new(collection)
465                    .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine))
466                    .quantization_config(ScalarQuantizationBuilder::default()),
467            ),
468        ))
469        .await
470        .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
471
472        for field in keyword_fields {
473            self.timed(Box::pin(self.client.create_field_index(
474                CreateFieldIndexCollectionBuilder::new(collection, *field, FieldType::Keyword),
475            )))
476            .await
477            .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
478        }
479        Ok(())
480    }
481
482    /// Convert a JSON value to a Qdrant payload map.
483    ///
484    /// # Errors
485    ///
486    /// Returns a JSON error if deserialization fails.
487    pub fn json_to_payload(
488        value: serde_json::Value,
489    ) -> Result<HashMap<String, qdrant_client::qdrant::Value>, serde_json::Error> {
490        serde_json::from_value(value)
491    }
492}
493
494impl crate::vector_store::VectorStore for QdrantOps {
495    fn ensure_collection(
496        &self,
497        collection: &str,
498        vector_size: u64,
499    ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
500        let collection = collection.to_owned();
501        Box::pin(async move {
502            self.ensure_collection(&collection, vector_size)
503                .await
504                .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
505        })
506    }
507
508    fn collection_exists(
509        &self,
510        collection: &str,
511    ) -> BoxFuture<'_, Result<bool, crate::VectorStoreError>> {
512        let collection = collection.to_owned();
513        Box::pin(async move {
514            self.collection_exists(&collection)
515                .await
516                .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
517        })
518    }
519
520    fn delete_collection(
521        &self,
522        collection: &str,
523    ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
524        let collection = collection.to_owned();
525        Box::pin(async move {
526            self.delete_collection(&collection)
527                .await
528                .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))
529        })
530    }
531
532    fn upsert(
533        &self,
534        collection: &str,
535        points: Vec<crate::VectorPoint>,
536    ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
537        let collection = collection.to_owned();
538        Box::pin(async move {
539            let qdrant_points: Vec<PointStruct> = points
540                .into_iter()
541                .map(|p| {
542                    let payload: HashMap<String, qdrant_client::qdrant::Value> =
543                        serde_json::from_value(serde_json::Value::Object(
544                            p.payload.into_iter().collect(),
545                        ))
546                        .unwrap_or_default();
547                    PointStruct::new(p.id, p.vector, payload)
548                })
549                .collect();
550            self.upsert(&collection, qdrant_points)
551                .await
552                .map_err(|e| crate::VectorStoreError::Upsert(e.to_string()))
553        })
554    }
555
556    fn search(
557        &self,
558        collection: &str,
559        vector: Vec<f32>,
560        limit: u64,
561        filter: Option<crate::VectorFilter>,
562    ) -> BoxFuture<'_, Result<Vec<crate::ScoredVectorPoint>, crate::VectorStoreError>> {
563        let collection = collection.to_owned();
564        Box::pin(async move {
565            let qdrant_filter = filter.map(vector_filter_to_qdrant);
566            let results = self
567                .search(&collection, vector, limit, qdrant_filter)
568                .await
569                .map_err(|e| crate::VectorStoreError::Search(e.to_string()))?;
570            Ok(results.into_iter().map(scored_point_to_vector).collect())
571        })
572    }
573
574    fn delete_by_ids(
575        &self,
576        collection: &str,
577        ids: Vec<String>,
578    ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
579        let collection = collection.to_owned();
580        Box::pin(async move {
581            let point_ids: Vec<PointId> = ids.into_iter().map(PointId::from).collect();
582            self.delete_by_ids(&collection, point_ids)
583                .await
584                .map_err(|e| crate::VectorStoreError::Delete(e.to_string()))
585        })
586    }
587
588    fn scroll_all(
589        &self,
590        collection: &str,
591        key_field: &str,
592    ) -> BoxFuture<'_, Result<HashMap<String, HashMap<String, String>>, crate::VectorStoreError>>
593    {
594        let collection = collection.to_owned();
595        let key_field = key_field.to_owned();
596        Box::pin(async move {
597            self.scroll_all(&collection, &key_field)
598                .await
599                .map_err(|e| crate::VectorStoreError::Scroll(e.to_string()))
600        })
601    }
602
603    fn scroll_all_with_point_ids(
604        &self,
605        collection: &str,
606        key_field: &str,
607    ) -> BoxFuture<'_, Result<crate::vector_store::ScrollWithIdsResult, crate::VectorStoreError>>
608    {
609        let collection = collection.to_owned();
610        let key_field = key_field.to_owned();
611        Box::pin(async move {
612            self.scroll_all_with_point_ids(&collection, &key_field)
613                .await
614                .map_err(|e| crate::VectorStoreError::Scroll(e.to_string()))
615        })
616    }
617
618    fn health_check(&self) -> BoxFuture<'_, Result<bool, crate::VectorStoreError>> {
619        use tracing::Instrument as _;
620        Box::pin(
621            async move {
622                match self.timed(Box::pin(self.client.health_check())).await {
623                    Ok(_) => Ok(true),
624                    Err(e) => {
625                        tracing::warn!(err = %e, "health_check failed");
626                        Err(crate::VectorStoreError::Collection(e.to_string()))
627                    }
628                }
629            }
630            .instrument(tracing::debug_span!("memory.qdrant.health_check")),
631        )
632    }
633
634    fn create_keyword_indexes(
635        &self,
636        collection: &str,
637        fields: &[&str],
638    ) -> BoxFuture<'_, Result<(), crate::VectorStoreError>> {
639        use qdrant_client::qdrant::{CreateFieldIndexCollectionBuilder, FieldType};
640        use tracing::Instrument as _;
641        let collection = collection.to_owned();
642        let fields: Vec<String> = fields.iter().map(|f| (*f).to_owned()).collect();
643        Box::pin(
644            async move {
645                for field in &fields {
646                    self.timed(Box::pin(self.client.create_field_index(
647                        CreateFieldIndexCollectionBuilder::new(
648                            &collection,
649                            field.as_str(),
650                            FieldType::Keyword,
651                        ),
652                    )))
653                    .await
654                    .map_err(|e| crate::VectorStoreError::Collection(e.to_string()))?;
655                }
656                Ok(())
657            }
658            .instrument(tracing::debug_span!("memory.qdrant.create_keyword_indexes")),
659        )
660    }
661
662    fn get_points(
663        &self,
664        collection: &str,
665        ids: Vec<String>,
666    ) -> BoxFuture<'_, Result<Vec<crate::VectorPoint>, crate::VectorStoreError>> {
667        use tracing::Instrument as _;
668        let collection = collection.to_owned();
669        Box::pin(
670            async move {
671                if ids.is_empty() {
672                    return Ok(Vec::new());
673                }
674                let point_ids: Vec<PointId> = ids.into_iter().map(PointId::from).collect();
675                let response = self
676                    .timed(Box::pin(
677                        self.client.get_points(
678                            GetPointsBuilder::new(&collection, point_ids)
679                                .with_vectors(true)
680                                .with_payload(true),
681                        ),
682                    ))
683                    .await
684                    .map_err(|e| {
685                        tracing::error!(err = %e, "get_points failed");
686                        crate::VectorStoreError::Search(e.to_string())
687                    })?;
688
689                let mut result = Vec::with_capacity(response.result.len());
690                for point in response.result {
691                    let Some(id_str) = point_id_to_string(point.id) else {
692                        continue;
693                    };
694                    // Use VectorsOutput::get_vector() to extract the default dense vector.
695                    let vector = match point.vectors.and_then(|v| v.get_vector()) {
696                        Some(VectorVariant::Dense(dv)) => dv.data,
697                        _ => continue,
698                    };
699                    let payload: HashMap<String, serde_json::Value> = point
700                        .payload
701                        .into_iter()
702                        .filter_map(|(k, v)| {
703                            let json = qdrant_value_to_json(v.kind?)?;
704                            Some((k, json))
705                        })
706                        .collect();
707                    result.push(crate::VectorPoint {
708                        id: id_str,
709                        vector,
710                        payload,
711                    });
712                }
713                Ok(result)
714            }
715            .instrument(tracing::debug_span!("memory.qdrant.get_points")),
716        )
717    }
718}
719
720fn vector_filter_to_qdrant(filter: crate::VectorFilter) -> Filter {
721    let must: Vec<_> = filter
722        .must
723        .into_iter()
724        .map(field_condition_to_qdrant)
725        .collect();
726    let must_not: Vec<_> = filter
727        .must_not
728        .into_iter()
729        .map(field_condition_to_qdrant)
730        .collect();
731
732    let mut f = Filter::default();
733    if !must.is_empty() {
734        f.must = must;
735    }
736    if !must_not.is_empty() {
737        f.must_not = must_not;
738    }
739    f
740}
741
742fn field_condition_to_qdrant(cond: crate::FieldCondition) -> qdrant_client::qdrant::Condition {
743    match cond.value {
744        crate::FieldValue::Integer(v) => qdrant_client::qdrant::Condition::matches(cond.field, v),
745        crate::FieldValue::Text(v) => qdrant_client::qdrant::Condition::matches(cond.field, v),
746    }
747}
748
749/// Convert a Qdrant [`qdrant_client::qdrant::PointId`] to its string representation.
750///
751/// Returns `None` when the id variant is unrecognised.
752fn point_id_to_string(pid: Option<qdrant_client::qdrant::PointId>) -> Option<String> {
753    match pid?.point_id_options? {
754        qdrant_client::qdrant::point_id::PointIdOptions::Uuid(u) => Some(u),
755        qdrant_client::qdrant::point_id::PointIdOptions::Num(n) => Some(n.to_string()),
756    }
757}
758
759/// Convert a Qdrant [`Kind`] to a `serde_json::Value`.
760///
761/// Returns `None` for unsupported kinds (structs, lists, nulls).
762fn qdrant_value_to_json(kind: Kind) -> Option<serde_json::Value> {
763    match kind {
764        Kind::StringValue(s) => Some(serde_json::Value::String(s)),
765        Kind::IntegerValue(i) => Some(serde_json::Value::Number(i.into())),
766        Kind::DoubleValue(d) => serde_json::Number::from_f64(d).map(serde_json::Value::Number),
767        Kind::BoolValue(b) => Some(serde_json::Value::Bool(b)),
768        _ => None,
769    }
770}
771
772fn scored_point_to_vector(point: ScoredPoint) -> crate::ScoredVectorPoint {
773    let payload: HashMap<String, serde_json::Value> = point
774        .payload
775        .into_iter()
776        .filter_map(|(k, v)| Some((k, qdrant_value_to_json(v.kind?)?)))
777        .collect();
778
779    let id = point_id_to_string(point.id).unwrap_or_default();
780
781    crate::ScoredVectorPoint {
782        id,
783        score: point.score,
784        payload,
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn new_valid_url() {
794        let ops = QdrantOps::new("http://localhost:6334", None);
795        assert!(ops.is_ok());
796    }
797
798    #[test]
799    fn new_invalid_url() {
800        let ops = QdrantOps::new("not a valid url", None);
801        assert!(ops.is_err());
802    }
803
804    /// Empty `api_key` must be silently treated as `None` — the whitespace guard in
805    /// `QdrantOps::new` must not panic and the client must be built successfully.
806    #[test]
807    fn new_empty_api_key_is_treated_as_none() {
808        let result = QdrantOps::new("http://127.0.0.1:9999", Some(""));
809        assert!(result.is_ok(), "empty key must not cause a build error");
810    }
811
812    /// Whitespace-only keys must be dropped the same way as empty keys.
813    #[test]
814    fn new_whitespace_api_key_is_treated_as_none() {
815        let result = QdrantOps::new("http://127.0.0.1:9999", Some("   "));
816        assert!(
817            result.is_ok(),
818            "whitespace-only key must not cause a build error"
819        );
820    }
821
822    /// A non-empty, non-whitespace key must be accepted without errors.
823    #[test]
824    fn new_with_api_key_constructs_successfully() {
825        let result = QdrantOps::new("http://127.0.0.1:9999", Some("valid-key"));
826        assert!(result.is_ok(), "valid key must not cause a build error");
827    }
828
829    /// `new` must apply [`DEFAULT_TIMEOUT`] and [`QdrantOps::with_timeout`] must override it
830    /// (#5484). The override is observable via the `Debug` impl since `timeout` is private.
831    #[test]
832    fn with_timeout_overrides_default() {
833        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
834        assert!(format!("{ops:?}").contains("10s"), "default must be 10s");
835
836        let ops = ops.with_timeout(Duration::from_secs(2));
837        assert!(
838            format!("{ops:?}").contains("2s"),
839            "with_timeout must override the default"
840        );
841    }
842
843    /// `timed` must return an error instead of hanging forever when the wrapped future never
844    /// resolves — the core guarantee of #5484.
845    #[tokio::test]
846    async fn timed_returns_error_instead_of_hanging() {
847        let ops = QdrantOps::new("http://localhost:6334", None)
848            .unwrap()
849            .with_timeout(Duration::from_millis(10));
850
851        let never_resolves: Pin<
852            Box<dyn Future<Output = Result<(), qdrant_client::QdrantError>> + Send>,
853        > = Box::pin(std::future::pending());
854
855        let result = ops.timed(never_resolves).await;
856        assert!(result.is_err(), "must time out instead of hanging forever");
857    }
858
859    #[test]
860    fn debug_format() {
861        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
862        let dbg = format!("{ops:?}");
863        assert!(dbg.contains("QdrantOps"));
864    }
865
866    #[test]
867    fn json_to_payload_valid() {
868        let value = serde_json::json!({"key": "value", "num": 42});
869        let result = QdrantOps::json_to_payload(value);
870        assert!(result.is_ok());
871    }
872
873    #[test]
874    fn json_to_payload_empty() {
875        let result = QdrantOps::json_to_payload(serde_json::json!({}));
876        assert!(result.is_ok());
877        assert!(result.unwrap().is_empty());
878    }
879
880    #[test]
881    fn delete_by_ids_empty_is_ok_sync() {
882        // Constructing QdrantOps with a valid URL succeeds even without a live server.
883        // delete_by_ids with empty list short-circuits before any network call.
884        // We validate the early-return logic via the async test below.
885        let ops = QdrantOps::new("http://localhost:6334", None);
886        assert!(ops.is_ok());
887    }
888
889    /// Requires a live Qdrant instance at localhost:6334.
890    #[tokio::test]
891    #[ignore = "requires a live Qdrant instance at localhost:6334"]
892    async fn ensure_collection_with_quantization_idempotent() {
893        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
894        let collection = "test_quant_idempotent";
895
896        // Clean up from any prior run
897        let _ = ops.delete_collection(collection).await;
898
899        // First call — creates collection
900        ops.ensure_collection_with_quantization(collection, 128, &["language", "file_path"])
901            .await
902            .unwrap();
903
904        assert!(ops.collection_exists(collection).await.unwrap());
905
906        // Second call — idempotent, must not error
907        ops.ensure_collection_with_quantization(collection, 128, &["language", "file_path"])
908            .await
909            .unwrap();
910
911        // Cleanup
912        ops.delete_collection(collection).await.unwrap();
913    }
914
915    /// Requires a live Qdrant instance at localhost:6334.
916    #[tokio::test]
917    #[ignore = "requires a live Qdrant instance at localhost:6334"]
918    async fn delete_by_ids_empty_no_network_call() {
919        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
920        // Empty ID list must short-circuit and return Ok without hitting Qdrant.
921        let result = ops.delete_by_ids("nonexistent_collection", vec![]).await;
922        assert!(result.is_ok());
923    }
924
925    /// Requires a live Qdrant instance at localhost:6334.
926    #[tokio::test]
927    #[ignore = "requires a live Qdrant instance at localhost:6334"]
928    async fn ensure_collection_idempotent_same_size() {
929        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
930        let collection = "test_ensure_idempotent";
931
932        let _ = ops.delete_collection(collection).await;
933
934        ops.ensure_collection(collection, 128).await.unwrap();
935        assert!(ops.collection_exists(collection).await.unwrap());
936
937        // Second call with same size must be a no-op.
938        ops.ensure_collection(collection, 128).await.unwrap();
939        assert!(ops.collection_exists(collection).await.unwrap());
940
941        ops.delete_collection(collection).await.unwrap();
942    }
943
944    /// Requires a live Qdrant instance at localhost:6334.
945    ///
946    /// Verifies that `ensure_collection` detects a vector dimension mismatch and
947    /// recreates the collection instead of silently reusing the wrong-dimension one.
948    #[tokio::test]
949    #[ignore = "requires a live Qdrant instance at localhost:6334"]
950    async fn ensure_collection_recreates_on_dimension_mismatch() {
951        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
952        let collection = "test_dim_mismatch";
953
954        let _ = ops.delete_collection(collection).await;
955
956        // Create with 128 dims.
957        ops.ensure_collection(collection, 128).await.unwrap();
958        assert_eq!(
959            ops.get_collection_vector_size(collection).await.unwrap(),
960            Some(128)
961        );
962
963        // Call again with a different size — must recreate.
964        ops.ensure_collection(collection, 256).await.unwrap();
965        assert_eq!(
966            ops.get_collection_vector_size(collection).await.unwrap(),
967            Some(256),
968            "collection must have been recreated with the new dimension"
969        );
970
971        ops.delete_collection(collection).await.unwrap();
972    }
973
974    /// Requires a live Qdrant instance at localhost:6334.
975    ///
976    /// Verifies that `ensure_collection_with_quantization` also detects dimension mismatch.
977    #[tokio::test]
978    #[ignore = "requires a live Qdrant instance at localhost:6334"]
979    async fn ensure_collection_with_quantization_recreates_on_dimension_mismatch() {
980        let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
981        let collection = "test_quant_dim_mismatch";
982
983        let _ = ops.delete_collection(collection).await;
984
985        ops.ensure_collection_with_quantization(collection, 128, &["language"])
986            .await
987            .unwrap();
988        assert_eq!(
989            ops.get_collection_vector_size(collection).await.unwrap(),
990            Some(128)
991        );
992
993        // Call again with a different size — must recreate.
994        ops.ensure_collection_with_quantization(collection, 384, &["language"])
995            .await
996            .unwrap();
997        assert_eq!(
998            ops.get_collection_vector_size(collection).await.unwrap(),
999            Some(384),
1000            "collection must have been recreated with the new dimension"
1001        );
1002
1003        ops.delete_collection(collection).await.unwrap();
1004    }
1005}