Skip to main content

videre_core/
embeddings_db.rs

1//! Per-model embedding databases: one SQLite file per (library, model) pair,
2//! attached to the main connection as `emb`.
3//!
4//! Embeddings used to live in an `embeddings` table inside the main library
5//! database, tagged with a `model_id` column. That allowed exactly one model
6//! to be usable at a time (every read filters on `model_id`, so switching
7//! models made the whole library look unembedded) and left the main database
8//! roughly three-quarters vectors. See
9//! docs/superpowers/specs/2026-08-05-multi-model-embeddings-split-design.md.
10
11use anyhow::{Context, Result};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14
15/// Schema alias the model database is attached under.
16pub const ATTACH_ALIAS: &str = "emb";
17
18/// Filename extension for a model database.
19const DB_EXT: &str = "db";
20
21/// `google/siglip2-base-patch16-384` -> `google--siglip2-base-patch16-384`.
22///
23/// Mirrors the Hugging Face cache convention already on disk at
24/// `~/.cache/huggingface/hub/models--google--siglip2-base-patch16-384`, so the
25/// two directories read the same way.
26pub fn model_slug(model_id: &str) -> String {
27    model_id.replace('/', "--")
28}
29
30/// Inverse of `model_slug`. Only the first separator is restored: HF ids are
31/// `owner/name` with exactly one `/`, and a name may legitimately contain `--`.
32pub fn model_from_slug(slug: &str) -> String {
33    slug.replacen("--", "/", 1)
34}
35
36/// Directory holding every model database for one library:
37/// `<home>/embeddings/<db stem>-<hash16>`.
38///
39/// The hash of the canonical path is load-bearing, not decoration, exactly as
40/// in `pipeline_runs::lock_path_for`: two libraries can both be named
41/// `photos.db` in different directories, and keying on the stem alone would
42/// silently merge their embeddings. Canonicalizing first also collapses a
43/// symlink and a relative path to one directory.
44///
45/// Path only; creating the directory is the caller's job, so readers never
46/// bring videre's home into existence just by looking.
47pub fn library_dir(db_path: &Path) -> Result<PathBuf> {
48    use std::hash::{Hash, Hasher};
49    let canonical = db_path
50        .canonicalize()
51        .with_context(|| format!("canonicalize {}", db_path.display()))?;
52    let mut hasher = std::collections::hash_map::DefaultHasher::new();
53    canonical.hash(&mut hasher);
54    let stem = canonical
55        .file_stem()
56        .map(|s| s.to_string_lossy().to_string())
57        .unwrap_or_else(|| "db".to_string());
58    Ok(crate::home::videre_home()?
59        .join("embeddings")
60        .join(format!("{stem}-{:016x}", hasher.finish())))
61}
62
63/// Full path to one model's database for one library. Path only; creates
64/// nothing.
65pub fn db_path(db_path: &Path, model_id: &str) -> Result<PathBuf> {
66    Ok(library_dir(db_path)?.join(format!("{}.{DB_EXT}", model_slug(model_id))))
67}
68
69/// Page size for model databases, overriding SQLite's 4096 default.
70///
71/// Measured 2026-08-05 over 20,000 synthetic rows, extrapolated to 70,587:
72///
73/// | page_size | 1152-dim | 768-dim |
74/// |-----------|----------|---------|
75/// | 4096      | 282 MB   | 143 MB  |
76/// | 8192      | 189 MB   | 143 MB  |
77/// | 16384     | 189 MB   | 128 MB  |
78/// | 32768     | 175 MB   | 122 MB  |
79///
80/// 8192 recovers a third of a 1152-dimension model's footprint but does
81/// nothing at all for 768-dimension models, which is where future data goes.
82/// 16384 is the first size that improves both. 32768 buys a further 5% at the
83/// cost of reading 32KB to touch one vector.
84pub const PAGE_SIZE: i64 = 16384;
85
86/// Initialise a new model database at `path`: page size, WAL, schema.
87///
88/// Done on a standalone connection, before any ATTACH, because `page_size`
89/// only takes effect on an empty database and must be set before
90/// `journal_mode = WAL` and before any table exists. Setting it later is
91/// silently ignored and needs a full VACUUM to apply.
92fn init_model_db(path: &Path) -> Result<()> {
93    if let Some(parent) = path.parent() {
94        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
95    }
96    let conn = Connection::open(path).with_context(|| format!("create {}", path.display()))?;
97    conn.pragma_update(None, "page_size", PAGE_SIZE)
98        .context("set page_size")?;
99    conn.pragma_update(None, "journal_mode", "WAL")
100        .context("set journal_mode")?;
101    conn.execute_batch(
102        "CREATE TABLE IF NOT EXISTS embeddings (
103            hash        TEXT PRIMARY KEY NOT NULL,
104            model_id    TEXT NOT NULL,
105            embedding   BLOB NOT NULL,
106            embedded_at TEXT NOT NULL
107        );",
108    )
109    .with_context(|| format!("create embeddings table in {}", path.display()))?;
110    Ok(())
111}
112
113/// ATTACH the model database for `(db_path, model_id)` as `emb`.
114///
115/// With `create`, a missing file is initialised first; this is reached only
116/// from `videre embed`. Without, a missing file is an error naming the models
117/// that do exist, so the user is never left guessing why search is empty.
118pub fn attach(conn: &Connection, db_path: &Path, model_id: &str, create: bool) -> Result<()> {
119    let path = self::db_path(db_path, model_id)?;
120    if !path.exists() {
121        if !create {
122            let available = list_models(db_path).unwrap_or_default();
123            let available = if available.is_empty() {
124                "(none)".to_string()
125            } else {
126                available.join(", ")
127            };
128            anyhow::bail!(
129                "no embeddings for {model_id} in this library\n  \
130                 expected: {}\n  available: {available}\n  \
131                 run: videre embed --model {model_id}",
132                path.display()
133            );
134        }
135        init_model_db(&path)?;
136    }
137    conn.execute(
138        &format!("ATTACH DATABASE ?1 AS {ATTACH_ALIAS}"),
139        [path.to_string_lossy().as_ref()],
140    )
141    .with_context(|| format!("attach {}", path.display()))?;
142    Ok(())
143}
144
145/// Model ids with an existing database for this library, sorted.
146///
147/// A missing directory is an empty list, not an error: a library that has
148/// never been embedded is a normal state, not a fault.
149pub fn list_models(db_path: &Path) -> Result<Vec<String>> {
150    let dir = library_dir(db_path)?;
151    let entries = match std::fs::read_dir(&dir) {
152        Ok(e) => e,
153        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
154        Err(e) => return Err(e).with_context(|| format!("read {}", dir.display())),
155    };
156    let mut models: Vec<String> = entries
157        .filter_map(|e| e.ok())
158        .map(|e| e.path())
159        .filter(|p| p.extension().is_some_and(|x| x == DB_EXT))
160        .filter_map(|p| p.file_stem().map(|s| model_from_slug(&s.to_string_lossy())))
161        .collect();
162    models.sort();
163    Ok(models)
164}
165
166/// One model's embedding inventory, for `videre stats`.
167#[derive(Debug, Clone, PartialEq, serde::Serialize)]
168pub struct ModelEmbeddingCount {
169    pub model_id: String,
170    pub count: i64,
171    /// Vector dimensions, derived from stored blob length rather than a
172    /// hardcoded per-model table, so an unfamiliar model still reports
173    /// honestly. 0 when the database holds no rows yet.
174    pub dims: i64,
175    pub size_bytes: i64,
176}
177
178/// Row count, dimensions, and file size for every model in this library.
179pub fn counts_by_model(db_path: &Path) -> Result<Vec<ModelEmbeddingCount>> {
180    let mut out = Vec::new();
181    for model_id in list_models(db_path)? {
182        let path = self::db_path(db_path, &model_id)?;
183        let size_bytes = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
184        let conn = Connection::open(&path).with_context(|| format!("open {}", path.display()))?;
185        let count: i64 = conn
186            .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
187            .unwrap_or(0);
188        let dims: i64 = conn
189            .query_row(
190                "SELECT LENGTH(embedding) / 2 FROM embeddings LIMIT 1",
191                [],
192                |r| r.get(0),
193            )
194            .unwrap_or(0);
195        out.push(ModelEmbeddingCount {
196            model_id,
197            count,
198            dims,
199            size_bytes,
200        });
201    }
202    Ok(out)
203}
204
205/// Attach for a reader, tolerating a missing model database when the main
206/// database still holds pre-split embeddings.
207///
208/// Readers must not create a model database, but they also must not hard-fail
209/// on an unmigrated 0.9.x library: those vectors live in `main.embeddings`,
210/// and `embeddings::load_embeddings` reads them when nothing is attached. A
211/// plain `attach(create: false)` here would abort before that fallback ever
212/// ran, making it dead code for precisely the case it exists to handle, and
213/// turning the 0.10 upgrade into the silent break it was written to prevent.
214///
215/// Errors only when there is genuinely nothing to read, in which case the
216/// message still names the models that do exist.
217pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
218    match attach(conn, db_path, model_id, false) {
219        Ok(()) => Ok(()),
220        Err(e) => {
221            let legacy = crate::embeddings::legacy_main_db_embedding_count(conn).unwrap_or(0);
222            if legacy > 0 {
223                Ok(())
224            } else {
225                Err(e)
226            }
227        }
228    }
229}
230
231/// DETACH the model database. Needed before attaching a different model on
232/// the same connection, since the alias may bind only one file at a time.
233pub fn detach(conn: &Connection) -> Result<()> {
234    conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
235        .context("detach embeddings database")?;
236    Ok(())
237}
238
239/// The one `VIDERE_HOME` for this test binary, set exactly once.
240///
241/// Shared by every module's tests rather than each setting its own. Tests
242/// share a process and run in parallel, so a per-test `set_var` races every
243/// concurrent `getenv`, and deleting that directory afterwards pulls the home
244/// out from under unrelated tests mid-run. Doing exactly that made two
245/// `pipeline_runs` lock tests fail. Isolation comes from per-test
246/// subdirectories instead, which suffices because `library_dir` keys on the
247/// database's canonical path.
248#[cfg(test)]
249pub(crate) fn test_home() -> &'static Path {
250    static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
251    HOME.get_or_init(|| {
252        let dir = std::env::temp_dir().join(format!("videre-embdb-home-{}", std::process::id()));
253        std::fs::create_dir_all(&dir).expect("create isolated test home");
254        std::env::set_var("VIDERE_HOME", &dir);
255        dir
256    })
257}
258
259/// Test-only: a fresh library directory plus an empty database file at
260/// `<test home>/<tag>/<tag>.db`, ready to hand to `attach`.
261#[cfg(test)]
262pub(crate) fn test_library(tag: &str) -> PathBuf {
263    let dir = test_home().join(tag);
264    let _ = std::fs::remove_dir_all(&dir);
265    std::fs::create_dir_all(&dir).unwrap();
266    let lib = dir.join(format!("{tag}.db"));
267    std::fs::write(&lib, b"").unwrap();
268    lib
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// Gives one test its own directory under the shared per-binary
276    /// `VIDERE_HOME`. See `test_home` for why the home is not set per test.
277    fn with_home<T>(tag: &str, f: impl FnOnce(&Path) -> T) -> T {
278        let dir = test_home().join(tag);
279        let _ = std::fs::remove_dir_all(&dir);
280        std::fs::create_dir_all(&dir).unwrap();
281        f(&dir)
282    }
283
284    fn touch_db(dir: &Path, name: &str) -> PathBuf {
285        let p = dir.join(name);
286        std::fs::write(&p, b"").unwrap();
287        p
288    }
289
290    #[test]
291    fn model_slug_replaces_the_owner_separator() {
292        assert_eq!(
293            model_slug("google/siglip2-base-patch16-384"),
294            "google--siglip2-base-patch16-384"
295        );
296    }
297
298    #[test]
299    fn model_slug_round_trips_through_model_from_slug() {
300        for id in [
301            "google/siglip2-base-patch16-384",
302            "google/siglip-so400m-patch14-384",
303            "google/siglip-base-patch16-224",
304        ] {
305            assert_eq!(model_from_slug(&model_slug(id)), id);
306        }
307    }
308
309    #[test]
310    fn model_slug_contains_no_path_separator() {
311        // The slug becomes a filename; a surviving '/' would silently create
312        // a nested directory instead of the intended file.
313        assert!(!model_slug("google/siglip2-base-patch16-384").contains('/'));
314    }
315
316    #[test]
317    fn two_libraries_sharing_a_stem_get_different_directories() {
318        with_home("stem", |home| {
319            let a_dir = home.join("a");
320            let b_dir = home.join("b");
321            std::fs::create_dir_all(&a_dir).unwrap();
322            std::fs::create_dir_all(&b_dir).unwrap();
323            let a = touch_db(&a_dir, "photos.db");
324            let b = touch_db(&b_dir, "photos.db");
325
326            let da = library_dir(&a).unwrap();
327            let db_ = library_dir(&b).unwrap();
328            assert_ne!(da, db_, "same stem in different dirs must not collide");
329            assert!(da
330                .file_name()
331                .unwrap()
332                .to_string_lossy()
333                .starts_with("photos-"));
334        });
335    }
336
337    #[test]
338    fn relative_and_absolute_paths_resolve_to_one_directory() {
339        with_home("canon", |home| {
340            let abs = touch_db(home, "hashes.db");
341            let canonical_home = home.canonicalize().unwrap();
342            let rel = canonical_home.join(".").join("hashes.db");
343            assert_eq!(library_dir(&abs).unwrap(), library_dir(&rel).unwrap());
344        });
345    }
346
347    #[test]
348    fn db_path_joins_library_dir_and_model_slug() {
349        with_home("dbpath", |home| {
350            let lib = touch_db(home, "hashes.db");
351            let p = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
352            assert_eq!(p.file_name().unwrap(), "google--siglip2-base-patch16-384.db");
353            assert_eq!(p.parent().unwrap(), library_dir(&lib).unwrap());
354        });
355    }
356
357    #[test]
358    fn attach_with_create_makes_a_database_with_the_chosen_page_size() {
359        with_home("create", |home| {
360            let lib = touch_db(home, "hashes.db");
361            let conn = Connection::open_in_memory().unwrap();
362            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
363
364            // Read the pragma back rather than assuming the write took: a
365            // page_size set after the file has content is silently ignored.
366            let ps: i64 = conn
367                .query_row("PRAGMA emb.page_size", [], |r| r.get(0))
368                .unwrap();
369            assert_eq!(ps, PAGE_SIZE);
370        });
371    }
372
373    #[test]
374    fn attach_with_create_is_idempotent_and_preserves_rows() {
375        with_home("idem", |home| {
376            let lib = touch_db(home, "hashes.db");
377            let model = "google/siglip2-base-patch16-384";
378
379            let c1 = Connection::open_in_memory().unwrap();
380            attach(&c1, &lib, model, true).unwrap();
381            c1.execute(
382                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
383                 VALUES ('h1', ?1, X'0102', '2026-08-05T00:00:00')",
384                [model],
385            )
386            .unwrap();
387            detach(&c1).unwrap();
388            drop(c1);
389
390            let c2 = Connection::open_in_memory().unwrap();
391            attach(&c2, &lib, model, true).unwrap();
392            let n: i64 = c2
393                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
394                .unwrap();
395            assert_eq!(n, 1, "re-attaching must not clobber existing rows");
396        });
397    }
398
399    #[test]
400    fn attach_without_create_errors_and_names_available_models() {
401        with_home("missing", |home| {
402            let lib = touch_db(home, "hashes.db");
403            let conn = Connection::open_in_memory().unwrap();
404            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
405            detach(&conn).unwrap();
406
407            let err = attach(&conn, &lib, "google/siglip-base-patch16-224", false).unwrap_err();
408            let msg = format!("{err:#}");
409            assert!(
410                msg.contains("no embeddings for google/siglip-base-patch16-224"),
411                "{msg}"
412            );
413            assert!(
414                msg.contains("google/siglip2-base-patch16-384"),
415                "error must list what IS available: {msg}"
416            );
417            assert!(msg.contains("videre embed --model"), "{msg}");
418        });
419    }
420
421    #[test]
422    fn two_models_do_not_see_each_others_rows() {
423        with_home("isolate", |home| {
424            let lib = touch_db(home, "hashes.db");
425            let a = "google/siglip2-base-patch16-384";
426            let b = "google/siglip-base-patch16-224";
427
428            let conn = Connection::open_in_memory().unwrap();
429            attach(&conn, &lib, a, true).unwrap();
430            conn.execute(
431                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
432                 VALUES ('h1', ?1, X'0102', 'now')",
433                [a],
434            )
435            .unwrap();
436            detach(&conn).unwrap();
437
438            attach(&conn, &lib, b, true).unwrap();
439            let n: i64 = conn
440                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
441                .unwrap();
442            assert_eq!(n, 0, "model b must not see model a's rows");
443        });
444    }
445
446    #[test]
447    fn attached_table_is_visible_through_emb_sqlite_master() {
448        // Regression guard. `sqlite_master` is per-database: the unqualified
449        // form returns 0 once the table is attached, and every caller treats
450        // 0 as "not embedded yet" rather than as an error, so the failure is
451        // silent. This test fails against the unqualified query.
452        with_home("master", |home| {
453            let lib = touch_db(home, "hashes.db");
454            let conn = Connection::open_in_memory().unwrap();
455            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
456
457            let found: i64 = conn
458                .query_row(
459                    "SELECT COUNT(*) FROM emb.sqlite_master
460                     WHERE type='table' AND name='embeddings'",
461                    [],
462                    |r| r.get(0),
463                )
464                .unwrap();
465            assert_eq!(found, 1);
466
467            let unqualified: i64 = conn
468                .query_row(
469                    "SELECT COUNT(*) FROM sqlite_master
470                     WHERE type='table' AND name='embeddings'",
471                    [],
472                    |r| r.get(0),
473                )
474                .unwrap();
475            assert_eq!(unqualified, 0, "documents exactly why emb. is required");
476        });
477    }
478
479    #[test]
480    fn attach_for_read_tolerates_a_missing_model_db_when_legacy_rows_exist() {
481        // Regression guard for a bug that made the whole legacy fallback dead
482        // code: readers attached with create:false, which failed before
483        // load_embeddings ever ran, so a 0.9.x library got a hard error
484        // instead of its own vectors. Only an end-to-end run caught it,
485        // because every unit test attached explicitly first.
486        with_home("readlegacy", |home| {
487            let lib = home.join("hashes.db");
488            std::fs::write(&lib, b"").unwrap();
489            let conn = Connection::open_in_memory().unwrap();
490            conn.execute_batch(
491                "CREATE TABLE embeddings (
492                    hash TEXT PRIMARY KEY, model_id TEXT NOT NULL,
493                    embedding BLOB NOT NULL, embedded_at TEXT NOT NULL
494                );
495                INSERT INTO embeddings VALUES ('h1', 'm', X'0102', 'now');",
496            )
497            .unwrap();
498
499            attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384")
500                .expect("legacy rows must keep an unmigrated library working");
501        });
502    }
503
504    #[test]
505    fn attach_for_read_still_errors_when_there_is_nothing_at_all_to_read() {
506        with_home("readnothing", |home| {
507            let lib = home.join("hashes.db");
508            std::fs::write(&lib, b"").unwrap();
509            let conn = Connection::open_in_memory().unwrap();
510
511            let err = attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384").unwrap_err();
512            assert!(format!("{err:#}").contains("no embeddings for"), "{err:#}");
513        });
514    }
515
516    #[test]
517    fn detach_allows_attaching_a_different_model_on_the_same_connection() {
518        with_home("reattach", |home| {
519            let lib = touch_db(home, "hashes.db");
520            let conn = Connection::open_in_memory().unwrap();
521            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
522            detach(&conn).unwrap();
523            attach(&conn, &lib, "google/siglip-base-patch16-224", true).unwrap();
524            detach(&conn).unwrap();
525        });
526    }
527
528    #[test]
529    fn list_models_returns_sorted_ids_and_ignores_unrelated_files() {
530        with_home("list", |home| {
531            let lib = touch_db(home, "hashes.db");
532            let conn = Connection::open_in_memory().unwrap();
533            for m in [
534                "google/siglip2-base-patch16-384",
535                "google/siglip-base-patch16-224",
536            ] {
537                attach(&conn, &lib, m, true).unwrap();
538                detach(&conn).unwrap();
539            }
540            // WAL sidecars and stray files must not be mistaken for models.
541            let dir = library_dir(&lib).unwrap();
542            std::fs::write(dir.join("notes.txt"), b"x").unwrap();
543
544            let models = list_models(&lib).unwrap();
545            assert_eq!(
546                models,
547                vec![
548                    "google/siglip-base-patch16-224".to_string(),
549                    "google/siglip2-base-patch16-384".to_string(),
550                ]
551            );
552        });
553    }
554
555    #[test]
556    fn list_models_on_a_library_with_no_embeddings_is_empty_not_an_error() {
557        with_home("listempty", |home| {
558            let lib = touch_db(home, "hashes.db");
559            assert!(list_models(&lib).unwrap().is_empty());
560        });
561    }
562
563    #[test]
564    fn counts_by_model_reports_rows_dims_and_size() {
565        with_home("counts", |home| {
566            let lib = touch_db(home, "hashes.db");
567            let model = "google/siglip2-base-patch16-384";
568            let conn = Connection::open_in_memory().unwrap();
569            attach(&conn, &lib, model, true).unwrap();
570            // 768 dims f16 = 1536 bytes
571            conn.execute(
572                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
573                 VALUES ('h1', ?1, zeroblob(1536), 'now')",
574                [model],
575            )
576            .unwrap();
577            detach(&conn).unwrap();
578
579            let counts = counts_by_model(&lib).unwrap();
580            assert_eq!(counts.len(), 1);
581            assert_eq!(counts[0].model_id, model);
582            assert_eq!(counts[0].count, 1);
583            assert_eq!(
584                counts[0].dims, 768,
585                "dims derive from blob length, not a table"
586            );
587            assert!(counts[0].size_bytes > 0);
588        });
589    }
590
591    #[test]
592    fn counts_by_model_reports_zero_dims_for_an_empty_model_database() {
593        with_home("countsempty", |home| {
594            let lib = touch_db(home, "hashes.db");
595            let conn = Connection::open_in_memory().unwrap();
596            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
597            detach(&conn).unwrap();
598
599            let counts = counts_by_model(&lib).unwrap();
600            assert_eq!(counts.len(), 1);
601            assert_eq!(counts[0].count, 0);
602            assert_eq!(counts[0].dims, 0);
603        });
604    }
605
606    #[test]
607    fn path_computation_creates_nothing() {
608        // Readers must be able to ask "which models exist" without bringing
609        // videre's home into existence, same rule locks_dir follows.
610        with_home("nocreate", |home| {
611            let lib = touch_db(home, "hashes.db");
612            let dir = library_dir(&lib).unwrap();
613            let _ = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
614            assert!(!dir.exists(), "path computation must not create {dir:?}");
615        });
616    }
617}