diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index de4f768..901283f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1987,6 +1987,14 @@ pub async fn vacuum_database( }) } +#[tauri::command] +pub async fn rebuild_semantic_index(db: State<'_, DbState>) -> Result { + 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, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ccb8884..cdcb7bf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 { + // 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 { // Only re-queue images that are actually embeddable right now. // Videos without a thumbnail would just fail again immediately, so skip them — diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 4779ca6..244ffe2 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -1288,7 +1288,7 @@ fn max_worker_fetch_size(active_folders: &HashSet) -> usize { .unwrap_or(StorageProfile::Balanced.worker_fetch_size()) } -fn with_db_write_lock(operation: impl FnOnce() -> Result) -> Result { +pub fn with_db_write_lock(operation: impl FnOnce() -> Result) -> Result { let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(())); let _guard = lock.lock().unwrap(); operation() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79a5379..5f6cade 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index c32d22c..f6b5545 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -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])?; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 8393c99..ba50dca 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -138,6 +138,8 @@ export function SettingsModal() { const [dbInfo, setDbInfo] = useState(null); const [vacuuming, setVacuuming] = useState(false); const [vacuumResult, setVacuumResult] = useState(null); + const [rebuildingIndex, setRebuildingIndex] = useState(false); + const [rebuildIndexResult, setRebuildIndexResult] = useState(null); const [thumbnailInfo, setThumbnailInfo] = useState(null); const [cleaningThumbnails, setCleaningThumbnails] = useState(false); const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null); @@ -184,6 +186,7 @@ export function SettingsModal() { const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); + const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); const appVersion = useGalleryStore((state) => state.appVersion); @@ -799,6 +802,42 @@ export function SettingsModal() { + + + Recreates the visual-embedding index and re-embeds every image in the + background. Use this if semantic or similar-image search reports a + dimension-mismatch error (for example after experimenting with a + different embedding model). + + {rebuildIndexResult !== null ? ( + {rebuildIndexResult} + ) : null} + + } + > + + + Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; + rebuildSemanticIndex: () => Promise; getOrphanedThumbnailsInfo: () => Promise; cleanupOrphanedThumbnails: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; @@ -1858,6 +1859,8 @@ export const useGalleryStore = create((set, get) => ({ vacuumDatabase: () => invoke("vacuum_database"), + rebuildSemanticIndex: () => invoke("rebuild_semantic_index"), + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"),