1use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct VectorEntry {
16 pub id: String,
18 pub text: String,
20 pub embedding: Vec<f32>,
22 pub tags: Vec<String>,
24 pub metadata: HashMap<String, serde_json::Value>,
26 pub timestamp: u64,
28}
29
30#[derive(Debug, Clone)]
32pub struct SearchResult {
33 pub id: String,
35 pub text: String,
37 pub score: f32,
39 pub tags: Vec<String>,
41 pub timestamp: u64,
43}
44
45#[derive(Debug, Default)]
50pub struct VectorStore {
51 entries: Vec<VectorEntry>,
52}
53
54impl VectorStore {
55 pub fn new() -> Self {
57 Self {
58 entries: Vec::new(),
59 }
60 }
61
62 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 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 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 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 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 pub fn len(&self) -> usize {
149 self.entries.len()
150 }
151
152 pub fn is_empty(&self) -> bool {
154 self.entries.is_empty()
155 }
156
157 pub fn entries(&self) -> &[VectorEntry] {
159 &self.entries
160 }
161}
162
163fn 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 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")); }
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 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}