1use std::collections::HashMap;
11use std::sync::atomic::AtomicBool;
12
13use parking_lot::RwLock;
14
15use crate::vector_store::{
16 BoxFuture, FieldValue, ScoredVectorPoint, ScrollWithIdsResult, VectorFilter, VectorPoint,
17 VectorStore, VectorStoreError,
18};
19
20struct StoredPoint {
21 vector: Vec<f32>,
22 payload: HashMap<String, serde_json::Value>,
23}
24
25struct InMemoryCollection {
26 points: HashMap<String, StoredPoint>,
27}
28
29pub struct InMemoryVectorStore {
42 collections: RwLock<HashMap<String, InMemoryCollection>>,
43}
44
45impl InMemoryVectorStore {
46 #[must_use]
47 pub fn new() -> Self {
48 Self {
49 collections: RwLock::new(HashMap::new()),
50 }
51 }
52}
53
54impl Default for InMemoryVectorStore {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl std::fmt::Debug for InMemoryVectorStore {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("InMemoryVectorStore")
63 .finish_non_exhaustive()
64 }
65}
66
67use zeph_common::math::cosine_similarity;
68
69fn matches_filter(payload: &HashMap<String, serde_json::Value>, filter: &VectorFilter) -> bool {
70 for cond in &filter.must {
71 let Some(val) = payload.get(&cond.field) else {
72 return false;
73 };
74 if !field_matches(val, &cond.value) {
75 return false;
76 }
77 }
78 for cond in &filter.must_not {
79 if let Some(val) = payload.get(&cond.field)
80 && field_matches(val, &cond.value)
81 {
82 return false;
83 }
84 }
85 true
86}
87
88fn field_matches(val: &serde_json::Value, expected: &FieldValue) -> bool {
89 match expected {
90 FieldValue::Integer(i) => val.as_i64() == Some(*i),
91 FieldValue::Text(s) => val.as_str() == Some(s.as_str()),
92 }
93}
94
95impl VectorStore for InMemoryVectorStore {
96 fn ensure_collection(
97 &self,
98 collection: &str,
99 _vector_size: u64,
100 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
101 let collection = collection.to_owned();
102 Box::pin(async move {
103 let mut cols = self.collections.write();
104 cols.entry(collection)
105 .or_insert_with(|| InMemoryCollection {
106 points: HashMap::new(),
107 });
108 Ok(())
109 })
110 }
111
112 fn collection_exists(&self, collection: &str) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
113 let collection = collection.to_owned();
114 Box::pin(async move {
115 let cols = self.collections.read();
116 Ok(cols.contains_key(&collection))
117 })
118 }
119
120 fn delete_collection(&self, collection: &str) -> BoxFuture<'_, Result<(), VectorStoreError>> {
121 let collection = collection.to_owned();
122 Box::pin(async move {
123 let mut cols = self.collections.write();
124 cols.remove(&collection);
125 Ok(())
126 })
127 }
128
129 fn upsert(
130 &self,
131 collection: &str,
132 points: Vec<VectorPoint>,
133 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
134 let collection = collection.to_owned();
135 Box::pin(async move {
136 let mut cols = self.collections.write();
137 let col = cols.get_mut(&collection).ok_or_else(|| {
138 VectorStoreError::Upsert(format!("collection {collection} not found"))
139 })?;
140 for p in points {
141 col.points.insert(
142 p.id,
143 StoredPoint {
144 vector: p.vector,
145 payload: p.payload,
146 },
147 );
148 }
149 Ok(())
150 })
151 }
152
153 fn search_clamp_diagnostics(&self) -> (&'static str, &'static AtomicBool) {
154 static CLAMP_WARNED: AtomicBool = AtomicBool::new(false);
155 ("InMemoryVectorStore::search", &CLAMP_WARNED)
156 }
157
158 fn search_clamped(
159 &self,
160 collection: &str,
161 vector: Vec<f32>,
162 limit: u64,
163 filter: Option<VectorFilter>,
164 ) -> BoxFuture<'_, Result<Vec<ScoredVectorPoint>, VectorStoreError>> {
165 let collection = collection.to_owned();
166 Box::pin(async move {
167 let cols = self.collections.read();
168 let col = cols.get(&collection).ok_or_else(|| {
169 VectorStoreError::Search(format!("collection {collection} not found"))
170 })?;
171
172 let empty_filter = VectorFilter::default();
173 let f = filter.as_ref().unwrap_or(&empty_filter);
174
175 let mut scored: Vec<ScoredVectorPoint> = col
176 .points
177 .iter()
178 .filter(|(_, sp)| matches_filter(&sp.payload, f))
179 .map(|(id, sp)| ScoredVectorPoint {
180 id: id.clone(),
181 score: cosine_similarity(&vector, &sp.vector),
182 payload: sp.payload.clone(),
183 })
184 .collect();
185
186 scored.sort_by(|a, b| {
187 b.score
188 .partial_cmp(&a.score)
189 .unwrap_or(std::cmp::Ordering::Equal)
190 });
191 #[expect(clippy::cast_possible_truncation)]
192 scored.truncate(limit as usize);
193 Ok(scored)
194 })
195 }
196
197 fn delete_by_ids(
198 &self,
199 collection: &str,
200 ids: Vec<String>,
201 ) -> BoxFuture<'_, Result<(), VectorStoreError>> {
202 let collection = collection.to_owned();
203 Box::pin(async move {
204 if ids.is_empty() {
205 return Ok(());
206 }
207 let mut cols = self.collections.write();
208 let col = cols.get_mut(&collection).ok_or_else(|| {
209 VectorStoreError::Delete(format!("collection {collection} not found"))
210 })?;
211 for id in &ids {
212 col.points.remove(id);
213 }
214 Ok(())
215 })
216 }
217
218 fn scroll_all(
219 &self,
220 collection: &str,
221 key_field: &str,
222 ) -> BoxFuture<'_, Result<HashMap<String, HashMap<String, String>>, VectorStoreError>> {
223 let collection = collection.to_owned();
224 let key_field = key_field.to_owned();
225 Box::pin(async move {
226 let cols = self.collections.read();
227 let col = cols.get(&collection).ok_or_else(|| {
228 VectorStoreError::Scroll(format!("collection {collection} not found"))
229 })?;
230
231 let mut result = HashMap::new();
232 for sp in col.points.values() {
233 let Some(key_val) = sp.payload.get(&key_field).and_then(|v| v.as_str()) else {
234 continue;
235 };
236 let mut fields = HashMap::new();
237 for (k, v) in &sp.payload {
238 if let Some(s) = v.as_str() {
239 fields.insert(k.clone(), s.to_owned());
240 }
241 }
242 result.insert(key_val.to_owned(), fields);
243 }
244 Ok(result)
245 })
246 }
247
248 fn scroll_all_with_point_ids(
249 &self,
250 collection: &str,
251 key_field: &str,
252 ) -> BoxFuture<'_, Result<ScrollWithIdsResult, VectorStoreError>> {
253 let collection = collection.to_owned();
254 let key_field = key_field.to_owned();
255 Box::pin(async move {
256 let cols = self.collections.read();
257 let col = cols.get(&collection).ok_or_else(|| {
258 VectorStoreError::Scroll(format!("collection {collection} not found"))
259 })?;
260
261 let mut result = Vec::new();
262 for (point_id, sp) in &col.points {
263 let Some(key_val) = sp.payload.get(&key_field).and_then(|v| v.as_str()) else {
264 continue;
265 };
266 let mut fields = HashMap::new();
267 for (k, v) in &sp.payload {
268 if let Some(s) = v.as_str() {
269 fields.insert(k.clone(), s.to_owned());
270 }
271 }
272 fields.insert(key_field.clone(), key_val.to_owned());
274 result.push((point_id.clone(), fields));
275 }
276 Ok(result)
277 })
278 }
279
280 fn health_check(&self) -> BoxFuture<'_, Result<bool, VectorStoreError>> {
281 Box::pin(async { Ok(true) })
282 }
283
284 fn get_points(
285 &self,
286 collection: &str,
287 ids: Vec<String>,
288 ) -> BoxFuture<'_, Result<Vec<VectorPoint>, VectorStoreError>> {
289 let collection = collection.to_owned();
290 Box::pin(async move {
291 let cols = self.collections.read();
292 let col = cols.get(&collection).ok_or_else(|| {
293 VectorStoreError::Unsupported(format!("collection {collection} not found"))
294 })?;
295 let points = ids
296 .into_iter()
297 .filter_map(|id| {
298 col.points.get(&id).map(|sp| VectorPoint {
299 id: id.clone(),
300 vector: sp.vector.clone(),
301 payload: sp.payload.clone(),
302 })
303 })
304 .collect();
305 Ok(points)
306 })
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[tokio::test]
315 async fn ensure_collection_and_exists() {
316 let store = InMemoryVectorStore::new();
317 assert!(!store.collection_exists("test").await.unwrap());
318 store.ensure_collection("test", 3).await.unwrap();
319 assert!(store.collection_exists("test").await.unwrap());
320 }
321
322 #[tokio::test]
323 async fn ensure_collection_idempotent() {
324 let store = InMemoryVectorStore::new();
325 store.ensure_collection("test", 3).await.unwrap();
326 store.ensure_collection("test", 3).await.unwrap();
327 assert!(store.collection_exists("test").await.unwrap());
328 }
329
330 #[tokio::test]
331 async fn delete_collection_removes() {
332 let store = InMemoryVectorStore::new();
333 store.ensure_collection("test", 3).await.unwrap();
334 store.delete_collection("test").await.unwrap();
335 assert!(!store.collection_exists("test").await.unwrap());
336 }
337
338 #[tokio::test]
339 async fn upsert_and_search() {
340 let store = InMemoryVectorStore::new();
341 store.ensure_collection("test", 3).await.unwrap();
342
343 let points = vec![
344 VectorPoint {
345 id: "a".into(),
346 vector: vec![1.0, 0.0, 0.0],
347 payload: HashMap::from([("name".into(), serde_json::json!("alpha"))]),
348 },
349 VectorPoint {
350 id: "b".into(),
351 vector: vec![0.0, 1.0, 0.0],
352 payload: HashMap::from([("name".into(), serde_json::json!("beta"))]),
353 },
354 ];
355 store.upsert("test", points).await.unwrap();
356
357 let results = store
358 .search("test", vec![1.0, 0.0, 0.0], 2, None)
359 .await
360 .unwrap();
361 assert_eq!(results.len(), 2);
362 assert_eq!(results[0].id, "a");
363 assert!((results[0].score - 1.0).abs() < f32::EPSILON);
364 }
365
366 #[tokio::test]
367 async fn search_with_filter() {
368 let store = InMemoryVectorStore::new();
369 store.ensure_collection("test", 3).await.unwrap();
370
371 let points = vec![
372 VectorPoint {
373 id: "a".into(),
374 vector: vec![1.0, 0.0, 0.0],
375 payload: HashMap::from([("role".into(), serde_json::json!("user"))]),
376 },
377 VectorPoint {
378 id: "b".into(),
379 vector: vec![0.9, 0.1, 0.0],
380 payload: HashMap::from([("role".into(), serde_json::json!("assistant"))]),
381 },
382 ];
383 store.upsert("test", points).await.unwrap();
384
385 let filter = VectorFilter {
386 must: vec![crate::vector_store::FieldCondition {
387 field: "role".into(),
388 value: FieldValue::Text("user".into()),
389 }],
390 must_not: vec![],
391 };
392 let results = store
393 .search("test", vec![1.0, 0.0, 0.0], 10, Some(filter))
394 .await
395 .unwrap();
396 assert_eq!(results.len(), 1);
397 assert_eq!(results[0].id, "a");
398 }
399
400 #[tokio::test]
401 async fn delete_by_ids_removes_points() {
402 let store = InMemoryVectorStore::new();
403 store.ensure_collection("test", 3).await.unwrap();
404
405 let points = vec![VectorPoint {
406 id: "a".into(),
407 vector: vec![1.0, 0.0, 0.0],
408 payload: HashMap::new(),
409 }];
410 store.upsert("test", points).await.unwrap();
411 store.delete_by_ids("test", vec!["a".into()]).await.unwrap();
412
413 let results = store
414 .search("test", vec![1.0, 0.0, 0.0], 10, None)
415 .await
416 .unwrap();
417 assert!(results.is_empty());
418 }
419
420 #[tokio::test]
421 async fn scroll_all_extracts_strings() {
422 let store = InMemoryVectorStore::new();
423 store.ensure_collection("test", 3).await.unwrap();
424
425 let points = vec![VectorPoint {
426 id: "a".into(),
427 vector: vec![1.0, 0.0, 0.0],
428 payload: HashMap::from([
429 ("name".into(), serde_json::json!("alpha")),
430 ("desc".into(), serde_json::json!("first")),
431 ("num".into(), serde_json::json!(42)),
432 ]),
433 }];
434 store.upsert("test", points).await.unwrap();
435
436 let result = store.scroll_all("test", "name").await.unwrap();
437 assert_eq!(result.len(), 1);
438 let fields = result.get("alpha").unwrap();
439 assert_eq!(fields.get("desc").unwrap(), "first");
440 assert!(!fields.contains_key("num"));
441 }
442
443 #[tokio::test]
444 async fn scroll_all_with_point_ids_returns_point_id() {
445 let store = InMemoryVectorStore::new();
446 store.ensure_collection("test", 3).await.unwrap();
447
448 let points = vec![
449 VectorPoint {
450 id: "pid-1".into(),
451 vector: vec![1.0, 0.0, 0.0],
452 payload: HashMap::from([
453 ("entity_id_str".into(), serde_json::json!("42")),
454 ("name".into(), serde_json::json!("Alpha")),
455 ("count".into(), serde_json::json!(7)), ]),
457 },
458 VectorPoint {
459 id: "pid-2".into(),
460 vector: vec![0.0, 1.0, 0.0],
461 payload: HashMap::from([("name".into(), serde_json::json!("Beta"))]),
463 },
464 ];
465 store.upsert("test", points).await.unwrap();
466
467 let result = store
468 .scroll_all_with_point_ids("test", "entity_id_str")
469 .await
470 .unwrap();
471
472 assert_eq!(
473 result.len(),
474 1,
475 "only the point with key_field should appear"
476 );
477 let (point_id, fields) = &result[0];
478 assert_eq!(point_id, "pid-1");
479 assert_eq!(fields.get("entity_id_str").map(String::as_str), Some("42"));
480 assert_eq!(fields.get("name").map(String::as_str), Some("Alpha"));
481 assert!(!fields.contains_key("count"));
483 }
484
485 #[test]
486 fn cosine_similarity_import_wired() {
487 assert!(!cosine_similarity(&[1.0, 0.0, 0.0], &[0.0, 1.0, 0.0]).is_nan());
489 }
490
491 #[tokio::test]
492 async fn default_impl() {
493 let store = InMemoryVectorStore::default();
494 assert!(!store.collection_exists("any").await.unwrap());
495 }
496
497 #[test]
498 fn debug_format() {
499 let store = InMemoryVectorStore::new();
500 let dbg = format!("{store:?}");
501 assert!(dbg.contains("InMemoryVectorStore"));
502 }
503
504 #[tokio::test]
509 #[tracing_test::traced_test]
510 async fn search_clamps_oversized_limit() {
511 let store = InMemoryVectorStore::new();
512 store.ensure_collection("test", 3).await.unwrap();
513
514 let points: Vec<VectorPoint> = (0..(crate::MAX_SEARCH_LIMIT + 10))
515 .map(|i| VectorPoint {
516 id: format!("p{i}"),
517 vector: vec![1.0, 0.0, 0.0],
518 payload: HashMap::new(),
519 })
520 .collect();
521 store.upsert("test", points).await.unwrap();
522
523 let results = store
524 .search("test", vec![1.0, 0.0, 0.0], u64::MAX, None)
525 .await
526 .unwrap();
527 assert_eq!(results.len(), crate::MAX_SEARCH_LIMIT);
528 assert!(
529 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
530 "expected a one-shot warn when the trait-impl clamp actually reduces the requested limit"
531 );
532 }
533}