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