use anyhow::{anyhow, Result}; use rusqlite::{ffi::sqlite3_auto_extension, Connection, Error as SqliteError}; use sqlite_vec::sqlite3_vec_init; use std::sync::Once; pub const CLIP_MODEL_NAME: &str = "openclip-vit-b-32"; pub const CLIP_VECTOR_DIM: usize = 512; static SQLITE_VEC_INIT: Once = Once::new(); pub fn register_sqlite_vec() { SQLITE_VEC_INIT.call_once(|| unsafe { sqlite3_auto_extension(Some(std::mem::transmute::< *const (), unsafe extern "C" fn( *mut rusqlite::ffi::sqlite3, *mut *mut std::os::raw::c_char, *const rusqlite::ffi::sqlite3_api_routines, ) -> i32, >(sqlite3_vec_init as *const ()))); }); } pub fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(&format!( "CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0( image_id INTEGER PRIMARY KEY, embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine ); CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0( image_id INTEGER PRIMARY KEY, embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine );" ))?; Ok(()) } /// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by /// the "Rebuild semantic index" maintenance action when stored vectors no longer /// match the active model's dimension (e.g. after switching embedding models), /// so the columns are rebuilt to the right size before embeddings regenerate. pub fn rebuild_tables(conn: &Connection) -> Result<()> { conn.execute_batch( "DROP TABLE IF EXISTS image_vec; DROP TABLE IF EXISTS caption_vec;", )?; migrate(conn) } #[allow(dead_code)] pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> { conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?; // Advance the revision so any cached HNSW index is invalidated after deletions. conn.execute( "INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1) ON CONFLICT(key) DO UPDATE SET value = value + 1", [], )?; Ok(()) } #[allow(dead_code)] pub fn delete_caption_embedding(conn: &Connection, image_id: i64) -> Result<()> { conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?; Ok(()) } #[allow(dead_code)] pub fn upsert_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?; conn.execute( "INSERT INTO image_vec (image_id, embedding) VALUES (?1, ?2)", (&image_id, &packed), )?; Ok(()) } #[allow(dead_code)] pub fn upsert_caption_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?; conn.execute( "INSERT INTO caption_vec (image_id, embedding) VALUES (?1, ?2)", (&image_id, &packed), )?; Ok(()) } pub fn find_similar_image_ids( conn: &Connection, image_id: i64, limit: usize, folder_id: Option, ) -> Result> { let embedding: Vec = match conn.query_row( "SELECT embedding FROM image_vec WHERE image_id = ?1", [image_id], |row| row.get(0), ) { Ok(embedding) => embedding, Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()), Err(error) => return Err(error.into()), }; if let Some(folder_id) = folder_id { // Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit // and returns exact nearest neighbours within the folder. let mut stmt = conn.prepare( "SELECT v.image_id FROM image_vec v JOIN images i ON i.id = v.image_id WHERE i.folder_id = ?2 AND v.image_id != ?3 ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC LIMIT ?4", )?; let rows = stmt.query_map((&embedding, folder_id, image_id, limit as i64), |row| { row.get::<_, i64>(0) })?; return Ok(rows.collect::>>()?); } // Global KNN search (no folder filter) — use the ANN index. let mut stmt = conn.prepare( "SELECT image_id FROM image_vec WHERE embedding MATCH vec_f32(?1) AND k = ?2", )?; let rows = stmt .query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))? .collect::>>()?; let mut ids = Vec::new(); for row in rows { if row != image_id { ids.push(row); } if ids.len() >= limit { break; } } Ok(ids) } // pub fn find_similar_image_matches( // conn: &Connection, // image_id: i64, // folder_id: Option, // threshold: f32, // offset: usize, // limit: usize, // ) -> Result> { // let embedding: Vec = match conn.query_row( // "SELECT embedding FROM image_vec WHERE image_id = ?1", // [image_id], // |row| row.get(0), // ) { // Ok(embedding) => embedding, // Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()), // Err(error) => return Err(error.into()), // }; // let query = match folder_id { // Some(_) => { // "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance // FROM image_vec v // JOIN images i ON i.id = v.image_id // WHERE i.folder_id = ?2 // AND v.image_id != ?3 // AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4 // ORDER BY distance ASC // LIMIT ?5 OFFSET ?6" // } // None => { // "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance // FROM image_vec v // WHERE v.image_id != ?2 // AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3 // ORDER BY distance ASC // LIMIT ?4 OFFSET ?5" // } // }; // let mut stmt = conn.prepare(query)?; // match folder_id { // Some(folder_id) => Ok(stmt // .query_map( // ( // &embedding, // folder_id, // image_id, // threshold, // limit as i64, // offset as i64, // ), // |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)), // )? // .collect::>>()?), // None => Ok(stmt // .query_map( // (&embedding, image_id, threshold, limit as i64, offset as i64), // |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)), // )? // .collect::>>()?), // } // } pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result>> { let embedding: Result, rusqlite::Error> = conn.query_row( "SELECT embedding FROM image_vec WHERE image_id = ?1", [image_id], |row| row.get(0), ); match embedding { Ok(bytes) => Ok(Some(unpack_f32(&bytes))), Err(SqliteError::QueryReturnedNoRows) => Ok(None), Err(error) => Err(error.into()), } } pub fn get_embedding_revision(conn: &Connection) -> Result { // Use the monotonically incremented app_kv counter so that two embeddings // saved within the same clock second still advance the revision, preventing // the HNSW cache from serving stale vectors. let revision: i64 = conn .query_row( "SELECT COALESCE((SELECT value FROM app_kv WHERE key = 'embedding_revision'), 0)", [], |row| row.get(0), ) .unwrap_or(0); Ok(revision.to_string()) } // fn image_ids_for_folder( // conn: &Connection, // folder_id: i64, // ) -> Result> { // let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?; // let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; // Ok(rows.collect::>>()?) // } /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Each entry is `(image_id, normalized_f32_embedding)`. /// Returns `(count, hash)` over the stored embedding image IDs for the scope in a /// single ordered pass, without loading any embedding blobs. The hash covers the /// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an /// image between folders changes it even when the count happens to stay the same. /// Used (together with the embedding revision, which catches an image being /// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't /// have to read and unpack hundreds of MB of embeddings just to validate freshness. pub fn embedding_ids_signature(conn: &Connection, folder_id: Option) -> Result<(i64, u64)> { use xxhash_rust::xxh3::Xxh3; let mut hasher = Xxh3::new(); let mut count: i64 = 0; let mut hash_row = |id: i64| { hasher.update(&id.to_le_bytes()); count += 1; }; match folder_id { Some(fid) => { let mut stmt = conn.prepare( "SELECT image_id FROM image_vec WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1) ORDER BY image_id", )?; let mut rows = stmt.query([fid])?; while let Some(row) = rows.next()? { hash_row(row.get(0)?); } } None => { let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { hash_row(row.get(0)?); } } } Ok((count, hasher.digest())) } pub fn get_all_image_embeddings_with_ids( conn: &Connection, folder_id: Option, ) -> Result)>> { let packed_rows: Vec<(i64, Vec)> = match folder_id { Some(fid) => { let mut stmt = conn.prepare( "SELECT image_id, embedding FROM image_vec WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)", )?; let rows: Vec<(i64, Vec)> = stmt .query_map([fid], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)) })? .filter_map(|r| r.ok()) .collect(); rows } None => { let mut stmt = conn.prepare("SELECT image_id, embedding FROM image_vec")?; let rows: Vec<(i64, Vec)> = stmt .query_map([], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)) })? .filter_map(|r| r.ok()) .collect(); rows } }; Ok(packed_rows .into_iter() .map(|(id, b)| (id, unpack_f32(&b))) .collect()) } fn unpack_f32(bytes: &[u8]) -> Vec { bytes .chunks_exact(4) .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) .collect() } pub fn search_image_ids_by_embedding( conn: &Connection, embedding: &[f32], limit: usize, ) -> Result> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); let mut stmt = conn.prepare( "SELECT image_id FROM image_vec WHERE embedding MATCH vec_f32(?1) AND k = ?2", )?; let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?; let mut ids = Vec::new(); for row in rows { ids.push(row?); if ids.len() >= limit { break; } } Ok(ids) } /// Brute-force cosine search scoped to a single folder, ordered by ascending distance. /// Used for region-based similarity search where we want folder-scoped results. pub fn search_image_ids_by_embedding_in_folder( conn: &Connection, embedding: &[f32], folder_id: i64, exclude_image_id: Option, limit: usize, ) -> Result> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); let exclude_id = exclude_image_id.unwrap_or(-1); let mut stmt = conn.prepare( "SELECT v.image_id FROM image_vec v JOIN images i ON i.id = v.image_id WHERE i.folder_id = ?2 AND v.image_id != ?3 ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC LIMIT ?4", )?; let rows = stmt.query_map((&packed, folder_id, exclude_id, limit as i64), |row| { row.get::<_, i64>(0) })?; Ok(rows.collect::>>()?) } /// Brute-force cosine search scoped to a single album (membership via /// `album_images`), ordered by ascending distance. Mirrors the folder-scoped /// variant for region-based similarity search. pub fn search_image_ids_by_embedding_in_album( conn: &Connection, embedding: &[f32], album_id: i64, exclude_image_id: Option, limit: usize, ) -> Result> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); let exclude_id = exclude_image_id.unwrap_or(-1); let mut stmt = conn.prepare( "SELECT v.image_id FROM image_vec v JOIN album_images ai ON ai.image_id = v.image_id WHERE ai.album_id = ?2 AND v.image_id != ?3 ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC LIMIT ?4", )?; let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| { row.get::<_, i64>(0) })?; Ok(rows.collect::>>()?) } #[allow(dead_code)] pub fn search_caption_ids_by_embedding( conn: &Connection, embedding: &[f32], limit: usize, ) -> Result> { if embedding.len() != CLIP_VECTOR_DIM { return Err(anyhow!( "expected {}-dimensional embedding, got {}", CLIP_VECTOR_DIM, embedding.len() )); } let packed = pack_f32(embedding); let mut stmt = conn.prepare( "SELECT image_id FROM caption_vec WHERE embedding MATCH vec_f32(?1) AND k = ?2", )?; let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?; let mut ids = Vec::new(); for row in rows { ids.push(row?); if ids.len() >= limit { break; } } Ok(ids) } pub fn count_image_vectors(conn: &Connection) -> Result { conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0)) .map_err(Into::into) } #[allow(dead_code)] pub fn count_caption_vectors(conn: &Connection) -> Result { conn.query_row("SELECT COUNT(*) FROM caption_vec", [], |row| row.get(0)) .map_err(Into::into) } pub fn delete_orphaned_embeddings(conn: &Connection) -> Result { let image_ids = { let mut stmt = conn.prepare("SELECT id FROM images")?; let rows = stmt .query_map([], |row| row.get::<_, i64>(0))? .collect::>>()?; rows }; let vector_ids = { let mut stmt = conn.prepare("SELECT image_id FROM image_vec")?; let rows = stmt .query_map([], |row| row.get::<_, i64>(0))? .collect::>>()?; rows }; let orphaned_ids = vector_ids .into_iter() .filter(|image_id| !image_ids.contains(image_id)) .collect::>(); for image_id in &orphaned_ids { delete_embedding(conn, *image_id)?; } Ok(orphaned_ids.len()) } #[allow(dead_code)] pub fn delete_orphaned_caption_embeddings(conn: &Connection) -> Result { let image_ids = { let mut stmt = conn.prepare("SELECT id FROM images")?; let rows = stmt .query_map([], |row| row.get::<_, i64>(0))? .collect::>>()?; rows }; let vector_ids = { let mut stmt = conn.prepare("SELECT image_id FROM caption_vec")?; let rows = stmt .query_map([], |row| row.get::<_, i64>(0))? .collect::>>()?; rows }; let orphaned_ids = vector_ids .into_iter() .filter(|image_id| !image_ids.contains(image_id)) .collect::>(); for image_id in &orphaned_ids { delete_caption_embedding(conn, *image_id)?; } Ok(orphaned_ids.len()) } pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result { conn.query_row( "SELECT EXISTS(SELECT 1 FROM image_vec WHERE image_id = ?1)", [image_id], |row| row.get::<_, i64>(0), ) .map(|value| value != 0) .map_err(Into::into) } #[allow(dead_code)] fn pack_f32(values: &[f32]) -> Vec { let mut out = Vec::with_capacity(std::mem::size_of_val(values)); for value in values { out.extend_from_slice(&value.to_le_bytes()); } out }