/// HNSW (Hierarchical Navigable Small World) index for fast approximate nearest neighbor search. /// Wraps instant-distance to provide <50ms search over 100K+ vectors. /// Falls back to brute-force for small datasets. use instant_distance::{Builder, HnswMap, Search, Point}; use std::sync::Arc; use tokio::sync::RwLock; use crate::store::StoredEmbedding; use crate::trial::HnswConfig; /// A vector point for HNSW — wraps f32 slice with cosine distance. #[derive(Clone)] struct VecPoint(Vec); impl Point for VecPoint { fn distance(&self, other: &Self) -> f32 { // Cosine distance = 1 - cosine_similarity let dot: f32 = self.0.iter().zip(other.0.iter()).map(|(a, b)| a * b).sum(); let norm_a: f32 = self.0.iter().map(|x| x * x).sum::().sqrt(); let norm_b: f32 = other.0.iter().map(|x| x * x).sum::().sqrt(); if norm_a == 0.0 || norm_b == 0.0 { return 1.0; } 1.0 - (dot / (norm_a * norm_b)) } } /// HNSW search result. #[derive(Debug, Clone, serde::Serialize)] pub struct HnswResult { pub source: String, pub doc_id: String, pub chunk_idx: u32, pub chunk_text: String, pub score: f32, // cosine similarity (1.0 = identical) } /// An HNSW index built from stored embeddings. pub struct HnswIndex { map: HnswMap, // value is index into metadata vec metadata: Vec, } #[derive(Clone)] struct EmbeddingMeta { source: String, doc_id: String, chunk_idx: u32, chunk_text: String, } /// Shared, thread-safe index store. #[derive(Clone)] pub struct HnswStore { indexes: Arc>>>, } impl HnswStore { pub fn new() -> Self { Self { indexes: Arc::new(RwLock::new(std::collections::HashMap::new())), } } /// Build an HNSW index from stored embeddings with default config. pub async fn build_index( &self, index_name: &str, embeddings: Vec, ) -> Result { self.build_index_with_config(index_name, embeddings, &HnswConfig::default()).await } /// Build an HNSW index from stored embeddings with explicit config. /// Used by the trial system — each trial calls this with different params. pub async fn build_index_with_config( &self, index_name: &str, embeddings: Vec, config: &HnswConfig, ) -> Result { let n = embeddings.len(); if n == 0 { return Err("no embeddings to index".into()); } tracing::info!( "building HNSW '{}': {} vectors, ef_construction={} ef_search={} seed={:?}", index_name, n, config.ef_construction, config.ef_search, config.seed, ); let start = std::time::Instant::now(); // Separate points and metadata let mut points = Vec::with_capacity(n); let mut metadata = Vec::with_capacity(n); let mut values = Vec::with_capacity(n); for (i, emb) in embeddings.into_iter().enumerate() { points.push(VecPoint(emb.vector)); metadata.push(EmbeddingMeta { source: emb.source, doc_id: emb.doc_id, chunk_idx: emb.chunk_idx, chunk_text: emb.chunk_text, }); values.push(i); } // Build HNSW — the expensive part let mut builder = Builder::default() .ef_construction(config.ef_construction) .ef_search(config.ef_search); if let Some(seed) = config.seed { builder = builder.seed(seed); } let map = builder.build(points, values); let build_time = start.elapsed().as_secs_f32(); tracing::info!("HNSW '{}' built: {} vectors in {:.1}s", index_name, n, build_time); let index = Arc::new(HnswIndex { map, metadata }); self.indexes.write().await.insert(index_name.to_string(), index); Ok(BuildStats { index_name: index_name.to_string(), vectors: n, build_time_secs: build_time, }) } /// Run a batch of search queries and return raw per-query latencies in microseconds. /// Also returns the retrieved doc_ids per query (for recall calculation). pub async fn bench_search( &self, index_name: &str, query_vectors: &[Vec], top_k: usize, ) -> Result { let indexes = self.indexes.read().await; let index = indexes .get(index_name) .ok_or_else(|| format!("HNSW index not found: {index_name}"))? .clone(); drop(indexes); let mut latencies_us = Vec::with_capacity(query_vectors.len()); let mut retrieved: Vec> = Vec::with_capacity(query_vectors.len()); for qv in query_vectors { let query_point = VecPoint(qv.clone()); let t0 = std::time::Instant::now(); let mut search = Search::default(); let results = index.map.search(&query_point, &mut search); let ids: Vec = results .take(top_k) .map(|item| { let meta_idx = *item.value; index.metadata[meta_idx].doc_id.clone() }) .collect(); latencies_us.push(t0.elapsed().as_micros() as f32); retrieved.push(ids); } Ok(BenchResult { latencies_us, retrieved }) } /// Search an HNSW index. Returns approximate nearest neighbors. pub async fn search( &self, index_name: &str, query: &[f32], top_k: usize, ) -> Result, String> { let indexes = self.indexes.read().await; let index = indexes.get(index_name) .ok_or_else(|| format!("HNSW index not found: {index_name}"))?; let query_point = VecPoint(query.to_vec()); let mut search = Search::default(); let results = index.map.search(&query_point, &mut search); let mut out = Vec::with_capacity(top_k); for item in results.take(top_k) { let meta_idx = *item.value; let meta = &index.metadata[meta_idx]; // Convert distance back to similarity let similarity = 1.0 - item.distance; out.push(HnswResult { source: meta.source.clone(), doc_id: meta.doc_id.clone(), chunk_idx: meta.chunk_idx, chunk_text: meta.chunk_text.clone(), score: similarity, }); } Ok(out) } /// Check if an index exists. pub async fn has_index(&self, name: &str) -> bool { self.indexes.read().await.contains_key(name) } /// List all loaded indexes. pub async fn list(&self) -> Vec { self.indexes.read().await.keys().cloned().collect() } /// Drop an index from memory. pub async fn drop(&self, name: &str) -> bool { self.indexes.write().await.remove(name).is_some() } } #[derive(Debug, Clone, serde::Serialize)] pub struct BuildStats { pub index_name: String, pub vectors: usize, pub build_time_secs: f32, } #[derive(Debug, Clone)] pub struct BenchResult { pub latencies_us: Vec, pub retrieved: Vec>, }