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 static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
182 let limit = clamp_search_limit("VectorStore::search", limit, &CLAMP_WARNED);
183 self.search_clamped(collection, vector, limit, filter)
184 }
185
186 /// Backend-specific search implementation invoked by [`Self::search`].
187 ///
188 /// Do not call directly — call [`Self::search`], which clamps `limit` before
189 /// delegating here. Implementors MUST NOT re-clamp `limit`; it is guaranteed to
190 /// already be within `[1, MAX_SEARCH_LIMIT]`. Never call `Self::search` from here —
191 /// it re-enters this method (infinite recursion).
192 fn search_clamped(
193 &self,
194 collection: &str,
195 vector: Vec<f32>,
196 limit: u64,
197 filter: Option<VectorFilter>,
198 ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>>;
199
200 /// Delete specific points from `collection` by their string IDs.
201 fn delete_by_ids(
202 &self,
203 collection: &str,
204 ids: Vec<String>,
205 ) -> BoxFuture<'_, Result<(), VectorStoreError>>;
206
207 /// Scroll (paginate) all points in `collection` and return a map of
208 /// `point_id → { key_field → value }` payload entries.
209 fn scroll_all(
210 &self,
211 collection: &str,
212 key_field: &str,
213 ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>>;
214
215 /// Scroll all points in `collection`, returning `(point_id, string_payload_fields)` pairs.
216 ///
217 /// Only points whose payload contains `key_field` as a string value are included.
218 /// Unlike [`Self::scroll_all`], the Qdrant point ID is preserved as the first tuple element
219 /// rather than being used as the map key — this is required when consumers need to delete
220 /// points by their IDs (e.g. stale-embedding cleanup).
221 ///
222 /// # Errors
223 ///
224 /// Returns an error if the underlying scroll operation fails.
225 fn scroll_all_with_point_ids(
226 &self,
227 collection: &str,
228 key_field: &str,
229 ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>>;
230
231 /// Return `true` if the backend is reachable and operational.
232 fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>>;
233
234 /// Create keyword payload indexes for the given field names.
235 ///
236 /// Default implementation is a no-op (for non-Qdrant backends).
237 fn create_keyword_indexes(
238 &self,
239 _collection: &str,
240 _fields: &[&str],
241 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
242 Box::pin(async { Ok(()) })
243 }
244
245 /// Batched vector + payload retrieval by point IDs.
246 ///
247 /// Returns one [`VectorPoint`] per matched id (missing ids are silently dropped).
248 /// Backends that cannot return vectors return `Err(VectorStoreError::Unsupported)`.
249 ///
250 /// # Errors
251 ///
252 /// Returns [`VectorStoreError::Unsupported`] when the backend does not support
253 /// direct point retrieval with vectors (e.g. `DbVectorStore`, `InMemoryVectorStore`
254 /// unless overridden in tests).
255 fn get_points(
256 &self,
257 _collection: &str,
258 _ids: Vec<String>,
259 ) -> BoxFuture<'_, Result<Vec<VectorPoint>, VectorStoreError>> {
260 Box::pin(async {
261 Err(VectorStoreError::Unsupported(
262 "get_points not implemented for this backend".into(),
263 ))
264 })
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::sync::Arc;
272 use std::sync::atomic::{AtomicU64, Ordering};
273
274 /// Minimal [`VectorStore`] whose `search_clamped` records the `limit` it was given
275 /// instead of performing a real search, to prove the trait-provided `search` clamp
276 /// is structurally reached regardless of implementor.
277 struct RecordingStore {
278 last_limit: Arc<AtomicU64>,
279 }
280
281 impl VectorStore for RecordingStore {
282 fn ensure_collection(
283 &self,
284 _collection: &str,
285 _vector_size: u64,
286 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
287 Box::pin(async { Ok(()) })
288 }
289
290 fn collection_exists(
291 &self,
292 _collection: &str,
293 ) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
294 Box::pin(async { Ok(true) })
295 }
296
297 fn delete_collection(
298 &self,
299 _collection: &str,
300 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
301 Box::pin(async { Ok(()) })
302 }
303
304 fn upsert(
305 &self,
306 _collection: &str,
307 _points: Vec<VectorPoint>,
308 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
309 Box::pin(async { Ok(()) })
310 }
311
312 fn search_clamped(
313 &self,
314 _collection: &str,
315 _vector: Vec<f32>,
316 limit: u64,
317 _filter: Option<VectorFilter>,
318 ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
319 self.last_limit.store(limit, Ordering::SeqCst);
320 Box::pin(async { Ok(vec![]) })
321 }
322
323 fn delete_by_ids(
324 &self,
325 _collection: &str,
326 _ids: Vec<String>,
327 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
328 Box::pin(async { Ok(()) })
329 }
330
331 fn scroll_all(
332 &self,
333 _collection: &str,
334 _key_field: &str,
335 ) -> BoxFuture<'_, Result<ScrollResult, VectorStoreError>> {
336 Box::pin(async { Ok(ScrollResult::new()) })
337 }
338
339 fn scroll_all_with_point_ids(
340 &self,
341 _collection: &str,
342 _key_field: &str,
343 ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
344 Box::pin(async { Ok(Vec::new()) })
345 }
346
347 fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
348 Box::pin(async { Ok(true) })
349 }
350 }
351
352 #[tokio::test]
353 async fn search_clamps_oversized_limit_before_delegating() {
354 let last_limit = Arc::new(AtomicU64::new(0));
355 let store = RecordingStore {
356 last_limit: last_limit.clone(),
357 };
358
359 store
360 .search("collection", vec![0.0], u64::MAX, None)
361 .await
362 .unwrap();
363
364 assert_eq!(
365 last_limit.load(Ordering::SeqCst),
366 crate::MAX_SEARCH_LIMIT as u64
367 );
368 }
369
370 #[tokio::test]
371 async fn search_passes_small_limit_through_unclamped() {
372 let last_limit = Arc::new(AtomicU64::new(0));
373 let store = RecordingStore {
374 last_limit: last_limit.clone(),
375 };
376
377 store
378 .search("collection", vec![0.0], 5, None)
379 .await
380 .unwrap();
381
382 assert_eq!(last_limit.load(Ordering::SeqCst), 5);
383 }
384}