use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use instant_distance::{Builder, HnswMap, Point, Search};
use serde::{Deserialize, Serialize};
use tracing::debug;
use super::path_predict::{PathPrediction, RegionEntryTable};
const HNSW_THRESHOLD: usize = 32;
const DEFAULT_EF_SEARCH: usize = 100;
const DEFAULT_EF_CONSTRUCTION: usize = 200;
const DEFAULT_REBUILD_THRESHOLD: usize = 100;
#[derive(Debug, Clone)]
struct EmbeddingPoint {
embedding: Arc<[f32]>,
}
impl Point for EmbeddingPoint {
fn distance(&self, other: &Self) -> f32 {
1.0 - cosine_similarity(&self.embedding, &other.embedding)
}
}
#[derive(Debug, Clone)]
pub struct HnswEntry {
pub id: String,
pub embedding: Arc<[f32]>,
pub metadata: serde_json::Value,
}
impl Serialize for HnswEntry {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("HnswEntry", 3)?;
s.serialize_field("id", &self.id)?;
s.serialize_field("embedding", &*self.embedding)?;
s.serialize_field("metadata", &self.metadata)?;
s.end()
}
}
impl<'de> Deserialize<'de> for HnswEntry {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
id: String,
embedding: Vec<f32>,
metadata: serde_json::Value,
}
let raw = Raw::deserialize(deserializer)?;
Ok(HnswEntry {
id: raw.id,
embedding: Arc::from(raw.embedding),
metadata: raw.metadata,
})
}
}
#[derive(Debug, Clone)]
pub struct HnswQueryResult {
pub id: String,
pub score: f32,
pub metadata: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoreSnapshot {
entries: Vec<HnswEntry>,
ef_search: usize,
ef_construction: usize,
}
pub struct HnswStore {
entries: Vec<HnswEntry>,
id_index: HashMap<String, usize>,
index: Option<HnswMap<EmbeddingPoint, usize>>,
ef_search: usize,
ef_construction: usize,
dirty: bool,
index_built_len: usize,
inserts_since_rebuild: usize,
rebuild_threshold: usize,
}
impl HnswStore {
pub fn new() -> Self {
Self {
entries: Vec::new(),
id_index: HashMap::new(),
index: None,
ef_search: DEFAULT_EF_SEARCH,
ef_construction: DEFAULT_EF_CONSTRUCTION,
dirty: false,
index_built_len: 0,
inserts_since_rebuild: 0,
rebuild_threshold: DEFAULT_REBUILD_THRESHOLD,
}
}
pub fn with_params(ef_search: usize, ef_construction: usize) -> Self {
Self {
entries: Vec::new(),
id_index: HashMap::new(),
index: None,
ef_search,
ef_construction,
dirty: false,
index_built_len: 0,
inserts_since_rebuild: 0,
rebuild_threshold: DEFAULT_REBUILD_THRESHOLD,
}
}
pub fn set_rebuild_threshold(&mut self, threshold: usize) -> &mut Self {
self.rebuild_threshold = threshold;
self
}
pub fn insert(&mut self, id: String, embedding: Vec<f32>, metadata: serde_json::Value) {
if let Some(old_pos) = self.id_index.remove(&id) {
self.entries.swap_remove(old_pos);
if old_pos < self.entries.len() {
let moved_id = self.entries[old_pos].id.clone();
self.id_index.insert(moved_id, old_pos);
}
}
let new_pos = self.entries.len();
self.id_index.insert(id.clone(), new_pos);
self.entries.push(HnswEntry {
id,
embedding: Arc::from(embedding),
metadata,
});
self.dirty = true;
self.inserts_since_rebuild += 1;
}
pub fn query(&mut self, query_embedding: &[f32], top_k: usize) -> Vec<HnswQueryResult> {
if self.entries.is_empty() || top_k == 0 {
return Vec::new();
}
if self.entries.len() < HNSW_THRESHOLD {
return self.brute_force_query(query_embedding, top_k);
}
if self.index.is_none()
|| (self.dirty && self.inserts_since_rebuild >= self.rebuild_threshold)
{
self.rebuild_index();
}
let Some(ref index) = self.index else {
return self.brute_force_query(query_embedding, top_k);
};
let query_point = EmbeddingPoint {
embedding: Arc::from(query_embedding),
};
let mut search = Search::default();
let mut results: Vec<HnswQueryResult> = index
.search(&query_point, &mut search)
.take(top_k)
.filter_map(|item| {
let idx = *item.value;
if idx < self.entries.len() {
let entry = &self.entries[idx];
Some(HnswQueryResult {
id: entry.id.clone(),
score: cosine_similarity(query_embedding, &entry.embedding),
metadata: entry.metadata.clone(),
})
} else {
None
}
})
.collect();
if self.dirty && self.index_built_len < self.entries.len() {
for entry in &self.entries[self.index_built_len..] {
let score = cosine_similarity(query_embedding, &entry.embedding);
results.push(HnswQueryResult {
id: entry.id.clone(),
score,
metadata: entry.metadata.clone(),
});
}
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.dedup_by(|a, b| a.id == b.id);
results.truncate(top_k);
results
}
pub fn delete(&mut self, id: &str) -> bool {
if let Some(pos) = self.id_index.remove(id) {
self.entries.swap_remove(pos);
if pos < self.entries.len() {
let moved_id = self.entries[pos].id.clone();
self.id_index.insert(moved_id, pos);
}
self.dirty = true;
self.inserts_since_rebuild += 1;
true
} else {
false
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, id: &str) -> Option<&HnswEntry> {
self.id_index.get(id).map(|&pos| &self.entries[pos])
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let snapshot = StoreSnapshot {
entries: self.entries.clone(),
ef_search: self.ef_search,
ef_construction: self.ef_construction,
};
let json = serde_json::to_string_pretty(&snapshot)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
std::fs::write(path, json)
}
pub fn load(path: &Path) -> std::io::Result<Self> {
if !path.exists() {
return Ok(Self::new());
}
let data = std::fs::read_to_string(path)?;
let snapshot: StoreSnapshot = serde_json::from_str(&data)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
debug!(
entries = snapshot.entries.len(),
ef_search = snapshot.ef_search,
"loaded HnswStore from disk"
);
Ok(Self::from_entries(
snapshot.entries,
snapshot.ef_search,
snapshot.ef_construction,
))
}
pub fn rebuild_index(&mut self) {
if self.entries.is_empty() {
self.index = None;
self.dirty = false;
self.index_built_len = 0;
self.inserts_since_rebuild = 0;
return;
}
let points: Vec<EmbeddingPoint> = self
.entries
.iter()
.map(|e| EmbeddingPoint {
embedding: Arc::clone(&e.embedding),
})
.collect();
let values: Vec<usize> = (0..self.entries.len()).collect();
debug!(
entries = self.entries.len(),
ef_construction = self.ef_construction,
ef_search = self.ef_search,
"rebuilding HNSW index"
);
let map = Builder::default()
.ef_search(self.ef_search)
.ef_construction(self.ef_construction)
.build(points, values);
self.index = Some(map);
self.dirty = false;
self.index_built_len = self.entries.len();
self.inserts_since_rebuild = 0;
}
pub fn force_rebuild(&mut self) {
self.rebuild_index();
}
pub fn set_ef_search(&mut self, ef: usize) {
self.ef_search = ef;
if self.entries.len() >= HNSW_THRESHOLD {
self.rebuild_index();
}
}
pub fn ef_search(&self) -> usize {
self.ef_search
}
pub fn ef_construction(&self) -> usize {
self.ef_construction
}
pub fn entries(&self) -> &[HnswEntry] {
&self.entries
}
pub fn from_entries(
entries: Vec<HnswEntry>,
ef_search: usize,
ef_construction: usize,
) -> Self {
let mut id_index: HashMap<String, usize> = HashMap::with_capacity(entries.len());
for (i, e) in entries.iter().enumerate() {
id_index.insert(e.id.clone(), i);
}
let mut store = Self {
entries,
id_index,
index: None,
ef_search,
ef_construction,
dirty: true,
index_built_len: 0,
inserts_since_rebuild: 0,
rebuild_threshold: DEFAULT_REBUILD_THRESHOLD,
};
if store.entries.len() >= HNSW_THRESHOLD {
store.rebuild_index();
}
store
}
pub fn inserts_since_rebuild(&self) -> usize {
self.inserts_since_rebuild
}
pub fn brute_force_topk(&self, query: &[f32], top_k: usize) -> Vec<String> {
self.brute_force_query(query, top_k)
.into_iter()
.map(|r| r.id)
.collect()
}
pub fn entry_id_embeddings(&self) -> Vec<(String, Vec<f32>)> {
self.entries
.iter()
.map(|e| (e.id.clone(), e.embedding.to_vec()))
.collect()
}
pub fn query_guided(
&mut self,
query_embedding: &[f32],
top_k: usize,
table: &RegionEntryTable,
probes: usize,
) -> (Vec<HnswQueryResult>, PathPrediction) {
let prediction = table.predict(query_embedding, probes);
if self.entries.is_empty() || top_k == 0 {
return (Vec::new(), prediction);
}
let mut results = self.score_candidates(query_embedding, &prediction.candidate_ids);
if !prediction.entry_ids.is_empty() {
let entry_hits = self.score_candidates(query_embedding, &prediction.entry_ids);
results.extend(entry_hits);
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.dedup_by(|a, b| a.id == b.id);
if results.len() < top_k {
let hnsw = self.query(query_embedding, top_k);
results.extend(hnsw);
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.dedup_by(|a, b| a.id == b.id);
}
results.truncate(top_k);
(results, prediction)
}
fn score_candidates(&self, query: &[f32], ids: &[String]) -> Vec<HnswQueryResult> {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
if let Some(&pos) = self.id_index.get(id) {
let entry = &self.entries[pos];
out.push(HnswQueryResult {
id: entry.id.clone(),
score: cosine_similarity(query, &entry.embedding),
metadata: entry.metadata.clone(),
});
}
}
out
}
fn brute_force_query(&self, query_embedding: &[f32], top_k: usize) -> Vec<HnswQueryResult> {
let mut scored: Vec<HnswQueryResult> = self
.entries
.iter()
.map(|entry| HnswQueryResult {
id: entry.id.clone(),
score: cosine_similarity(query_embedding, &entry.embedding),
metadata: entry.metadata.clone(),
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);
scored
}
}
impl Default for HnswStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct TierConfig {
pub dims: Vec<usize>,
pub keep: usize,
}
pub struct TieredSearch {
coarse_index: HnswStore,
coarse_dims: Vec<usize>,
coarse_keep: usize,
medium_dims: Vec<usize>,
medium_keep: usize,
full_store: HnswStore,
}
fn project(embedding: &[f32], dims: &[usize]) -> Vec<f32> {
dims.iter()
.map(|&d| {
if d < embedding.len() {
embedding[d]
} else {
0.0
}
})
.collect()
}
impl TieredSearch {
pub fn build(
entries: &[(String, Vec<f32>, serde_json::Value)],
coarse_dims: Vec<usize>,
medium_dims: Vec<usize>,
top_k: usize,
ef: usize,
) -> Self {
let mut coarse_index = HnswStore::with_params(ef, 200);
for (id, emb, meta) in entries {
let projected = project(emb, &coarse_dims);
coarse_index.insert(id.clone(), projected, meta.clone());
}
coarse_index.force_rebuild();
let mut full_store = HnswStore::new();
for (id, emb, meta) in entries {
full_store.insert(id.clone(), emb.clone(), meta.clone());
}
let coarse_keep = (top_k * 50).max(200);
let medium_keep = (top_k * 5).max(20);
TieredSearch {
coarse_index,
coarse_dims,
coarse_keep,
medium_dims,
medium_keep,
full_store,
}
}
pub fn build_default(
entries: &[(String, Vec<f32>, serde_json::Value)],
dims: usize,
top_k: usize,
ef: usize,
) -> Self {
let coarse_n = (dims / 16).max(2);
let medium_n = (dims / 4).max(4);
Self::build(
entries,
(0..coarse_n).collect(),
(0..medium_n).collect(),
top_k,
ef,
)
}
pub fn build_learned(
entries: &[(String, Vec<f32>, serde_json::Value)],
dim_order: &[usize],
dims: usize,
top_k: usize,
ef: usize,
) -> Self {
let coarse_n = (dims / 16).max(2);
let medium_n = (dims / 4).max(4);
Self::build(
entries,
dim_order[..coarse_n.min(dim_order.len())].to_vec(),
dim_order[..medium_n.min(dim_order.len())].to_vec(),
top_k,
ef,
)
}
pub fn len(&self) -> usize {
self.full_store.len()
}
pub fn is_empty(&self) -> bool {
self.full_store.is_empty()
}
pub fn search(&mut self, query: &[f32], top_k: usize) -> Vec<HnswQueryResult> {
if self.full_store.is_empty() || top_k == 0 {
return Vec::new();
}
let coarse_query = project(query, &self.coarse_dims);
let mut candidates: Vec<(String, f32, serde_json::Value)> = self
.coarse_index
.query(&coarse_query, self.coarse_keep)
.into_iter()
.map(|r| (r.id, r.score, r.metadata))
.collect();
if !self.medium_dims.is_empty() && candidates.len() > self.medium_keep {
for c in &mut candidates {
if let Some(entry) = self.full_store.get(&c.0) {
c.1 = cosine_partial(query, &entry.embedding, &self.medium_dims);
}
}
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
candidates.truncate(self.medium_keep);
}
let mut results: Vec<HnswQueryResult> = candidates
.into_iter()
.filter_map(|(id, _, _)| {
self.full_store.get(&id).map(|entry| HnswQueryResult {
id: id.clone(),
score: cosine_similarity(query, &entry.embedding),
metadata: entry.metadata.clone(),
})
})
.collect();
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(top_k);
results
}
pub fn brute_force_topk(&self, query: &[f32], top_k: usize) -> Vec<String> {
self.full_store.brute_force_topk(query, top_k)
}
}
fn cosine_partial(a: &[f32], b: &[f32], dims: &[usize]) -> f32 {
let mut dot = 0.0_f32;
let mut norm_a = 0.0_f32;
let mut norm_b = 0.0_f32;
let len = a.len().min(b.len());
for &d in dims {
if d < len {
let va = a[d];
let vb = b[d];
dot += va * vb;
norm_a += va * va;
norm_b += vb * vb;
}
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
}
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::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path(label: &str) -> std::path::PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_hnsw_test_{label}_{pid}_{n}.json"))
}
#[test]
fn create_empty_store() {
let store = HnswStore::new();
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn insert_and_brute_force_query() {
let mut store = HnswStore::new();
store.insert(
"doc1".into(),
vec![1.0, 0.0, 0.0],
serde_json::json!({"text": "hello"}),
);
store.insert(
"doc2".into(),
vec![0.0, 1.0, 0.0],
serde_json::json!({"text": "world"}),
);
let results = store.query(&[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 query_ordering() {
let mut store = HnswStore::new();
store.insert("a".into(), vec![1.0, 0.0], serde_json::json!({}));
store.insert("b".into(), vec![0.7, 0.7], serde_json::json!({}));
store.insert("c".into(), vec![0.0, 1.0], serde_json::json!({}));
let results = store.query(&[1.0, 0.0], 3);
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[test]
fn upsert_semantics() {
let mut store = HnswStore::new();
store.insert("doc1".into(), vec![1.0, 0.0], serde_json::json!({"v": 1}));
store.insert("doc1".into(), vec![0.0, 1.0], serde_json::json!({"v": 2}));
assert_eq!(store.len(), 1);
let entry = store.get("doc1").unwrap();
assert_eq!(entry.metadata["v"], 2);
assert_eq!(&*entry.embedding, &[0.0, 1.0]);
}
#[test]
fn delete_entry() {
let mut store = HnswStore::new();
store.insert("doc1".into(), vec![1.0], serde_json::json!({}));
store.insert("doc2".into(), vec![0.0], serde_json::json!({}));
assert!(store.delete("doc1"));
assert_eq!(store.len(), 1);
assert!(!store.delete("doc1"));
}
#[test]
fn query_empty_store() {
let mut store = HnswStore::new();
let results = store.query(&[1.0, 0.0], 5);
assert!(results.is_empty());
}
#[test]
fn query_top_k_zero() {
let mut store = HnswStore::new();
store.insert("x".into(), vec![1.0], serde_json::json!({}));
let results = store.query(&[1.0], 0);
assert!(results.is_empty());
}
#[test]
fn save_and_load_roundtrip() {
let path = temp_path("roundtrip");
{
let mut store = HnswStore::new();
store.insert(
"doc1".into(),
vec![1.0, 0.0, 0.0],
serde_json::json!({"text": "hello"}),
);
store.insert(
"doc2".into(),
vec![0.0, 1.0, 0.0],
serde_json::json!({"text": "world"}),
);
store.save(&path).unwrap();
}
let mut store = HnswStore::load(&path).unwrap();
assert_eq!(store.len(), 2);
let results = store.query(&[1.0, 0.0, 0.0], 1);
assert_eq!(results[0].id, "doc1");
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_nonexistent_returns_empty() {
let path = temp_path("nonexist");
let _ = std::fs::remove_file(&path);
let store = HnswStore::load(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn hnsw_index_triggers_above_threshold() {
let mut store = HnswStore::new();
for i in 0..HNSW_THRESHOLD + 5 {
let dim = 16;
let mut emb = vec![0.0f32; dim];
emb[i % dim] = 1.0;
store.insert(format!("doc{i}"), emb, serde_json::json!({"idx": i}));
}
let mut query = vec![0.0f32; 16];
query[0] = 1.0;
let results = store.query(&query, 3);
assert!(!results.is_empty());
assert!(results.len() <= 3);
}
#[test]
fn get_entry_by_id() {
let mut store = HnswStore::new();
store.insert(
"doc1".into(),
vec![1.0, 2.0],
serde_json::json!({"key": "value"}),
);
let entry = store.get("doc1");
assert!(entry.is_some());
assert_eq!(entry.unwrap().id, "doc1");
assert!(store.get("nonexistent").is_none());
}
#[test]
fn default_creates_empty() {
let store = HnswStore::default();
assert!(store.is_empty());
}
#[test]
fn cosine_similarity_identical() {
let score = cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]);
assert!((score - 1.0).abs() < 0.01);
}
#[test]
fn cosine_similarity_orthogonal() {
let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
assert!(score.abs() < 0.01);
}
#[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 with_params_custom_values() {
let store = HnswStore::with_params(50, 100);
assert_eq!(store.ef_search, 50);
assert_eq!(store.ef_construction, 100);
assert!(store.is_empty());
}
#[test]
fn guided_query_hits_right_cluster() {
use crate::embeddings::path_predict::PathPredictConfig;
let mut store = HnswStore::new();
for i in 0..15 {
let n = 0.02 * (i as f32);
store.insert(format!("a{i}"), vec![1.0 + n, n, n], serde_json::json!({}));
store.insert(format!("b{i}"), vec![n, 1.0 + n, n], serde_json::json!({}));
store.insert(format!("c{i}"), vec![n, n, 1.0 + n], serde_json::json!({}));
}
let pairs = store.entry_id_embeddings();
let table = RegionEntryTable::build(
&pairs,
PathPredictConfig {
num_regions: 3,
region_probes: 1,
entries_per_region: 2,
kmeans_iters: 12,
},
);
let (results, pred) = store.query_guided(&[1.0, 0.0, 0.0], 5, &table, 1);
assert!(pred.is_learned);
assert!(!results.is_empty());
assert!(
results[0].id.starts_with('a'),
"top hit should be cluster A, got {}",
results[0].id
);
}
#[test]
fn hnsw_query_consistency_with_brute_force() {
let dim = 8;
let n = HNSW_THRESHOLD + 10;
let mut store = HnswStore::new();
for i in 0..n {
let mut emb = vec![0.0f32; dim];
emb[i % dim] = 1.0;
for (j, val) in emb.iter_mut().enumerate() {
*val += (i as f32 * 0.001) * ((j + 1) as f32);
}
store.insert(format!("d{i}"), emb, serde_json::json!({}));
}
let target = Arc::clone(&store.get("d0").unwrap().embedding);
let results = store.query(&target, 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "d0");
assert!(results[0].score > 0.9);
}
}