Skip to main content

zeph_memory/
vector_store.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Abstract vector-store trait and associated types.
5//!
6//! The [`VectorStore`] trait decouples the rest of `zeph-memory` from any specific
7//! vector database. Two implementations ship in this crate:
8//!
9//! - [`crate::qdrant_ops::QdrantOps`] / [`crate::embedding_store::EmbeddingStore`] —
10//!   production Qdrant-backed store.
11//! - [`crate::db_vector_store::DbVectorStore`] — `SQLite` BLOB store for testing and offline use.
12//! - [`crate::in_memory_store::InMemoryVectorStore`] — purely in-memory store for unit tests.
13
14use std::collections::HashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::atomic::AtomicBool;
18
19/// Error type for [`VectorStore`] operations.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum VectorStoreError {
23    #[error("connection error: {0}")]
24    Connection(String),
25    #[error("collection error: {0}")]
26    Collection(String),
27    #[error("upsert error: {0}")]
28    Upsert(String),
29    #[error("search error: {0}")]
30    Search(String),
31    #[error("delete error: {0}")]
32    Delete(String),
33    #[error("scroll error: {0}")]
34    Scroll(String),
35    #[error("serialization error: {0}")]
36    Serialization(String),
37    /// Operation is not supported by this backend (e.g. `get_points` on `DbVectorStore`).
38    #[error("operation unsupported: {0}")]
39    Unsupported(String),
40}
41
42/// A vector point to be stored in or retrieved from a [`VectorStore`].
43#[derive(Debug, Clone)]
44pub struct VectorPoint {
45    /// Unique string identifier for the point (e.g. a UUID).
46    pub id: String,
47    /// Dense embedding vector.
48    pub vector: Vec<f32>,
49    /// Arbitrary JSON metadata stored alongside the vector.
50    pub payload: HashMap<String, serde_json::Value>,
51}
52
53/// Filter applied to [`VectorStore::search`] and [`VectorStore::scroll_all`].
54///
55/// All `must` conditions are `ANDed`; all `must_not` conditions are `ANDed`.
56#[derive(Debug, Clone, Default)]
57pub struct VectorFilter {
58    /// All of these conditions must match.
59    pub must: Vec<FieldCondition>,
60    /// None of these conditions must match.
61    pub must_not: Vec<FieldCondition>,
62}
63
64/// A single payload field condition in a [`VectorFilter`].
65#[derive(Debug, Clone)]
66pub struct FieldCondition {
67    /// Payload field name.
68    pub field: String,
69    /// Expected value for the field.
70    pub value: FieldValue,
71}
72
73/// Value type in a [`FieldCondition`].
74#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub enum FieldValue {
77    /// Exact integer match.
78    Integer(i64),
79    /// Exact string match.
80    Text(String),
81}
82
83/// A vector point returned by [`VectorStore::search`] with an attached similarity score.
84#[derive(Debug, Clone)]
85pub struct ScoredVectorPoint {
86    /// Point identifier (matches [`VectorPoint::id`]).
87    pub id: String,
88    /// Cosine similarity score in `[0, 1]`.
89    pub score: f32,
90    /// Payload stored alongside the vector.
91    pub payload: HashMap<String, serde_json::Value>,
92}
93
94/// Shared return type alias for all [`VectorStore`] trait methods.
95///
96/// Intentionally `pub(crate)` — all [`VectorStore`] implementations are internal to this crate.
97/// If the trait is ever made externally extensible, this alias should become `pub`.
98pub(crate) type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
99
100/// Result of [`VectorStore::scroll_all`]: maps point ID → key → value payload strings.
101pub type ScrollResult = HashMap<String, HashMap<String, String>>;
102
103/// Result of [`VectorStore::scroll_all_with_point_ids`]: a list of `(point_id, string_fields)` pairs.
104///
105/// Only points whose payload contains `key_field` as a `StringValue` are included.
106pub type ScrollWithIdsResult = Vec<(String, HashMap<String, String>)>;
107
108/// Clamp a caller-supplied `search` `limit` to `[1, MAX_SEARCH_LIMIT]` at the
109/// [`VectorStore::search`] trait method itself (issue #6616).
110///
111/// The wrapper methods in `embedding_store`, `embedding_registry`, and `reasoning` already
112/// clamp before forwarding to a [`VectorStore`] implementor (issue #6553), but any caller
113/// that reaches an implementor directly — e.g. `zeph-index`'s `CodeStore::search` or a
114/// generic `V: VectorStore` pipeline step — bypasses those wrappers entirely. The
115/// trait-provided [`VectorStore::search`] calls this once before delegating to
116/// [`VectorStore::search_clamped`], so the bound holds regardless of the call path.
117/// Clamping an already-clamped value is a no-op, so enforcing it at both the wrapper and
118/// the trait layer is safe.
119fn clamp_search_limit(site: &'static str, limit: u64, warned: &AtomicBool) -> u64 {
120    if let Ok(requested) = usize::try_from(limit) {
121        crate::warn_if_search_limit_clamped(site, requested, warned);
122    }
123    limit.clamp(1, crate::MAX_SEARCH_LIMIT as u64)
124}
125
126/// Abstraction over a vector database backend.
127///
128/// Implementations must be `Send + Sync` so they can be wrapped in `Arc` and shared
129/// across async tasks. All methods return boxed futures via `BoxFuture` to remain
130/// object-safe.
131///
132/// # Implementations
133///
134/// | Type | Notes |
135/// |------|-------|
136/// | [`crate::embedding_store::EmbeddingStore`] | Qdrant-backed; production default. |
137/// | [`crate::db_vector_store::DbVectorStore`] | SQLite BLOB; offline / CI use. |
138/// | [`crate::in_memory_store::InMemoryVectorStore`] | Fully in-process; unit tests. |
139pub trait VectorStore: Send + Sync {
140    /// Create a collection with cosine-distance vectors of `vector_size` dimensions.
141    ///
142    /// Idempotent — no error if the collection already exists with the same dimension.
143    fn ensure_collection(
144        &self,
145        collection: &str,
146        vector_size: u64,
147    ) -> BoxFuture<'_, Result<(), VectorStoreError>>;
148
149    /// Returns `true` if `collection` exists in the backend.
150    fn collection_exists(&self, collection: &str) -> BoxFuture<'_, Result<bool, VectorStoreError>>;
151
152    /// Delete a collection and all its points.
153    fn delete_collection(&self, collection: &str) -> BoxFuture<'_, Result<(), VectorStoreError>>;
154
155    /// Upsert `points` into `collection`.
156    ///
157    /// Points with existing IDs are overwritten; new IDs are inserted.
158    fn upsert(
159        &self,
160        collection: &str,
161        points: Vec<VectorPoint>,
162    ) -> BoxFuture<'_, Result<(), VectorStoreError>>;
163
164    /// Search `collection` for the `limit` nearest neighbours of `vector`.
165    ///
166    /// Returns results in descending similarity order.  An optional [`VectorFilter`]
167    /// restricts the search space to points matching the payload conditions.
168    ///
169    /// `limit` is clamped to `[1, MAX_SEARCH_LIMIT]` before delegating to
170    /// [`Self::search_clamped`] — this is the sole choke point where the clamp is
171    /// enforced, regardless of which implementor handles the call. Implementors MUST
172    /// implement [`Self::search_clamped`], not override this method; overriding
173    /// `search` bypasses the clamp.
174    fn search(
175        &self,
176        collection: &str,
177        vector: Vec<f32>,
178        limit: u64,
179        filter: Option<VectorFilter>,
180    ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
181        let (site, warned) = self.search_clamp_diagnostics();
182        let limit = clamp_search_limit(site, limit, warned);
183        self.search_clamped(collection, vector, limit, filter)
184    }
185
186    /// Per-implementor diagnostic label and "already warned" flag backing [`Self::search`]'s
187    /// one-shot clamp warning.
188    ///
189    /// [`Self::search`] is one shared default-method body invoked identically for every
190    /// implementor, so a `static` declared directly inside it would be a single item shared
191    /// by *all* implementors — Rust does not duplicate function-local statics per
192    /// monomorphization, and a default method reached through `dyn VectorStore` compiles to
193    /// one shared body regardless of the concrete backend behind it. For the same reason, a
194    /// generic helper like `std::any::type_name::<Self>()` called from within that one shared
195    /// body cannot distinguish implementors either. Each implementor must therefore supply its
196    /// own label and flag here — a distinct `&'static str` identifying the concrete type (so an
197    /// operator can tell which backend logged the warning) and a reference to a local
198    /// `static AtomicBool` initialized to `false` — mirroring the per-call-site static already
199    /// used by `EmbeddingStore::search`, `EmbeddingRegistry::search_raw`, and
200    /// `ReasoningMemory::search` (see module docs).
201    ///
202    /// The flag this returns is per-*implementor-type*, not per-instance: every `Self` value
203    /// shares the one `static` declared in this method's body. This crate's own test suite
204    /// currently has exactly one `logs_contain(...)`-asserting clamp test per implementor type,
205    /// which is why that is safe today — a *second* such test against the same concrete type
206    /// would silently race on this same flag (the identical #6686 hazard this method exists to
207    /// prevent, just reintroduced one level up). If you add another oversized-limit clamp test
208    /// for a type that already has one, give the existing test's assertion double duty instead
209    /// of adding a second one.
210    fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool);
211
212    /// Backend-specific search implementation invoked by [`Self::search`].
213    ///
214    /// Do not call directly — call [`Self::search`], which clamps `limit` before
215    /// delegating here. Implementors MUST NOT re-clamp `limit`; it is guaranteed to
216    /// already be within `[1, MAX_SEARCH_LIMIT]`. Never call `Self::search` from here —
217    /// it re-enters this method (infinite recursion).
218    fn search_clamped(
219        &self,
220        collection: &str,
221        vector: Vec<f32>,
222        limit: u64,
223        filter: Option<VectorFilter>,
224    ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>>;
225
226    /// Delete specific points from `collection` by their string IDs.
227    fn delete_by_ids(
228        &self,
229        collection: &str,
230        ids: Vec<String>,
231    ) -> BoxFuture<'_, Result<(), VectorStoreError>>;
232
233    /// Scroll (paginate) all points in `collection` and return a map of
234    /// `point_id → { key_field → value }` payload entries.
235    fn scroll_all(
236        &self,
237        collection: &str,
238        key_field: &str,
239    ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>>;
240
241    /// Scroll all points in `collection`, returning `(point_id, string_payload_fields)` pairs.
242    ///
243    /// Only points whose payload contains `key_field` as a string value are included.
244    /// Unlike [`Self::scroll_all`], the Qdrant point ID is preserved as the first tuple element
245    /// rather than being used as the map key — this is required when consumers need to delete
246    /// points by their IDs (e.g. stale-embedding cleanup).
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the underlying scroll operation fails.
251    fn scroll_all_with_point_ids(
252        &self,
253        collection: &str,
254        key_field: &str,
255    ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>>;
256
257    /// Return `true` if the backend is reachable and operational.
258    fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>>;
259
260    /// Create keyword payload indexes for the given field names.
261    ///
262    /// Default implementation is a no-op (for non-Qdrant backends).
263    fn create_keyword_indexes(
264        &self,
265        _collection: &str,
266        _fields: &[&str],
267    ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
268        Box::pin(async { Ok(()) })
269    }
270
271    /// Batched vector + payload retrieval by point IDs.
272    ///
273    /// Returns one [`VectorPoint`] per matched id (missing ids are silently dropped).
274    /// Backends that cannot return vectors return `Err(VectorStoreError::Unsupported)`.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`VectorStoreError::Unsupported`] when the backend does not support
279    /// direct point retrieval with vectors (e.g. `DbVectorStore`, `InMemoryVectorStore`
280    /// unless overridden in tests).
281    fn get_points(
282        &self,
283        _collection: &str,
284        _ids: Vec<String>,
285    ) -> BoxFuture<'_, Result<Vec<VectorPoint>, VectorStoreError>> {
286        Box::pin(async {
287            Err(VectorStoreError::Unsupported(
288                "get_points not implemented for this backend".into(),
289            ))
290        })
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::sync::Arc;
298    use std::sync::atomic::{AtomicU64, Ordering};
299
300    /// Minimal [`VectorStore`] whose `search_clamped` records the `limit` it was given
301    /// instead of performing a real search, to prove the trait-provided `search` clamp
302    /// is structurally reached regardless of implementor.
303    struct RecordingStore {
304        last_limit: Arc<AtomicU64>,
305    }
306
307    impl VectorStore for RecordingStore {
308        fn ensure_collection(
309            &self,
310            _collection: &str,
311            _vector_size: u64,
312        ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
313            Box::pin(async { Ok(()) })
314        }
315
316        fn collection_exists(
317            &self,
318            _collection: &str,
319        ) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
320            Box::pin(async { Ok(true) })
321        }
322
323        fn delete_collection(
324            &self,
325            _collection: &str,
326        ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
327            Box::pin(async { Ok(()) })
328        }
329
330        fn upsert(
331            &self,
332            _collection: &str,
333            _points: Vec<VectorPoint>,
334        ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
335            Box::pin(async { Ok(()) })
336        }
337
338        fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool) {
339            static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
340            ("RecordingStore::search", &CLAMP_WARNED)
341        }
342
343        fn search_clamped(
344            &self,
345            _collection: &str,
346            _vector: Vec<f32>,
347            limit: u64,
348            _filter: Option<VectorFilter>,
349        ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
350            self.last_limit.store(limit, Ordering::SeqCst);
351            Box::pin(async { Ok(vec![]) })
352        }
353
354        fn delete_by_ids(
355            &self,
356            _collection: &str,
357            _ids: Vec<String>,
358        ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
359            Box::pin(async { Ok(()) })
360        }
361
362        fn scroll_all(
363            &self,
364            _collection: &str,
365            _key_field: &str,
366        ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>> {
367            Box::pin(async { Ok(ScrollResult::new()) })
368        }
369
370        fn scroll_all_with_point_ids(
371            &self,
372            _collection: &str,
373            _key_field: &str,
374        ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
375            Box::pin(async { Ok(Vec::new()) })
376        }
377
378        fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
379            Box::pin(async { Ok(true) })
380        }
381    }
382
383    #[tokio::test]
384    async fn search_clamps_oversized_limit_before_delegating() {
385        let last_limit = Arc::new(AtomicU64::new(0));
386        let store = RecordingStore {
387            last_limit: last_limit.clone(),
388        };
389
390        store
391            .search("collection", vec![0.0], u64::MAX, None)
392            .await
393            .unwrap();
394
395        assert_eq!(
396            last_limit.load(Ordering::SeqCst),
397            crate::MAX_SEARCH_LIMIT as u64
398        );
399    }
400
401    #[tokio::test]
402    async fn search_passes_small_limit_through_unclamped() {
403        let last_limit = Arc::new(AtomicU64::new(0));
404        let store = RecordingStore {
405            last_limit: last_limit.clone(),
406        };
407
408        store
409            .search("collection", vec![0.0], 5, None)
410            .await
411            .unwrap();
412
413        assert_eq!(last_limit.load(Ordering::SeqCst), 5);
414    }
415}