use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VectorEntry {
pub id: String,
pub text: String,
pub embedding: Vec<f32>,
pub tags: Vec<String>,
pub metadata: HashMap<String, serde_json::Value>,
pub timestamp: u64,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub id: String,
pub text: String,
pub score: f32,
pub tags: Vec<String>,
pub timestamp: u64,
}
#[derive(Debug, Default)]
pub struct VectorStore {
entries: Vec<VectorEntry>,
}
impl VectorStore {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn add(
&mut self,
id: String,
text: String,
embedding: Vec<f32>,
tags: Vec<String>,
metadata: HashMap<String, serde_json::Value>,
) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.entries.push(VectorEntry {
id,
text,
embedding,
tags,
metadata,
timestamp,
});
}
pub fn add_with_timestamp(
&mut self,
id: String,
text: String,
embedding: Vec<f32>,
tags: Vec<String>,
metadata: HashMap<String, serde_json::Value>,
timestamp: u64,
) {
self.entries.push(VectorEntry {
id,
text,
embedding,
tags,
metadata,
timestamp,
});
}
pub fn search(&self, query_embedding: &[f32], top_k: usize) -> Vec<SearchResult> {
if self.entries.is_empty() || top_k == 0 {
return Vec::new();
}
let mut scored: Vec<SearchResult> = self
.entries
.iter()
.map(|entry| {
let score = cosine_similarity(query_embedding, &entry.embedding);
SearchResult {
id: entry.id.clone(),
text: entry.text.clone(),
score,
tags: entry.tags.clone(),
timestamp: entry.timestamp,
}
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);
scored
}
pub fn remove(&mut self, id: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.id != id);
self.entries.len() < before
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> &[VectorEntry] {
&self.entries
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_embedding(vals: &[f32]) -> Vec<f32> {
vals.to_vec()
}
#[test]
fn add_and_search() {
let mut store = VectorStore::new();
store.add(
"doc1".into(),
"hello world".into(),
make_embedding(&[1.0, 0.0, 0.0]),
vec!["greeting".into()],
HashMap::new(),
);
store.add(
"doc2".into(),
"goodbye moon".into(),
make_embedding(&[0.0, 1.0, 0.0]),
vec!["farewell".into()],
HashMap::new(),
);
let results = store.search(&[1.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "doc1");
assert!((results[0].score - 1.0).abs() < 0.01);
}
#[test]
fn top_k_ordering() {
let mut store = VectorStore::new();
store.add(
"a".into(),
"text a".into(),
make_embedding(&[1.0, 0.0]),
vec![],
HashMap::new(),
);
store.add(
"b".into(),
"text b".into(),
make_embedding(&[0.7, 0.7]),
vec![],
HashMap::new(),
);
store.add(
"c".into(),
"text c".into(),
make_embedding(&[0.0, 1.0]),
vec![],
HashMap::new(),
);
let results = store.search(&[1.0, 0.0], 3);
assert_eq!(results.len(), 3);
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
assert!(results[0].score >= results[1].score);
assert!(results[1].score >= results[2].score);
}
#[test]
fn remove_entry() {
let mut store = VectorStore::new();
store.add(
"doc1".into(),
"hello".into(),
make_embedding(&[1.0, 0.0]),
vec![],
HashMap::new(),
);
store.add(
"doc2".into(),
"world".into(),
make_embedding(&[0.0, 1.0]),
vec![],
HashMap::new(),
);
assert_eq!(store.len(), 2);
assert!(store.remove("doc1"));
assert_eq!(store.len(), 1);
assert!(!store.remove("doc1")); }
#[test]
fn empty_store_search_returns_empty() {
let store = VectorStore::new();
let results = store.search(&[1.0, 0.0], 5);
assert!(results.is_empty());
}
#[test]
fn identical_text_high_score() {
let mut store = VectorStore::new();
let emb = make_embedding(&[0.5, 0.5, 0.5]);
store.add(
"same".into(),
"same text".into(),
emb.clone(),
vec![],
HashMap::new(),
);
let results = store.search(&emb, 1);
assert_eq!(results.len(), 1);
assert!(
(results[0].score - 1.0).abs() < 0.01,
"identical embedding should score ~1.0, got {}",
results[0].score
);
}
#[test]
fn dissimilar_text_low_score() {
let mut store = VectorStore::new();
store.add(
"pos".into(),
"positive".into(),
make_embedding(&[1.0, 0.0, 0.0]),
vec![],
HashMap::new(),
);
let results = store.search(&[-1.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert!(
results[0].score < 0.0,
"opposite embeddings should have negative cosine, got {}",
results[0].score
);
}
#[test]
fn len_and_is_empty() {
let mut store = VectorStore::new();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
store.add(
"x".into(),
"text".into(),
make_embedding(&[1.0]),
vec![],
HashMap::new(),
);
assert!(!store.is_empty());
assert_eq!(store.len(), 1);
}
#[test]
fn search_with_top_k_zero_returns_empty() {
let mut store = VectorStore::new();
store.add(
"x".into(),
"text".into(),
make_embedding(&[1.0]),
vec![],
HashMap::new(),
);
let results = store.search(&[1.0], 0);
assert!(results.is_empty());
}
#[test]
fn search_top_k_larger_than_store() {
let mut store = VectorStore::new();
store.add(
"only".into(),
"only entry".into(),
make_embedding(&[1.0, 0.0]),
vec![],
HashMap::new(),
);
let results = store.search(&[1.0, 0.0], 100);
assert_eq!(results.len(), 1);
}
#[test]
fn cosine_similarity_zero_vectors() {
let score = cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]);
assert!((score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn cosine_similarity_different_lengths() {
let score = cosine_similarity(&[1.0, 0.0], &[1.0]);
assert!((score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn cosine_similarity_orthogonal() {
let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
assert!(
score.abs() < 0.01,
"orthogonal vectors should have ~0 cosine, got {score}"
);
}
#[test]
fn add_with_timestamp_works() {
let mut store = VectorStore::new();
store.add_with_timestamp(
"ts".into(),
"timestamped".into(),
make_embedding(&[1.0]),
vec![],
HashMap::new(),
1234567890,
);
let results = store.search(&[1.0], 1);
assert_eq!(results[0].timestamp, 1234567890);
}
#[test]
fn search_preserves_tags() {
let mut store = VectorStore::new();
store.add(
"tagged".into(),
"tagged entry".into(),
make_embedding(&[1.0, 0.0]),
vec!["important".into(), "test".into()],
HashMap::new(),
);
let results = store.search(&[1.0, 0.0], 1);
assert_eq!(results[0].tags, vec!["important", "test"]);
}
#[test]
fn default_creates_empty_store() {
let store = VectorStore::default();
assert!(store.is_empty());
}
#[test]
fn entries_accessor() {
let mut store = VectorStore::new();
store.add(
"e1".into(),
"entry 1".into(),
make_embedding(&[1.0]),
vec![],
HashMap::new(),
);
assert_eq!(store.entries().len(), 1);
assert_eq!(store.entries()[0].id, "e1");
}
}