Skip to main content

rskit_vectorstore/
memory.rs

1//! In-memory vector store implementation for testing.
2
3use std::collections::HashMap;
4
5use async_trait::async_trait;
6use parking_lot::Mutex as ParkingMutex;
7use rskit_errors::{AppError, AppResult, ErrorCode};
8use tracing::debug;
9
10use crate::config::VectorStoreLimits;
11use crate::store::{PointPayload, SearchFilter, SearchResult, SimilarityMetric, VectorStore};
12
13struct StoredPoint {
14    id: String,
15    vector: Vec<f32>,
16    payload: PointPayload,
17}
18
19struct Collection {
20    dimensions: usize,
21    metric: SimilarityMetric,
22    points: Vec<StoredPoint>,
23}
24
25/// In-memory vector store backed by a simple `Vec` with linear scan search.
26///
27/// Intended for unit tests and prototyping — not suitable for production workloads.
28pub struct InMemoryVectorStore {
29    default_metric: SimilarityMetric,
30    limits: VectorStoreLimits,
31    collections: ParkingMutex<HashMap<String, Collection>>,
32}
33
34impl InMemoryVectorStore {
35    /// Create a new empty in-memory vector store.
36    pub fn new() -> Self {
37        Self::with_metric(SimilarityMetric::Cosine)
38    }
39
40    /// Create a new empty in-memory vector store with the metric used for new collections.
41    #[must_use]
42    pub fn with_metric(default_metric: SimilarityMetric) -> Self {
43        Self::with_options(default_metric, VectorStoreLimits::default())
44    }
45
46    /// Create a new empty in-memory vector store with explicit safety limits.
47    #[must_use]
48    pub fn with_options(default_metric: SimilarityMetric, limits: VectorStoreLimits) -> Self {
49        Self {
50            default_metric,
51            limits,
52            collections: ParkingMutex::new(HashMap::new()),
53        }
54    }
55}
56
57impl Default for InMemoryVectorStore {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
64    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
65    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
66    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
67    if norm_a == 0.0 || norm_b == 0.0 {
68        return 0.0;
69    }
70    dot / (norm_a * norm_b)
71}
72
73fn dot_product(a: &[f32], b: &[f32]) -> f32 {
74    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
75}
76
77fn l2_score(a: &[f32], b: &[f32]) -> f32 {
78    -a.iter()
79        .zip(b.iter())
80        .map(|(x, y)| {
81            let delta = x - y;
82            delta * delta
83        })
84        .sum::<f32>()
85        .sqrt()
86}
87
88fn similarity_score(metric: SimilarityMetric, a: &[f32], b: &[f32]) -> f32 {
89    match metric {
90        SimilarityMetric::Cosine => cosine_similarity(a, b),
91        SimilarityMetric::Dot => dot_product(a, b),
92        SimilarityMetric::L2 => l2_score(a, b),
93    }
94}
95
96fn matches_filter(payload: &PointPayload, filter: &SearchFilter) -> bool {
97    for condition in &filter.must {
98        match payload.fields.get(&condition.field) {
99            Some(actual) if actual == &condition.equals => {}
100            _ => return false,
101        }
102    }
103    true
104}
105
106fn compare_score_desc(a: f32, b: f32) -> std::cmp::Ordering {
107    b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal)
108}
109
110#[async_trait]
111impl VectorStore for InMemoryVectorStore {
112    async fn ensure_collection(&self, collection: &str, dimensions: usize) -> AppResult<()> {
113        self.limits.validate_dimensions(dimensions)?;
114        let mut collections = self.collections.lock();
115        collections
116            .entry(collection.to_string())
117            .or_insert_with(|| Collection {
118                dimensions,
119                metric: self.default_metric,
120                points: Vec::new(),
121            });
122        Ok(())
123    }
124
125    async fn upsert(
126        &self,
127        collection: &str,
128        id: &str,
129        vector: Vec<f32>,
130        payload: PointPayload,
131    ) -> AppResult<()> {
132        debug!(collection, id, "InMemory: upserting vector point");
133
134        self.limits.validate_dimensions(vector.len())?;
135        payload.validate_limits(&self.limits)?;
136
137        let mut collections = self.collections.lock();
138
139        let col = collections.get_mut(collection).ok_or_else(|| {
140            AppError::new(
141                ErrorCode::NotFound,
142                format!("collection '{collection}' does not exist"),
143            )
144        })?;
145
146        if vector.len() != col.dimensions {
147            return Err(AppError::new(
148                ErrorCode::InvalidInput,
149                format!(
150                    "vector dimensions mismatch: expected {}, got {}",
151                    col.dimensions,
152                    vector.len()
153                ),
154            ));
155        }
156
157        // Update existing or insert new
158        if let Some(point) = col.points.iter_mut().find(|p| p.id == id) {
159            point.vector = vector;
160            point.payload = payload;
161        } else {
162            col.points.push(StoredPoint {
163                id: id.to_string(),
164                vector,
165                payload,
166            });
167        }
168
169        Ok(())
170    }
171
172    async fn search(
173        &self,
174        collection: &str,
175        vector: Vec<f32>,
176        limit: usize,
177        filter: Option<SearchFilter>,
178    ) -> AppResult<Vec<SearchResult>> {
179        debug!(collection, limit, "InMemory: searching vectors");
180
181        validate_search(limit, filter.as_ref(), &self.limits)?;
182        self.limits.validate_dimensions(vector.len())?;
183
184        let collections = self.collections.lock();
185
186        let col = collections.get(collection).ok_or_else(|| {
187            AppError::new(
188                ErrorCode::NotFound,
189                format!("collection '{collection}' does not exist"),
190            )
191        })?;
192
193        if vector.len() != col.dimensions {
194            return Err(AppError::new(
195                ErrorCode::InvalidInput,
196                format!(
197                    "vector dimensions mismatch: expected {}, got {}",
198                    col.dimensions,
199                    vector.len()
200                ),
201            ));
202        }
203
204        fn validate_search(
205            limit: usize,
206            filter: Option<&SearchFilter>,
207            limits: &VectorStoreLimits,
208        ) -> AppResult<()> {
209            limits.validate_search_limit(limit)?;
210            if let Some(filter) = filter {
211                filter.validate_limits(limits)?;
212            }
213            Ok(())
214        }
215
216        let mut scored: Vec<SearchResult> = col
217            .points
218            .iter()
219            .filter(|p| {
220                filter
221                    .as_ref()
222                    .is_none_or(|f| matches_filter(&p.payload, f))
223            })
224            .map(|p| SearchResult {
225                id: p.id.clone(),
226                score: similarity_score(col.metric, &vector, &p.vector),
227                payload: p.payload.clone(),
228            })
229            .collect();
230
231        scored.sort_by(|a, b| compare_score_desc(a.score, b.score));
232        scored.truncate(limit);
233
234        Ok(scored)
235    }
236
237    async fn delete(&self, collection: &str, id: &str) -> AppResult<()> {
238        debug!(collection, id, "InMemory: deleting vector point");
239
240        let mut collections = self.collections.lock();
241
242        let col = collections.get_mut(collection).ok_or_else(|| {
243            AppError::new(
244                ErrorCode::NotFound,
245                format!("collection '{collection}' does not exist"),
246            )
247        })?;
248
249        col.points.retain(|p| p.id != id);
250        Ok(())
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[tokio::test]
259    async fn test_ensure_collection_creates_new() {
260        let store = InMemoryVectorStore::new();
261        store.ensure_collection("test", 3).await.unwrap();
262        // Should not error when called again
263        store.ensure_collection("test", 3).await.unwrap();
264    }
265
266    #[tokio::test]
267    async fn test_upsert_and_search() {
268        let store = InMemoryVectorStore::new();
269        store.ensure_collection("test", 3).await.unwrap();
270
271        let payload = PointPayload::new().with_field("name", "doc1");
272        store
273            .upsert("test", "1", vec![1.0, 0.0, 0.0], payload)
274            .await
275            .unwrap();
276
277        let payload = PointPayload::new().with_field("name", "doc2");
278        store
279            .upsert("test", "2", vec![0.0, 1.0, 0.0], payload)
280            .await
281            .unwrap();
282
283        let results = store
284            .search("test", vec![1.0, 0.0, 0.0], 10, None)
285            .await
286            .unwrap();
287
288        assert_eq!(results.len(), 2);
289        assert_eq!(results[0].id, "1");
290        assert!((results[0].score - 1.0).abs() < 1e-6);
291    }
292
293    #[tokio::test]
294    async fn configured_metric_is_used_for_new_collections() {
295        let store = InMemoryVectorStore::with_metric(SimilarityMetric::Dot);
296        store.ensure_collection("test", 2).await.unwrap();
297
298        store
299            .upsert("test", "long", vec![10.0, 0.0], PointPayload::new())
300            .await
301            .unwrap();
302        store
303            .upsert("test", "unit", vec![1.0, 0.0], PointPayload::new())
304            .await
305            .unwrap();
306
307        let results = store
308            .search("test", vec![1.0, 0.0], 10, None)
309            .await
310            .unwrap();
311
312        assert_eq!(results[0].id, "long");
313        assert_eq!(results[0].score, 10.0);
314    }
315
316    #[tokio::test]
317    async fn test_upsert_updates_existing() {
318        let store = InMemoryVectorStore::new();
319        store.ensure_collection("test", 2).await.unwrap();
320
321        let payload = PointPayload::new().with_field("v", "old");
322        store
323            .upsert("test", "1", vec![1.0, 0.0], payload)
324            .await
325            .unwrap();
326
327        let payload = PointPayload::new().with_field("v", "new");
328        store
329            .upsert("test", "1", vec![0.0, 1.0], payload)
330            .await
331            .unwrap();
332
333        let results = store
334            .search("test", vec![0.0, 1.0], 10, None)
335            .await
336            .unwrap();
337
338        assert_eq!(results.len(), 1);
339        assert_eq!(results[0].id, "1");
340        assert_eq!(
341            results[0].payload.fields.get("v").and_then(|v| v.as_str()),
342            Some("new")
343        );
344    }
345
346    #[tokio::test]
347    async fn test_search_with_filter() {
348        let store = InMemoryVectorStore::new();
349        store.ensure_collection("test", 2).await.unwrap();
350
351        store
352            .upsert(
353                "test",
354                "1",
355                vec![1.0, 0.0],
356                PointPayload::new().with_field("type", "a"),
357            )
358            .await
359            .unwrap();
360
361        store
362            .upsert(
363                "test",
364                "2",
365                vec![1.0, 0.0],
366                PointPayload::new().with_field("type", "b"),
367            )
368            .await
369            .unwrap();
370
371        let filter = SearchFilter::new().must_match("type", "a");
372        let results = store
373            .search("test", vec![1.0, 0.0], 10, Some(filter))
374            .await
375            .unwrap();
376
377        assert_eq!(results.len(), 1);
378        assert_eq!(results[0].id, "1");
379    }
380
381    #[tokio::test]
382    async fn test_delete() {
383        let store = InMemoryVectorStore::new();
384        store.ensure_collection("test", 2).await.unwrap();
385
386        store
387            .upsert("test", "1", vec![1.0, 0.0], PointPayload::new())
388            .await
389            .unwrap();
390
391        store.delete("test", "1").await.unwrap();
392
393        let results = store
394            .search("test", vec![1.0, 0.0], 10, None)
395            .await
396            .unwrap();
397        assert!(results.is_empty());
398    }
399
400    #[tokio::test]
401    async fn test_upsert_wrong_dimensions() {
402        let store = InMemoryVectorStore::new();
403        store.ensure_collection("test", 3).await.unwrap();
404
405        let result = store
406            .upsert("test", "1", vec![1.0, 0.0], PointPayload::new())
407            .await;
408
409        assert!(result.is_err());
410    }
411
412    #[tokio::test]
413    async fn test_search_wrong_dimensions_returns_invalid_input() {
414        let store = InMemoryVectorStore::new();
415        store.ensure_collection("test", 3).await.unwrap();
416
417        let err = store
418            .search("test", vec![1.0, 0.0], 10, None)
419            .await
420            .expect_err("dimension mismatch must fail");
421
422        assert_eq!(err.code(), ErrorCode::InvalidInput);
423    }
424
425    #[tokio::test]
426    async fn search_rejects_limit_above_configured_bound() {
427        let store = InMemoryVectorStore::with_options(
428            SimilarityMetric::Cosine,
429            VectorStoreLimits::new().with_max_search_limit(1),
430        );
431        store.ensure_collection("test", 2).await.unwrap();
432
433        let err = store
434            .search("test", vec![1.0, 0.0], 2, None)
435            .await
436            .expect_err("search limit above configured bound must fail");
437
438        assert_eq!(err.code(), ErrorCode::InvalidInput);
439    }
440
441    #[tokio::test]
442    async fn upsert_rejects_payload_above_configured_bound() {
443        let store = InMemoryVectorStore::with_options(
444            SimilarityMetric::Cosine,
445            VectorStoreLimits::new().with_max_payload_bytes(4),
446        );
447        store.ensure_collection("test", 2).await.unwrap();
448
449        let err = store
450            .upsert(
451                "test",
452                "1",
453                vec![1.0, 0.0],
454                PointPayload::new().with_field("name", "too-large"),
455            )
456            .await
457            .expect_err("payload above configured bound must fail");
458
459        assert_eq!(err.code(), ErrorCode::InvalidInput);
460    }
461
462    #[tokio::test]
463    async fn search_rejects_filter_above_configured_bound() {
464        let store = InMemoryVectorStore::with_options(
465            SimilarityMetric::Cosine,
466            VectorStoreLimits::new().with_max_payload_bytes(4),
467        );
468        store.ensure_collection("test", 2).await.unwrap();
469
470        let filter = SearchFilter::new().must_match("name", "too-large");
471        let err = store
472            .search("test", vec![1.0, 0.0], 1, Some(filter))
473            .await
474            .expect_err("filter above configured bound must fail");
475
476        assert_eq!(err.code(), ErrorCode::InvalidInput);
477    }
478
479    #[tokio::test]
480    async fn search_rejects_non_finite_filter_float() {
481        let store = InMemoryVectorStore::new();
482        store.ensure_collection("test", 2).await.unwrap();
483
484        let filter = SearchFilter::new().must_match("score", f64::NAN);
485        let err = store
486            .search("test", vec![1.0, 0.0], 1, Some(filter))
487            .await
488            .expect_err("non-finite filter float must fail");
489
490        assert_eq!(err.code(), ErrorCode::InvalidInput);
491    }
492
493    #[tokio::test]
494    async fn test_upsert_missing_collection() {
495        let store = InMemoryVectorStore::new();
496        let result = store
497            .upsert("nonexistent", "1", vec![1.0], PointPayload::new())
498            .await;
499
500        assert!(result.is_err());
501    }
502}