use serde::{Deserialize, Serialize};
const MAX_NEIGHBORS: usize = 16;
const MAX_VECTORS: usize = 1024;
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MicroHnswRequest {
Insert {
id: u32,
embedding: Vec<f32>,
},
Query {
embedding: Vec<f32>,
k: usize,
},
Delete {
id: u32,
},
Count,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MicroHnswResponse {
Results(Vec<(u32, f32)>),
Ok,
Error(String),
Count(usize),
}
#[derive(Debug, Clone)]
struct MicroNode {
id: u32,
embedding: Vec<f32>,
neighbors: Vec<usize>,
}
pub struct MicroHnsw {
nodes: Vec<MicroNode>,
dimension: usize,
}
impl MicroHnsw {
pub fn new(dimension: usize) -> Self {
Self {
nodes: Vec::new(),
dimension,
}
}
pub fn process(&mut self, request: MicroHnswRequest) -> MicroHnswResponse {
match request {
MicroHnswRequest::Insert { id, embedding } => self.insert(id, embedding),
MicroHnswRequest::Query { embedding, k } => self.query(&embedding, k),
MicroHnswRequest::Delete { id } => self.delete(id),
MicroHnswRequest::Count => MicroHnswResponse::Count(self.nodes.len()),
}
}
fn insert(&mut self, id: u32, embedding: Vec<f32>) -> MicroHnswResponse {
if self.nodes.len() >= MAX_VECTORS {
return MicroHnswResponse::Error("micro-HNSW capacity exceeded".into());
}
if embedding.len() != self.dimension {
return MicroHnswResponse::Error(format!(
"dimension mismatch: expected {}, got {}",
self.dimension,
embedding.len()
));
}
self.remove_node(id);
let new_idx = self.nodes.len();
let neighbors = self.find_nearest_indices(&embedding, MAX_NEIGHBORS);
self.nodes.push(MicroNode {
id,
embedding,
neighbors: neighbors.clone(),
});
for &neighbor_idx in &neighbors {
if neighbor_idx < self.nodes.len() - 1 {
let node = &mut self.nodes[neighbor_idx];
if node.neighbors.len() < MAX_NEIGHBORS {
node.neighbors.push(new_idx);
}
}
}
MicroHnswResponse::Ok
}
fn query(&self, embedding: &[f32], k: usize) -> MicroHnswResponse {
if self.nodes.is_empty() || k == 0 {
return MicroHnswResponse::Results(Vec::new());
}
if self.nodes.len() <= 64 {
return self.brute_force_query(embedding, k);
}
let mut visited = vec![false; self.nodes.len()];
let mut results: Vec<(u32, f32)> = Vec::new();
let mut candidates = vec![0usize];
visited[0] = true;
while let Some(idx) = candidates.pop() {
let node = &self.nodes[idx];
let score = cosine_similarity(embedding, &node.embedding);
results.push((node.id, score));
for &neighbor_idx in &node.neighbors {
if neighbor_idx < self.nodes.len() && !visited[neighbor_idx] {
visited[neighbor_idx] = true;
candidates.push(neighbor_idx);
}
}
}
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
MicroHnswResponse::Results(results)
}
fn delete(&mut self, id: u32) -> MicroHnswResponse {
if self.remove_node(id) {
MicroHnswResponse::Ok
} else {
MicroHnswResponse::Error(format!("node {id} not found"))
}
}
fn remove_node(&mut self, id: u32) -> bool {
let pos = self.nodes.iter().position(|n| n.id == id);
let Some(removed_idx) = pos else {
return false;
};
self.nodes.remove(removed_idx);
for node in &mut self.nodes {
node.neighbors.retain(|&idx| idx != removed_idx);
for idx in &mut node.neighbors {
if *idx > removed_idx {
*idx -= 1;
}
}
}
true
}
fn find_nearest_indices(&self, embedding: &[f32], k: usize) -> Vec<usize> {
let mut scored: Vec<(usize, f32)> = self
.nodes
.iter()
.enumerate()
.map(|(i, node)| (i, cosine_similarity(embedding, &node.embedding)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored.into_iter().map(|(i, _)| i).collect()
}
fn brute_force_query(&self, embedding: &[f32], k: usize) -> MicroHnswResponse {
let mut results: Vec<(u32, f32)> = self
.nodes
.iter()
.map(|node| (node.id, cosine_similarity(embedding, &node.embedding)))
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
MicroHnswResponse::Results(results)
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
impl Default for MicroHnsw {
fn default() -> Self {
Self::new(0)
}
}
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::*;
#[test]
fn empty_index() {
let index = MicroHnsw::new(3);
assert!(index.is_empty());
assert_eq!(index.len(), 0);
}
#[test]
fn insert_and_query() {
let mut index = MicroHnsw::new(3);
let resp = index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0, 0.0],
});
assert!(matches!(resp, MicroHnswResponse::Ok));
let resp = index.process(MicroHnswRequest::Insert {
id: 2,
embedding: vec![0.0, 1.0, 0.0],
});
assert!(matches!(resp, MicroHnswResponse::Ok));
let resp = index.process(MicroHnswRequest::Query {
embedding: vec![1.0, 0.0, 0.0],
k: 1,
});
match resp {
MicroHnswResponse::Results(results) => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 1);
assert!((results[0].1 - 1.0).abs() < 0.01);
}
_ => panic!("expected Results"),
}
}
#[test]
fn delete_node() {
let mut index = MicroHnsw::new(2);
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
index.process(MicroHnswRequest::Insert {
id: 2,
embedding: vec![0.0, 1.0],
});
let resp = index.process(MicroHnswRequest::Delete { id: 1 });
assert!(matches!(resp, MicroHnswResponse::Ok));
let resp = index.process(MicroHnswRequest::Count);
assert!(matches!(resp, MicroHnswResponse::Count(1)));
}
#[test]
fn delete_nonexistent() {
let mut index = MicroHnsw::new(2);
let resp = index.process(MicroHnswRequest::Delete { id: 99 });
assert!(matches!(resp, MicroHnswResponse::Error(_)));
}
#[test]
fn upsert_semantics() {
let mut index = MicroHnsw::new(2);
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![0.0, 1.0],
});
assert_eq!(index.len(), 1);
let resp = index.process(MicroHnswRequest::Query {
embedding: vec![0.0, 1.0],
k: 1,
});
match resp {
MicroHnswResponse::Results(results) => {
assert_eq!(results[0].0, 1);
assert!((results[0].1 - 1.0).abs() < 0.01);
}
_ => panic!("expected Results"),
}
}
#[test]
fn dimension_mismatch_rejected() {
let mut index = MicroHnsw::new(3);
let resp = index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
assert!(matches!(resp, MicroHnswResponse::Error(_)));
}
#[test]
fn query_empty_index() {
let mut index = MicroHnsw::new(2);
let resp = index.process(MicroHnswRequest::Query {
embedding: vec![1.0, 0.0],
k: 5,
});
match resp {
MicroHnswResponse::Results(results) => {
assert!(results.is_empty());
}
_ => panic!("expected Results"),
}
}
#[test]
fn query_k_zero() {
let mut index = MicroHnsw::new(2);
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
let resp = index.process(MicroHnswRequest::Query {
embedding: vec![1.0, 0.0],
k: 0,
});
match resp {
MicroHnswResponse::Results(results) => {
assert!(results.is_empty());
}
_ => panic!("expected Results"),
}
}
#[test]
fn count_message() {
let mut index = MicroHnsw::new(2);
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
let resp = index.process(MicroHnswRequest::Count);
assert!(matches!(resp, MicroHnswResponse::Count(1)));
}
#[test]
fn query_ordering() {
let mut index = MicroHnsw::new(2);
index.process(MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 0.0],
});
index.process(MicroHnswRequest::Insert {
id: 2,
embedding: vec![0.7, 0.7],
});
index.process(MicroHnswRequest::Insert {
id: 3,
embedding: vec![0.0, 1.0],
});
let resp = index.process(MicroHnswRequest::Query {
embedding: vec![1.0, 0.0],
k: 3,
});
match resp {
MicroHnswResponse::Results(results) => {
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, 1);
assert_eq!(results[1].0, 2);
assert_eq!(results[2].0, 3);
}
_ => panic!("expected Results"),
}
}
#[test]
fn default_creates_zero_dim() {
let index = MicroHnsw::default();
assert!(index.is_empty());
}
#[test]
fn request_serialization() {
let req = MicroHnswRequest::Insert {
id: 1,
embedding: vec![1.0, 2.0],
};
let json = serde_json::to_string(&req).unwrap();
let parsed: MicroHnswRequest = serde_json::from_str(&json).unwrap();
match parsed {
MicroHnswRequest::Insert { id, embedding } => {
assert_eq!(id, 1);
assert_eq!(embedding, vec![1.0, 2.0]);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn response_serialization() {
let resp = MicroHnswResponse::Results(vec![(1, 0.95), (2, 0.8)]);
let json = serde_json::to_string(&resp).unwrap();
let parsed: MicroHnswResponse = serde_json::from_str(&json).unwrap();
match parsed {
MicroHnswResponse::Results(results) => {
assert_eq!(results.len(), 2);
}
_ => panic!("wrong variant"),
}
}
}