use anyhow::Result;
use serde_json::json;
use sqlitegraph::hnsw::{DistanceMetric, HnswConfig};
use sqlitegraph::SqliteGraph;
use super::embed::{symbol_embed_text, TextEmbedder};
const SEARCH_INDEX_NAME: &str = "symbols";
#[derive(Clone, Debug, serde::Serialize)]
pub struct HopgraphHit {
pub entity_id: i64,
pub score: f32,
pub name: String,
pub kind: String,
pub file_path: Option<String>,
pub start_line: usize,
#[serde(skip_serializing_if = "is_zero")]
pub hop_distance: u32,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn search_config(dim: usize) -> Result<HnswConfig> {
sqlitegraph::hnsw::HnswConfigBuilder::new()
.dimension(dim)
.distance_metric(DistanceMetric::Cosine)
.ef_search(200)
.enable_multilayer(true)
.build()
.map_err(|e| anyhow::anyhow!("HNSW config build failed: {}", e))
}
fn index_in_memory(graph: &SqliteGraph) -> bool {
graph
.get_hnsw_index(SEARCH_INDEX_NAME)
.map(|opt| opt.is_some())
.unwrap_or(false)
}
fn ensure_index_in_memory(graph: &SqliteGraph, dim: usize) -> Result<()> {
if index_in_memory(graph) {
return Ok(());
}
let config = search_config(dim)?;
{
let _guard = graph
.hnsw_index_persistent(SEARCH_INDEX_NAME, config)
.map_err(|e| anyhow::anyhow!("hnsw_index_persistent failed: {}", e))?;
}
Ok(())
}
pub fn ensure_search_index(
graph: &SqliteGraph,
embedder: &dyn TextEmbedder,
entities: &[sqlitegraph::GraphEntity],
) -> Result<()> {
if index_in_memory(graph) {
return Ok(());
}
ensure_index_in_memory(graph, embedder.dimension())?;
for entity in entities {
let text = symbol_embed_text(entity, None);
let vector = embedder.embed(&text)?;
let entity_id = entity.id;
let _ = graph.get_hnsw_index_mut(SEARCH_INDEX_NAME, move |idx| {
idx.insert_vector(&vector, Some(json!({"entity_id": entity_id})))
});
}
Ok(())
}
pub fn add_to_search_index(
graph: &SqliteGraph,
embedder: &dyn TextEmbedder,
entity: &sqlitegraph::GraphEntity,
) -> Result<()> {
ensure_index_in_memory(graph, embedder.dimension())?;
let text = symbol_embed_text(entity, None);
let vector = embedder.embed(&text)?;
let entity_id = entity.id;
graph
.get_hnsw_index_mut(SEARCH_INDEX_NAME, move |idx| {
idx.insert_vector(&vector, Some(json!({"entity_id": entity_id})))
})
.map_err(|e| anyhow::anyhow!("insert_vector failed: {}", e))?
.map_err(|e| anyhow::anyhow!("hnsw insert failed: {}", e))?;
Ok(())
}
pub fn add_to_search_index_with_vector(
graph: &SqliteGraph,
entity_id: i64,
vector: &[f32],
) -> Result<()> {
ensure_index_in_memory(graph, vector.len())?;
let v = vector.to_vec();
graph
.get_hnsw_index_mut(SEARCH_INDEX_NAME, move |idx| {
idx.insert_vector(&v, Some(json!({"entity_id": entity_id})))
})
.map_err(|e| anyhow::anyhow!("insert_vector failed: {}", e))?
.map_err(|e| anyhow::anyhow!("hnsw insert failed: {}", e))?;
Ok(())
}
pub fn bulk_add_to_search_index(graph: &SqliteGraph, entries: &[(i64, Vec<f32>)]) -> Result<usize> {
if entries.is_empty() {
return Ok(0);
}
let dim = entries[0].1.len();
ensure_index_in_memory(graph, dim)?;
let batch: Vec<(Vec<f32>, Option<serde_json::Value>)> = entries
.iter()
.map(|(entity_id, vec)| (vec.clone(), Some(json!({"entity_id": *entity_id}))))
.collect();
let count = batch.len();
graph
.get_hnsw_index_mut(SEARCH_INDEX_NAME, move |idx| {
idx.batch_insert_vectors(&batch)
})
.map_err(|e| anyhow::anyhow!("batch_insert failed: {}", e))?
.map_err(|e| anyhow::anyhow!("hnsw batch insert failed: {}", e))?;
Ok(count)
}
pub fn remove_from_search_index(_graph: &SqliteGraph, _entity_id: i64) -> Result<()> {
Ok(())
}
pub fn clear_search_index(graph: &SqliteGraph) -> Result<()> {
graph
.delete_hnsw_index(SEARCH_INDEX_NAME)
.map_err(|e| anyhow::anyhow!("failed to delete HNSW index: {}", e))?;
Ok(())
}
pub fn search_symbols(
graph: &SqliteGraph,
embedder: &dyn TextEmbedder,
query: &str,
k: usize,
) -> Result<Vec<(i64, f32)>> {
ensure_index_in_memory(graph, embedder.dimension())?;
if !index_in_memory(graph) {
return Ok(Vec::new());
}
let query_vec = embedder.embed(query)?;
let hits = graph
.get_hnsw_index_ref(SEARCH_INDEX_NAME, |idx| idx.search(&query_vec, k * 2))
.map_err(|e| anyhow::anyhow!("hnsw search failed: {}", e))?
.map_err(|e| anyhow::anyhow!("hnsw search error: {}", e))?;
let mut results = Vec::with_capacity(hits.len());
for (vector_id, score) in hits {
if results.len() >= k {
break;
}
let metadata = graph
.get_hnsw_index_ref(SEARCH_INDEX_NAME, |idx| {
idx.get_vector(vector_id).ok().flatten()
})
.map_err(|e| anyhow::anyhow!("get_vector failed: {}", e))?;
if let Some((_vec, meta)) = metadata {
if let Some(entity_id) = meta.get("entity_id").and_then(|v| v.as_i64()) {
if graph.get_entity(entity_id).is_ok() {
results.push((entity_id, score));
}
}
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::embed::HashEmbedder;
#[test]
fn test_search_config_builds() {
let config = search_config(128).unwrap();
assert_eq!(config.dimension, 128);
}
#[test]
fn test_symbol_embed_text_extracts_fields() {
let entity = sqlitegraph::GraphEntity {
id: 1,
kind: "Symbol".to_string(),
name: "parse_rust".to_string(),
file_path: Some("src/lib.rs".to_string()),
data: serde_json::json!({
"fqn": "magellan::parse_rust",
"kind_normalized": "function",
}),
};
let text = symbol_embed_text(&entity, None);
assert!(text.contains("parse_rust"));
assert!(text.contains("magellan::parse_rust"));
}
#[test]
fn test_add_and_search_roundtrip() {
let graph = SqliteGraph::open_in_memory().unwrap();
let embedder = HashEmbedder::new(128);
let e1 = sqlitegraph::GraphEntity {
id: 0,
kind: "Symbol".to_string(),
name: "parse_rust".to_string(),
file_path: None,
data: serde_json::json!({"fqn": "magellan::parse_rust"}),
};
let e2 = sqlitegraph::GraphEntity {
id: 0,
kind: "Symbol".to_string(),
name: "parse_python".to_string(),
file_path: None,
data: serde_json::json!({"fqn": "magellan::parse_python"}),
};
let id1 = graph.insert_entity(&e1).unwrap();
let id2 = graph.insert_entity(&e2).unwrap();
let entities: Vec<sqlitegraph::GraphEntity> = vec![
sqlitegraph::GraphEntity { id: id1, ..e1 },
sqlitegraph::GraphEntity { id: id2, ..e2 },
];
ensure_search_index(&graph, &embedder, &entities).unwrap();
let results = search_symbols(&graph, &embedder, "parse_rust", 2).unwrap();
assert!(!results.is_empty(), "search should find results");
assert_eq!(
results[0].0, id1,
"top result should be entity {} (parse_rust)",
id1
);
}
#[test]
fn test_remove_from_index() {
let graph = SqliteGraph::open_in_memory().unwrap();
let embedder = HashEmbedder::new(128);
let e1 = sqlitegraph::GraphEntity {
id: 0,
kind: "Symbol".to_string(),
name: "parse_rust".to_string(),
file_path: None,
data: serde_json::json!({}),
};
let e2 = sqlitegraph::GraphEntity {
id: 0,
kind: "Symbol".to_string(),
name: "parse_python".to_string(),
file_path: None,
data: serde_json::json!({}),
};
let id1 = graph.insert_entity(&e1).unwrap();
let id2 = graph.insert_entity(&e2).unwrap();
let entities: Vec<sqlitegraph::GraphEntity> = vec![
sqlitegraph::GraphEntity { id: id1, ..e1 },
sqlitegraph::GraphEntity { id: id2, ..e2 },
];
ensure_search_index(&graph, &embedder, &entities).unwrap();
graph.delete_entity(id2).unwrap();
let results = search_symbols(&graph, &embedder, "parse_python", 2).unwrap();
let found_ids: Vec<i64> = results.iter().map(|(id, _)| *id).collect();
assert!(
!found_ids.contains(&id2),
"deleted entity should not appear in results"
);
}
}