use std::collections::HashSet;
use petgraph::graph::NodeIndex;
use super::{
collect_embed_candidates, embed_batches, resolve_source_column, store_key, EmbedError,
EmbedHooks, EmbedMode,
};
use crate::graph::algorithms::vector::DistanceMetric;
use crate::graph::dir_graph::DirGraph;
use crate::graph::embedder::Embedder;
use crate::graph::schema::EmbeddingStore;
pub(crate) fn journal_node_store(graph: &mut DirGraph, node_type: &str, text_column: &str) {
let key = store_key(node_type, text_column);
let embeddings = &graph.embeddings;
if let Some(journal) = graph.graph.undo_journal_mut() {
journal.note_node_embedding_store_touched(&key, || embeddings.get(&key).cloned());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NodeEmbedReport {
pub embedded: usize,
pub skipped: usize,
pub dimension: usize,
pub model_id: Option<String>,
}
pub(crate) struct NodeEmbedRequest<'a> {
pub node_type: &'a str,
pub text_column: &'a str,
pub selected: &'a [NodeIndex],
pub mode: EmbedMode,
pub batch_size: usize,
pub metric: Option<&'a str>,
}
pub(crate) fn embed_selected_nodes(
graph: &mut DirGraph,
request: NodeEmbedRequest<'_>,
model: Option<&dyn Embedder>,
) -> Result<NodeEmbedReport, EmbedError> {
let NodeEmbedRequest {
node_type,
text_column,
selected,
mode,
batch_size,
metric,
} = request;
if let Some(name) = metric {
if DistanceMetric::from_name(name).is_none() {
return Err(EmbedError::Output(format!(
"Unknown distance metric '{name}'. Use cosine, dot_product, euclidean, or poincare."
)));
}
}
let key = store_key(node_type, text_column);
let existing = graph.embeddings.get(&key);
let untouched = NodeEmbedReport {
embedded: 0,
skipped: 0,
dimension: existing.map_or(0, |store| store.dimension),
model_id: existing.and_then(|store| store.model_id.clone()),
};
if selected.is_empty() {
return Ok(untouched);
}
if let (Some(store), Some(requested)) = (existing, metric) {
let stored = store.metric.as_deref().unwrap_or("cosine");
if stored != requested {
return Err(EmbedError::Output(format!(
"Store metric is '{stored}', but this pass requested '{requested}'"
)));
}
}
let arena_guard = graph.begin_read_pass();
let source_field = resolve_source_column(graph, node_type, text_column)
.map_err(EmbedError::Column)?
.to_string();
let source_key = crate::graph::storage::interner::InternedKey::from_str(&source_field);
let found = {
let _arena_guard = arena_guard;
collect_embed_candidates(
graph,
selected,
node_type,
&source_field,
source_key,
existing,
mode,
)
};
let taken: HashSet<usize> = found.texts.iter().map(|(slot, _, _)| *slot).collect();
let remove: Vec<usize> = if mode == EmbedMode::All {
selected
.iter()
.map(|node| node.index())
.filter(|slot| {
!taken.contains(slot)
&& existing.is_some_and(|store| store.get_embedding(*slot).is_some())
})
.collect()
} else {
Vec::new()
};
if found.texts.is_empty() && remove.is_empty() {
return Ok(NodeEmbedReport {
skipped: found.skipped,
..untouched
});
}
let model = model.ok_or_else(|| {
EmbedError::Output("node embedding generation requires a registered embedder".into())
})?;
let requested_model = model.model_id();
if mode != EmbedMode::All {
if let Some(prior) = existing.and_then(|store| store.model_id.as_deref()) {
if requested_model.as_deref() != Some(prior) {
return Err(EmbedError::Output(format!(
"the existing embedding store was generated by model '{prior}', but the \
current model is {}; use mode='all' to rebuild the selected nodes",
requested_model
.as_deref()
.map(|id| format!("'{id}'"))
.unwrap_or_else(|| "unknown".to_string())
)));
}
}
}
let selected_slots: HashSet<usize> = selected.iter().map(|node| node.index()).collect();
let unselected_vectors_remain = existing.is_some_and(|store| {
store
.slot_to_node
.iter()
.any(|slot| !selected_slots.contains(slot))
});
let had_existing_vectors = existing.is_some_and(|store| store.len() > 0);
let prior_model = existing.and_then(|store| store.model_id.clone());
model.load().map_err(EmbedError::Model)?;
let dimension = model.dimension();
let mut store = match existing {
Some(store) if store.dimension == dimension => store.clone(),
Some(store) if mode != EmbedMode::All || unselected_vectors_remain => {
model.unload();
return Err(EmbedError::Dimension {
store: store.dimension,
model: dimension,
});
}
Some(store) => match store.metric.as_deref().or(metric) {
Some(name) => EmbeddingStore::with_metric(dimension, name),
None => EmbeddingStore::new(dimension),
},
None => match metric {
Some(name) => EmbeddingStore::with_metric(dimension, name),
None => EmbeddingStore::new(dimension),
},
};
let hooks = EmbedHooks {
batch_size,
..EmbedHooks::default()
};
let written = embed_batches(&mut store, &found.texts, model, dimension, &hooks);
model.unload();
written?;
for slot in &remove {
store.remove_embedding(*slot);
}
store.model_id = crate::graph::edge_embedding_generation::final_model_id(
prior_model.as_deref(),
requested_model.as_deref(),
mode,
unselected_vectors_remain,
had_existing_vectors,
);
let model_id = store.model_id.clone();
journal_node_store(graph, node_type, text_column);
graph.set_embedding_store(node_type, text_column, store);
graph.bump_version();
Ok(NodeEmbedReport {
embedded: found.texts.len(),
skipped: found.skipped,
dimension,
model_id,
})
}
pub(crate) fn remove_node_vectors(
graph: &mut DirGraph,
node_type: &str,
text_column: &str,
nodes: &[NodeIndex],
) -> Result<usize, String> {
let Some(existing) = graph.embeddings.get(&store_key(node_type, text_column)) else {
return Err(format!(
"No embedding store '{node_type}.{}'",
super::store_name(text_column)
));
};
if !nodes
.iter()
.any(|node| existing.get_embedding(node.index()).is_some())
{
return Ok(0);
}
let mut store = existing.clone();
let removed = nodes
.iter()
.filter(|node| store.remove_embedding(node.index()).is_some())
.count();
journal_node_store(graph, node_type, text_column);
graph.set_embedding_store(node_type, text_column, store);
graph.bump_version();
Ok(removed)
}
pub(crate) fn drop_node_store(graph: &mut DirGraph, node_type: &str, text_column: &str) -> bool {
if !graph
.embeddings
.contains_key(&store_key(node_type, text_column))
{
return false;
}
journal_node_store(graph, node_type, text_column);
let dropped = graph.remove_embedding_store(node_type, text_column);
graph.bump_version();
dropped
}