use std::collections::HashSet;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use uuid::Uuid;
use khive_runtime::{KhiveRuntime, Namespace, RuntimeConfig};
use khive_storage::error::StorageError;
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
const MAX_EMBED_BYTES: usize = 32_768;
#[derive(Parser, Debug)]
pub struct ReindexArgs {
#[arg(long)]
pub db: Option<PathBuf>,
#[arg(long)]
pub model: Option<String>,
#[arg(long, default_value = "100")]
pub batch_size: u32,
#[arg(long)]
pub keep_existing: bool,
#[arg(long, default_value = "local")]
pub namespace: String,
#[arg(long)]
pub human: bool,
}
#[derive(Serialize)]
struct ReindexReport {
entities_processed: u64,
notes_processed: u64,
model_used: Option<String>,
elapsed_ms: u64,
errors_skipped: u64,
}
async fn filter_unembedded(
vectors: &dyn VectorStore,
ids: &[Uuid],
namespace: &str,
) -> Result<Vec<Uuid>> {
match vectors.batch_exists(ids, namespace).await {
Ok(existing) => Ok(ids
.iter()
.filter(|id| !existing.contains(id))
.copied()
.collect()),
Err(StorageError::Unsupported { .. }) => Ok(ids.to_vec()),
Err(e) => Err(anyhow::anyhow!("{e}")),
}
}
pub async fn run_reindex(args: ReindexArgs) -> Result<()> {
let mut cfg = RuntimeConfig::default();
if let Some(ref db) = args.db {
cfg.db_path = Some(db.clone());
}
let rt = KhiveRuntime::new(cfg).map_err(|e| anyhow::anyhow!("{e}"))?;
let ns = Namespace::parse(&args.namespace).map_err(|e| anyhow::anyhow!("{e}"))?;
let token = rt
.authorize(ns)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to authorize namespace")?;
let model_name: String = match args.model.as_deref().filter(|s| !s.is_empty()) {
Some(name) => name.to_string(),
None => {
let default = rt.default_embedder_name();
if default.is_empty() {
let report = ReindexReport {
entities_processed: 0,
notes_processed: 0,
model_used: None,
elapsed_ms: 0,
errors_skipped: 0,
};
print_report(&report, args.human);
eprintln!("warning: no embedding model configured");
return Ok(());
}
default.to_string()
}
};
let batch_size = args.batch_size.clamp(1, 500);
let drop_existing = !args.keep_existing;
let ns_str = token.namespace().as_str().to_owned();
let start = std::time::Instant::now();
let mut entities_processed: u64 = 0;
let mut errors_skipped: u64 = 0;
let mut entity_offset: u32 = 0;
loop {
let batch = rt
.list_entities(&token, None, None, batch_size, entity_offset)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let n = batch.len();
if n == 0 {
break;
}
let mut staged: Vec<(Uuid, String)> = Vec::with_capacity(n);
for entity in &batch {
let text = match &entity.description {
Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
_ => entity.name.clone(),
};
if !text.trim().is_empty() {
staged.push((entity.id, text));
}
}
if !staged.is_empty() {
if !drop_existing {
if let Ok(vectors) = rt.vectors_for_model(&token, &model_name) {
let all_ids: Vec<Uuid> = staged.iter().map(|(id, _)| *id).collect();
match filter_unembedded(vectors.as_ref(), &all_ids, &ns_str).await {
Ok(unembedded) => {
let keep: HashSet<Uuid> = unembedded.into_iter().collect();
staged.retain(|(id, _)| keep.contains(id));
}
Err(e) => {
tracing::error!(error = %e, "filter_unembedded failed; skipping entity batch to honour --keep-existing");
errors_skipped += staged.len() as u64;
staged.clear();
}
}
}
}
if !staged.is_empty() {
let texts: Vec<String> = staged.iter().map(|(_, t)| truncate_text(t)).collect();
match rt.embed_batch_with_model(&model_name, &texts).await {
Ok(embeddings) if embeddings.len() == staged.len() => {
match rt.vectors_for_model(&token, &model_name) {
Ok(vectors) => {
for ((id, _), emb) in staged.iter().zip(embeddings.iter()) {
if drop_existing {
let _ = vectors.delete(*id).await;
}
if let Err(e) = vectors
.insert(
*id,
SubstrateKind::Entity,
&ns_str,
"entity.body",
vec![emb.clone()],
)
.await
{
tracing::warn!(entity_id = %id, error = %e, "entity vector insert failed");
errors_skipped += 1;
}
}
entities_processed += staged.len() as u64;
}
Err(e) => {
tracing::warn!(error = %e, "failed to get vector store for model");
errors_skipped += staged.len() as u64;
}
}
}
Ok(_) => {
tracing::warn!("embedding count mismatch for entity batch");
errors_skipped += staged.len() as u64;
}
Err(e) => {
tracing::warn!(error = %e, "entity embed_batch failed");
errors_skipped += staged.len() as u64;
}
}
}
}
if n < batch_size as usize {
break;
}
entity_offset += n as u32;
}
let mut notes_processed: u64 = 0;
let mut note_offset: u32 = 0;
loop {
let batch = rt
.list_notes(&token, None, batch_size, note_offset)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let n = batch.len();
if n == 0 {
break;
}
let mut staged: Vec<(Uuid, String)> = Vec::with_capacity(n);
for note in &batch {
let text = match ¬e.name {
Some(name) if !name.is_empty() => format!("{name} {}", note.content),
_ => note.content.clone(),
};
if !text.trim().is_empty() {
staged.push((note.id, text));
}
}
if !staged.is_empty() {
if !drop_existing {
if let Ok(vectors) = rt.vectors_for_model(&token, &model_name) {
let all_ids: Vec<Uuid> = staged.iter().map(|(id, _)| *id).collect();
match filter_unembedded(vectors.as_ref(), &all_ids, &ns_str).await {
Ok(unembedded) => {
let keep: HashSet<Uuid> = unembedded.into_iter().collect();
staged.retain(|(id, _)| keep.contains(id));
}
Err(e) => {
tracing::error!(error = %e, "filter_unembedded failed; skipping note batch to honour --keep-existing");
errors_skipped += staged.len() as u64;
staged.clear();
}
}
}
}
if !staged.is_empty() {
let texts: Vec<String> = staged.iter().map(|(_, t)| truncate_text(t)).collect();
match rt.embed_batch_with_model(&model_name, &texts).await {
Ok(embeddings) if embeddings.len() == staged.len() => {
match rt.vectors_for_model(&token, &model_name) {
Ok(vectors) => {
for ((id, _), emb) in staged.iter().zip(embeddings.iter()) {
if drop_existing {
let _ = vectors.delete(*id).await;
}
if let Err(e) = vectors
.insert(
*id,
SubstrateKind::Note,
&ns_str,
"note.content",
vec![emb.clone()],
)
.await
{
tracing::warn!(note_id = %id, error = %e, "note vector insert failed");
errors_skipped += 1;
}
}
notes_processed += staged.len() as u64;
}
Err(e) => {
tracing::warn!(error = %e, "failed to get vector store for model (notes)");
errors_skipped += staged.len() as u64;
}
}
}
Ok(_) => {
tracing::warn!("embedding count mismatch for note batch");
errors_skipped += staged.len() as u64;
}
Err(e) => {
tracing::warn!(error = %e, "note embed_batch failed");
errors_skipped += staged.len() as u64;
}
}
}
}
if n < batch_size as usize {
break;
}
note_offset += n as u32;
}
let elapsed_ms = start.elapsed().as_millis() as u64;
if let Err(e) = invalidate_vamana_snapshots(&rt, &ns_str).await {
tracing::warn!(error = %e, "failed to invalidate Vamana snapshots after reindex");
}
let report = ReindexReport {
entities_processed,
notes_processed,
model_used: Some(model_name),
elapsed_ms,
errors_skipped,
};
print_report(&report, args.human);
Ok(())
}
async fn invalidate_vamana_snapshots(rt: &KhiveRuntime, namespace: &str) -> anyhow::Result<()> {
use khive_storage::types::{SqlStatement, SqlValue};
let pattern = format!("{namespace}::vamana::%");
let sql = rt.sql();
let mut writer = sql
.writer()
.await
.context("open SQL writer for Vamana snapshot invalidation")?;
match writer
.execute(SqlStatement {
sql: "DELETE FROM retrieval_snapshots WHERE namespace LIKE ?1".into(),
params: vec![SqlValue::Text(pattern)],
label: Some("invalidate_vamana_snapshots".into()),
})
.await
{
Ok(deleted) => {
tracing::info!(
deleted,
namespace,
"invalidated Vamana snapshots after reindex"
);
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.contains("no such table") {
tracing::debug!("retrieval_snapshots absent; no Vamana snapshots to invalidate");
Ok(())
} else {
Err(anyhow::anyhow!("{e}"))
}
}
}
}
fn truncate_text(t: &str) -> String {
if t.len() <= MAX_EMBED_BYTES {
t.to_string()
} else {
let mut end = MAX_EMBED_BYTES;
while !t.is_char_boundary(end) {
end -= 1;
}
t[..end].to_string()
}
}
fn print_report(report: &ReindexReport, human: bool) {
if human {
println!(
"Reindex complete: {} entities, {} notes ({} errors skipped) in {}ms",
report.entities_processed,
report.notes_processed,
report.errors_skipped,
report.elapsed_ms
);
if let Some(ref model) = report.model_used {
println!("Model: {model}");
}
} else {
let json = serde_json::to_string(report).expect("serialize ReindexReport");
println!("{json}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use khive_storage::types::{SqlStatement, SqlValue};
#[tokio::test]
async fn test_reindex_invalidates_vamana_snapshots() {
let rt = KhiveRuntime::memory().expect("in-memory runtime");
let sql = rt.sql();
let mut w = sql.writer().await.expect("writer");
w.execute_script(
"CREATE TABLE IF NOT EXISTS retrieval_snapshots (\
namespace TEXT NOT NULL, \
index_type TEXT NOT NULL, \
snapshot BLOB NOT NULL, \
created_at INTEGER NOT NULL, \
PRIMARY KEY (namespace, index_type));"
.into(),
)
.await
.expect("create table");
for (ns, idx_type) in &[
("local::vamana::model-a", "vamana"),
("local::vamana::model-b", "vamana"),
("other::vamana::model-a", "vamana"),
("local::hnsw::model-a", "hnsw"),
] {
w.execute(SqlStatement {
sql: "INSERT INTO retrieval_snapshots \
(namespace, index_type, snapshot, created_at) \
VALUES (?1, ?2, ?3, 0)"
.into(),
params: vec![
SqlValue::Text(ns.to_string()),
SqlValue::Text(idx_type.to_string()),
SqlValue::Blob(b"{}".to_vec()),
],
label: None,
})
.await
.expect("insert row");
}
drop(w);
invalidate_vamana_snapshots(&rt, "local")
.await
.expect("invalidate");
let mut r = sql.reader().await.expect("reader");
let rows = r
.query_all(SqlStatement {
sql: "SELECT namespace FROM retrieval_snapshots ORDER BY namespace".into(),
params: vec![],
label: None,
})
.await
.expect("query");
let remaining: Vec<String> = rows
.iter()
.filter_map(|row| match row.get("namespace") {
Some(SqlValue::Text(s)) => Some(s.clone()),
_ => None,
})
.collect();
assert!(
remaining.contains(&"other::vamana::model-a".to_string()),
"other namespace must survive: {remaining:?}"
);
assert!(
remaining.contains(&"local::hnsw::model-a".to_string()),
"HNSW rows must survive: {remaining:?}"
);
assert!(
!remaining.contains(&"local::vamana::model-a".to_string()),
"local vamana model-a must be deleted: {remaining:?}"
);
assert!(
!remaining.contains(&"local::vamana::model-b".to_string()),
"local vamana model-b must be deleted: {remaining:?}"
);
}
}