feat(settings): add "Rebuild semantic index" maintenance action

Drops and recreates the sqlite-vec tables at the current model dimension, then
re-queues every image for embedding. Fixes "dimension mismatch" search errors
that occur when the vector table was built for a different model/dimension (e.g.
after experimenting with 768-dim models against this 512-dim build).

The rebuild runs under the embedding worker's DB write lock and resets the job
queue inside a single transaction, so it can't interleave with an in-flight
embedding batch (per code review).
This commit is contained in:
2026-06-21 15:17:34 +01:00
parent 5870205047
commit f66fbe7931
7 changed files with 99 additions and 1 deletions
+8
View File
@@ -1987,6 +1987,14 @@ pub async fn vacuum_database(
})
}
#[tauri::command]
pub async fn rebuild_semantic_index(db: State<'_, DbState>) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?;
// Serialize against the embedding worker (which writes under the same lock)
// so the rebuild can't interleave with an in-flight embedding batch.
indexer::with_db_write_lock(|| db::reset_all_embeddings(&conn)).map_err(|e| e.to_string())
}
#[derive(serde::Serialize)]
pub struct OrphanedThumbnailsInfo {
pub count: u64,
+35
View File
@@ -496,6 +496,41 @@ pub fn repair_embedding_consistency(conn: &Connection) -> Result<(usize, usize)>
Ok((orphaned_vectors, missing_vector_ids.len()))
}
/// Full semantic-index rebuild: recreate the vector tables at the current model
/// dimension and re-queue every image for embedding. Used by the maintenance
/// action when the index is stale or its dimension no longer matches the model
/// (which otherwise surfaces as a "dimension mismatch" search error). Returns the
/// number of images re-queued.
pub fn reset_all_embeddings(conn: &Connection) -> Result<usize> {
// Recreate the vector tables at the current model dimension first (DDL, kept
// out of the transaction below). The caller holds the DB write lock, so the
// embedding worker can't interleave a write between this and the queue reset.
vector::rebuild_tables(conn)?;
let tx = conn.unchecked_transaction()?;
tx.execute(
"UPDATE images
SET embedding_status = 'pending',
embedding_model = NULL,
embedding_updated_at = NULL,
embedding_error = NULL",
[],
)?;
tx.execute("DELETE FROM embedding_jobs", [])?;
let queued = tx.execute(
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now')
FROM images",
[],
)?;
tx.execute(
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
ON CONFLICT(key) DO UPDATE SET value = value + 1",
[],
)?;
tx.commit()?;
Ok(queued)
}
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
// Only re-queue images that are actually embeddable right now.
// Videos without a thumbnail would just fail again immediately, so skip them —
+1 -1
View File
@@ -1288,7 +1288,7 @@ fn max_worker_fetch_size(active_folders: &HashSet<i64>) -> usize {
.unwrap_or(StorageProfile::Balanced.worker_fetch_size())
}
fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
pub fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock.lock().unwrap();
operation()
+1
View File
@@ -192,6 +192,7 @@ pub fn run() {
commands::open_app_data_folder,
commands::get_database_info,
commands::vacuum_database,
commands::rebuild_semantic_index,
commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids,
+12
View File
@@ -36,6 +36,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
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])?;