use super::EmbeddingVector;
use dashmap::DashMap;
use std::hash::Hash;
use std::hash::Hasher;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IndexError {
#[error("Index not found for key: {0:?}")]
IndexNotFound(IndexKey),
#[error("Dimension mismatch: expected {expected}, got {actual}")]
DimensionMismatch { expected: usize, actual: usize },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(String),
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct IndexKey {
pub repo_root: PathBuf,
pub model_id: String,
pub dimensions: usize,
}
impl IndexKey {
pub fn new(repo: &Path, model: &str, dimensions: usize) -> Result<Self, IndexError> {
let repo_root = repo.canonicalize()?;
Ok(Self {
repo_root,
model_id: model.to_string(),
dimensions,
})
}
pub fn storage_hash(&self) -> String {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
}
pub struct VectorIndex {
dimensions: usize,
vectors: Vec<(Vec<f32>, ChunkMetadata)>,
}
impl VectorIndex {
pub const fn new(dimensions: usize) -> Self {
Self {
dimensions,
vectors: Vec::new(),
}
}
pub fn insert(&mut self, vector: Vec<f32>, metadata: ChunkMetadata) -> Result<(), IndexError> {
if vector.len() != self.dimensions {
return Err(IndexError::DimensionMismatch {
expected: self.dimensions,
actual: vector.len(),
});
}
self.vectors.push((vector, metadata));
Ok(())
}
pub fn search(&self, query: &[f32], limit: usize) -> Result<Vec<SearchResult>, IndexError> {
if query.len() != self.dimensions {
return Err(IndexError::DimensionMismatch {
expected: self.dimensions,
actual: query.len(),
});
}
let mut results: Vec<_> = self
.vectors
.iter()
.map(|(vec, meta)| {
let similarity = cosine_similarity(query, vec);
SearchResult {
similarity,
metadata: meta.clone(),
}
})
.collect();
results.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
results.truncate(limit);
Ok(results)
}
pub async fn save_to_disk(&self, path: &Path) -> Result<(), IndexError> {
let data = bincode::encode_to_vec(&self.vectors, bincode::config::standard())
.map_err(|e| IndexError::Serialization(e.to_string()))?;
tokio::fs::write(path, data).await?;
Ok(())
}
pub async fn load_from_disk(path: &Path) -> Result<Self, IndexError> {
let data = tokio::fs::read(path).await?;
let vectors: Vec<(EmbeddingVector, ChunkMetadata)> =
bincode::decode_from_slice(&data, bincode::config::standard())
.map_err(|e| IndexError::Serialization(e.to_string()))?
.0;
let dimensions = vectors.first().map(|(v, _)| v.len()).unwrap_or(1536);
Ok(Self {
dimensions,
vectors,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bincode::Encode, bincode::Decode)]
pub struct ChunkMetadata {
pub file_path: PathBuf,
pub start_line: usize,
pub end_line: usize,
pub content_hash: u64,
pub chunk_type: String,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub similarity: f32,
pub metadata: ChunkMetadata,
}
pub struct EmbeddingIndexManager {
indexes: DashMap<IndexKey, Arc<VectorIndex>>,
storage_dir: PathBuf,
}
impl EmbeddingIndexManager {
pub fn new(storage_dir: PathBuf) -> Self {
Self {
indexes: DashMap::new(),
storage_dir,
}
}
pub fn get_or_create_index(
&self,
repo: &Path,
model: &str,
dimensions: usize,
) -> Result<Arc<VectorIndex>, IndexError> {
let key = IndexKey::new(repo, model, dimensions)?;
if let Some(index) = self.indexes.get(&key) {
return Ok(index.clone());
}
let storage_path = self.index_storage_path(&key);
if storage_path.exists() {
let runtime = tokio::runtime::Runtime::new()?;
let index = runtime.block_on(VectorIndex::load_from_disk(&storage_path))?;
let index = Arc::new(index);
self.indexes.insert(key, index.clone());
return Ok(index);
}
let index = Arc::new(VectorIndex::new(dimensions));
self.indexes.insert(key, index.clone());
Ok(index)
}
pub fn search(
&self,
repo: &Path,
model: &str,
dimensions: usize,
query_vector: &[f32],
limit: usize,
) -> Result<Vec<SearchResult>, IndexError> {
let index = self.get_or_create_index(repo, model, dimensions)?;
index.search(query_vector, limit)
}
fn index_storage_path(&self, key: &IndexKey) -> PathBuf {
self.storage_dir
.join(key.storage_hash())
.join(&key.model_id)
.join(format!("dim_{}", key.dimensions))
.join("index.bincode")
}
pub async fn save_all(&self) -> Result<(), IndexError> {
for entry in self.indexes.iter() {
let key = entry.key();
let index = entry.value();
let path = self.index_storage_path(key);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
index.save_to_disk(&path).await?;
}
Ok(())
}
pub fn stats(&self) -> IndexManagerStats {
let mut stats = IndexManagerStats::default();
for entry in self.indexes.iter() {
stats.total_indexes += 1;
let model = &entry.key().model_id;
*stats.indexes_by_model.entry(model.clone()).or_insert(0) += 1;
let dims = entry.key().dimensions;
*stats.indexes_by_dimensions.entry(dims).or_insert(0) += 1;
}
stats
}
}
#[derive(Debug, Default)]
pub struct IndexManagerStats {
pub total_indexes: usize,
pub indexes_by_model: std::collections::HashMap<String, usize>,
pub indexes_by_dimensions: std::collections::HashMap<usize, usize>,
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let magnitude_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let magnitude_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if magnitude_a * magnitude_b == 0.0 {
0.0
} else {
dot_product / (magnitude_a * magnitude_b)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_index_key_separation() {
let temp_dir = tempdir().unwrap();
let repo1_path = temp_dir.path().join("repo1");
let repo2_path = temp_dir.path().join("repo2");
std::fs::create_dir(&repo1_path).unwrap();
std::fs::create_dir(&repo2_path).unwrap();
let key1 = IndexKey::new(&repo1_path, "model1", 1536).unwrap();
let key2 = IndexKey::new(&repo1_path, "model2", 1536).unwrap();
let key3 = IndexKey::new(&repo2_path, "model1", 1536).unwrap();
let key4 = IndexKey::new(&repo1_path, "model1", 768).unwrap();
assert_ne!(key1, key2); assert_ne!(key1, key3); assert_ne!(key1, key4); }
#[test]
fn test_dimension_mismatch_protection() {
let mut index = VectorIndex::new(1536);
let vec_1536 = vec![0.1; 1536];
let metadata = ChunkMetadata {
file_path: PathBuf::from("test.rs"),
start_line: 1,
end_line: 10,
content_hash: 12345,
chunk_type: "function".to_string(),
symbols: vec!["test_fn".to_string()],
};
assert!(index.insert(vec_1536, metadata.clone()).is_ok());
let vec_768 = vec![0.1; 768];
assert!(matches!(
index.insert(vec_768, metadata),
Err(IndexError::DimensionMismatch { .. })
));
}
#[test]
fn test_index_manager_separation() {
let dir = tempdir().unwrap();
let manager = EmbeddingIndexManager::new(dir.path().to_path_buf());
let temp_repos = tempdir().unwrap();
let repo1_path = temp_repos.path().join("repo1");
let repo2_path = temp_repos.path().join("repo2");
std::fs::create_dir(&repo1_path).unwrap();
std::fs::create_dir(&repo2_path).unwrap();
let index1 = manager
.get_or_create_index(&repo1_path, "openai:text-embedding-3-small", 1536)
.unwrap();
let index2 = manager
.get_or_create_index(&repo1_path, "gemini:embedding-001", 768)
.unwrap();
let index3 = manager
.get_or_create_index(&repo2_path, "openai:text-embedding-3-small", 1536)
.unwrap();
assert!(!Arc::ptr_eq(&index1, &index2));
assert!(!Arc::ptr_eq(&index1, &index3));
assert!(!Arc::ptr_eq(&index2, &index3));
let stats = manager.stats();
assert_eq!(stats.total_indexes, 3);
}
}