Skip to main content

clawft_core/
vector_store.rs

1//! In-memory vector store with cosine similarity search.
2//!
3//! Provides a simple [`VectorStore`] that holds embedding vectors alongside
4//! their text, tags, and metadata. Search is brute-force cosine similarity
5//! over all entries, returning top-k results sorted by descending score.
6//!
7//! This module is gated behind the `vector-memory` feature flag.
8
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12
13/// A single entry in the vector store.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct VectorEntry {
16    /// Unique identifier for this entry.
17    pub id: String,
18    /// The original text that was embedded.
19    pub text: String,
20    /// The embedding vector.
21    pub embedding: Vec<f32>,
22    /// Tags for categorization and filtering.
23    pub tags: Vec<String>,
24    /// Arbitrary metadata.
25    pub metadata: HashMap<String, serde_json::Value>,
26    /// Unix timestamp (seconds since epoch) when the entry was added.
27    pub timestamp: u64,
28}
29
30/// A search result from the vector store.
31#[derive(Debug, Clone)]
32pub struct SearchResult {
33    /// The entry ID.
34    pub id: String,
35    /// The original text.
36    pub text: String,
37    /// Cosine similarity score (higher = more similar).
38    pub score: f32,
39    /// Tags from the entry.
40    pub tags: Vec<String>,
41    /// Timestamp from the entry.
42    pub timestamp: u64,
43}
44
45/// In-memory vector store with brute-force cosine similarity search.
46///
47/// Suitable for small to medium datasets (up to tens of thousands of entries).
48/// For larger datasets, swap to an HNSW-based backend (e.g. RVF) when available.
49#[derive(Debug, Default)]
50pub struct VectorStore {
51    entries: Vec<VectorEntry>,
52}
53
54impl VectorStore {
55    /// Create a new, empty vector store.
56    pub fn new() -> Self {
57        Self {
58            entries: Vec::new(),
59        }
60    }
61
62    /// Add an entry to the store.
63    pub fn add(
64        &mut self,
65        id: String,
66        text: String,
67        embedding: Vec<f32>,
68        tags: Vec<String>,
69        metadata: HashMap<String, serde_json::Value>,
70    ) {
71        let timestamp = std::time::SystemTime::now()
72            .duration_since(std::time::UNIX_EPOCH)
73            .unwrap_or_default()
74            .as_secs();
75
76        self.entries.push(VectorEntry {
77            id,
78            text,
79            embedding,
80            tags,
81            metadata,
82            timestamp,
83        });
84    }
85
86    /// Add an entry with an explicit timestamp.
87    pub fn add_with_timestamp(
88        &mut self,
89        id: String,
90        text: String,
91        embedding: Vec<f32>,
92        tags: Vec<String>,
93        metadata: HashMap<String, serde_json::Value>,
94        timestamp: u64,
95    ) {
96        self.entries.push(VectorEntry {
97            id,
98            text,
99            embedding,
100            tags,
101            metadata,
102            timestamp,
103        });
104    }
105
106    /// Search for the top-k most similar entries to the query embedding.
107    ///
108    /// Results are sorted by descending cosine similarity score.
109    pub fn search(&self, query_embedding: &[f32], top_k: usize) -> Vec<SearchResult> {
110        if self.entries.is_empty() || top_k == 0 {
111            return Vec::new();
112        }
113
114        let mut scored: Vec<SearchResult> = self
115            .entries
116            .iter()
117            .map(|entry| {
118                let score = cosine_similarity(query_embedding, &entry.embedding);
119                SearchResult {
120                    id: entry.id.clone(),
121                    text: entry.text.clone(),
122                    score,
123                    tags: entry.tags.clone(),
124                    timestamp: entry.timestamp,
125                }
126            })
127            .collect();
128
129        // Sort by descending score.
130        scored.sort_by(|a, b| {
131            b.score
132                .partial_cmp(&a.score)
133                .unwrap_or(std::cmp::Ordering::Equal)
134        });
135
136        scored.truncate(top_k);
137        scored
138    }
139
140    /// Remove an entry by ID. Returns `true` if the entry was found and removed.
141    pub fn remove(&mut self, id: &str) -> bool {
142        let before = self.entries.len();
143        self.entries.retain(|e| e.id != id);
144        self.entries.len() < before
145    }
146
147    /// Return the number of entries in the store.
148    pub fn len(&self) -> usize {
149        self.entries.len()
150    }
151
152    /// Return `true` if the store has no entries.
153    pub fn is_empty(&self) -> bool {
154        self.entries.is_empty()
155    }
156
157    /// Get all entries (for serialization / inspection).
158    pub fn entries(&self) -> &[VectorEntry] {
159        &self.entries
160    }
161}
162
163/// Compute cosine similarity between two vectors.
164///
165/// Returns 0.0 if either vector has zero norm.
166fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
167    if a.len() != b.len() {
168        return 0.0;
169    }
170
171    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
172    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
173    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
174
175    if norm_a == 0.0 || norm_b == 0.0 {
176        return 0.0;
177    }
178
179    dot / (norm_a * norm_b)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn make_embedding(vals: &[f32]) -> Vec<f32> {
187        vals.to_vec()
188    }
189
190    #[test]
191    fn add_and_search() {
192        let mut store = VectorStore::new();
193        store.add(
194            "doc1".into(),
195            "hello world".into(),
196            make_embedding(&[1.0, 0.0, 0.0]),
197            vec!["greeting".into()],
198            HashMap::new(),
199        );
200        store.add(
201            "doc2".into(),
202            "goodbye moon".into(),
203            make_embedding(&[0.0, 1.0, 0.0]),
204            vec!["farewell".into()],
205            HashMap::new(),
206        );
207
208        let results = store.search(&[1.0, 0.0, 0.0], 1);
209        assert_eq!(results.len(), 1);
210        assert_eq!(results[0].id, "doc1");
211        assert!((results[0].score - 1.0).abs() < 0.01);
212    }
213
214    #[test]
215    fn top_k_ordering() {
216        let mut store = VectorStore::new();
217        store.add(
218            "a".into(),
219            "text a".into(),
220            make_embedding(&[1.0, 0.0]),
221            vec![],
222            HashMap::new(),
223        );
224        store.add(
225            "b".into(),
226            "text b".into(),
227            make_embedding(&[0.7, 0.7]),
228            vec![],
229            HashMap::new(),
230        );
231        store.add(
232            "c".into(),
233            "text c".into(),
234            make_embedding(&[0.0, 1.0]),
235            vec![],
236            HashMap::new(),
237        );
238
239        let results = store.search(&[1.0, 0.0], 3);
240        assert_eq!(results.len(), 3);
241        // "a" should be first (exact match), then "b" (partial), then "c" (orthogonal)
242        assert_eq!(results[0].id, "a");
243        assert_eq!(results[1].id, "b");
244        assert_eq!(results[2].id, "c");
245        assert!(results[0].score >= results[1].score);
246        assert!(results[1].score >= results[2].score);
247    }
248
249    #[test]
250    fn remove_entry() {
251        let mut store = VectorStore::new();
252        store.add(
253            "doc1".into(),
254            "hello".into(),
255            make_embedding(&[1.0, 0.0]),
256            vec![],
257            HashMap::new(),
258        );
259        store.add(
260            "doc2".into(),
261            "world".into(),
262            make_embedding(&[0.0, 1.0]),
263            vec![],
264            HashMap::new(),
265        );
266
267        assert_eq!(store.len(), 2);
268        assert!(store.remove("doc1"));
269        assert_eq!(store.len(), 1);
270        assert!(!store.remove("doc1")); // Already removed.
271    }
272
273    #[test]
274    fn empty_store_search_returns_empty() {
275        let store = VectorStore::new();
276        let results = store.search(&[1.0, 0.0], 5);
277        assert!(results.is_empty());
278    }
279
280    #[test]
281    fn identical_text_high_score() {
282        let mut store = VectorStore::new();
283        let emb = make_embedding(&[0.5, 0.5, 0.5]);
284        store.add(
285            "same".into(),
286            "same text".into(),
287            emb.clone(),
288            vec![],
289            HashMap::new(),
290        );
291
292        let results = store.search(&emb, 1);
293        assert_eq!(results.len(), 1);
294        assert!(
295            (results[0].score - 1.0).abs() < 0.01,
296            "identical embedding should score ~1.0, got {}",
297            results[0].score
298        );
299    }
300
301    #[test]
302    fn dissimilar_text_low_score() {
303        let mut store = VectorStore::new();
304        store.add(
305            "pos".into(),
306            "positive".into(),
307            make_embedding(&[1.0, 0.0, 0.0]),
308            vec![],
309            HashMap::new(),
310        );
311
312        // Opposite direction.
313        let results = store.search(&[-1.0, 0.0, 0.0], 1);
314        assert_eq!(results.len(), 1);
315        assert!(
316            results[0].score < 0.0,
317            "opposite embeddings should have negative cosine, got {}",
318            results[0].score
319        );
320    }
321
322    #[test]
323    fn len_and_is_empty() {
324        let mut store = VectorStore::new();
325        assert!(store.is_empty());
326        assert_eq!(store.len(), 0);
327
328        store.add(
329            "x".into(),
330            "text".into(),
331            make_embedding(&[1.0]),
332            vec![],
333            HashMap::new(),
334        );
335        assert!(!store.is_empty());
336        assert_eq!(store.len(), 1);
337    }
338
339    #[test]
340    fn search_with_top_k_zero_returns_empty() {
341        let mut store = VectorStore::new();
342        store.add(
343            "x".into(),
344            "text".into(),
345            make_embedding(&[1.0]),
346            vec![],
347            HashMap::new(),
348        );
349
350        let results = store.search(&[1.0], 0);
351        assert!(results.is_empty());
352    }
353
354    #[test]
355    fn search_top_k_larger_than_store() {
356        let mut store = VectorStore::new();
357        store.add(
358            "only".into(),
359            "only entry".into(),
360            make_embedding(&[1.0, 0.0]),
361            vec![],
362            HashMap::new(),
363        );
364
365        let results = store.search(&[1.0, 0.0], 100);
366        assert_eq!(results.len(), 1);
367    }
368
369    #[test]
370    fn cosine_similarity_zero_vectors() {
371        let score = cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]);
372        assert!((score - 0.0).abs() < f32::EPSILON);
373    }
374
375    #[test]
376    fn cosine_similarity_different_lengths() {
377        let score = cosine_similarity(&[1.0, 0.0], &[1.0]);
378        assert!((score - 0.0).abs() < f32::EPSILON);
379    }
380
381    #[test]
382    fn cosine_similarity_orthogonal() {
383        let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
384        assert!(
385            score.abs() < 0.01,
386            "orthogonal vectors should have ~0 cosine, got {score}"
387        );
388    }
389
390    #[test]
391    fn add_with_timestamp_works() {
392        let mut store = VectorStore::new();
393        store.add_with_timestamp(
394            "ts".into(),
395            "timestamped".into(),
396            make_embedding(&[1.0]),
397            vec![],
398            HashMap::new(),
399            1234567890,
400        );
401
402        let results = store.search(&[1.0], 1);
403        assert_eq!(results[0].timestamp, 1234567890);
404    }
405
406    #[test]
407    fn search_preserves_tags() {
408        let mut store = VectorStore::new();
409        store.add(
410            "tagged".into(),
411            "tagged entry".into(),
412            make_embedding(&[1.0, 0.0]),
413            vec!["important".into(), "test".into()],
414            HashMap::new(),
415        );
416
417        let results = store.search(&[1.0, 0.0], 1);
418        assert_eq!(results[0].tags, vec!["important", "test"]);
419    }
420
421    #[test]
422    fn default_creates_empty_store() {
423        let store = VectorStore::default();
424        assert!(store.is_empty());
425    }
426
427    #[test]
428    fn entries_accessor() {
429        let mut store = VectorStore::new();
430        store.add(
431            "e1".into(),
432            "entry 1".into(),
433            make_embedding(&[1.0]),
434            vec![],
435            HashMap::new(),
436        );
437        assert_eq!(store.entries().len(), 1);
438        assert_eq!(store.entries()[0].id, "e1");
439    }
440}