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
36fn validate_context_storage(ctx: &crate::library::LibraryContext) -> Result<()> {
37    ctx.ensure_root_identity()?;
38    crate::library_locks::reject_dir_redirect(&ctx.paths.embeddings, "the embeddings directory")?;
39    Ok(())
40}
41
42/// Full path to one model database inside the selected library.
43///
44/// This is a path-only lookup. It validates the root and any existing state
45/// redirection, but creates no directory or database.
46pub fn db_path_in(ctx: &crate::library::LibraryContext, model_id: &str) -> Result<PathBuf> {
47    crate::embeddings::validate_model_id(model_id)?;
48    validate_context_storage(ctx)?;
49    let path = ctx
50        .paths
51        .embeddings
52        .join(format!("{}.{DB_EXT}", model_slug(model_id)));
53    crate::library_locks::reject_redirect(&path, "the model database")?;
54    Ok(path)
55}
56
57/// Page size for model databases, overriding SQLite's 4096 default.
58///
59/// Measured 2026-08-05 over 20,000 synthetic rows, extrapolated to 70,587:
60///
61/// | page_size | 1152-dim | 768-dim |
62/// |-----------|----------|---------|
63/// | 4096      | 282 MB   | 143 MB  |
64/// | 8192      | 189 MB   | 143 MB  |
65/// | 16384     | 189 MB   | 128 MB  |
66/// | 32768     | 175 MB   | 122 MB  |
67///
68/// 8192 recovers a third of a 1152-dimension model's footprint but does
69/// nothing at all for 768-dimension models, which is where future data goes.
70/// 16384 is the first size that improves both. 32768 buys a further 5% at the
71/// cost of reading 32KB to touch one vector.
72pub const PAGE_SIZE: i64 = 16384;
73
74/// Initialise a new model database at `path`: page size, WAL, schema.
75///
76/// Done on a standalone connection, before any ATTACH, because `page_size`
77/// only takes effect on an empty database and must be set before
78/// `journal_mode = WAL` and before any table exists. Setting it later is
79/// silently ignored and needs a full VACUUM to apply.
80fn init_model_db(path: &Path) -> Result<()> {
81    if let Some(parent) = path.parent() {
82        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
83    }
84    let conn = Connection::open(path).with_context(|| format!("create {}", path.display()))?;
85    conn.pragma_update(None, "page_size", PAGE_SIZE)
86        .context("set page_size")?;
87    conn.pragma_update(None, "journal_mode", "WAL")
88        .context("set journal_mode")?;
89    conn.execute_batch(
90        "CREATE TABLE IF NOT EXISTS embeddings (
91            hash        TEXT PRIMARY KEY NOT NULL,
92            model_id    TEXT NOT NULL,
93            embedding   BLOB NOT NULL,
94            embedded_at TEXT NOT NULL
95        );",
96    )
97    .with_context(|| format!("create embeddings table in {}", path.display()))?;
98    Ok(())
99}
100
101/// Attach one model database from the selected library as `emb`.
102pub fn attach_in(
103    conn: &Connection,
104    ctx: &crate::library::LibraryContext,
105    model_id: &str,
106    create: bool,
107) -> Result<()> {
108    crate::library_locks::verify_state(ctx)?;
109    let path = db_path_in(ctx, model_id)?;
110    if !path.exists() {
111        if !create {
112            let available = list_models_in(ctx).unwrap_or_default();
113            let available = if available.is_empty() {
114                "(none)".to_string()
115            } else {
116                available.join(", ")
117            };
118            anyhow::bail!(
119                "no embeddings for {model_id} in this library\n  \
120                 expected: {}\n  available: {available}\n  \
121                 run: videre embed --model {model_id}",
122                path.display()
123            );
124        }
125        std::fs::create_dir_all(&ctx.paths.embeddings)
126            .with_context(|| format!("create {}", ctx.paths.embeddings.display()))?;
127        validate_context_storage(ctx)?;
128        init_model_db(&path)?;
129    }
130    crate::library_locks::reject_redirect(&path, "the model database")?;
131    let meta =
132        std::fs::symlink_metadata(&path).with_context(|| format!("inspect {}", path.display()))?;
133    anyhow::ensure!(meta.is_file(), "{} is not a regular file", path.display());
134    conn.execute(
135        &format!("ATTACH DATABASE ?1 AS {ATTACH_ALIAS}"),
136        [path.to_string_lossy().as_ref()],
137    )
138    .with_context(|| format!("attach {}", path.display()))?;
139    if create {
140        conn.execute_batch(
141            "CREATE TABLE IF NOT EXISTS emb.embeddings (
142                hash        TEXT PRIMARY KEY NOT NULL,
143                model_id    TEXT NOT NULL,
144                embedding   BLOB NOT NULL,
145                embedded_at TEXT NOT NULL
146            );",
147        )
148        .with_context(|| format!("ensure schema in {}", path.display()))?;
149    }
150    Ok(())
151}
152
153/// Model ids with a database in the selected library, sorted.
154pub fn list_models_in(ctx: &crate::library::LibraryContext) -> Result<Vec<String>> {
155    validate_context_storage(ctx)?;
156    let entries = match std::fs::read_dir(&ctx.paths.embeddings) {
157        Ok(entries) => entries,
158        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
159        Err(error) => {
160            return Err(error).with_context(|| format!("read {}", ctx.paths.embeddings.display()))
161        }
162    };
163    let mut models = Vec::new();
164    for entry in entries {
165        let entry = entry.with_context(|| format!("read {}", ctx.paths.embeddings.display()))?;
166        let path = entry.path();
167        if path.extension().is_none_or(|extension| extension != DB_EXT) {
168            continue;
169        }
170        crate::library_locks::reject_redirect(&path, "the model database")?;
171        let meta = std::fs::symlink_metadata(&path)
172            .with_context(|| format!("inspect {}", path.display()))?;
173        anyhow::ensure!(meta.is_file(), "{} is not a regular file", path.display());
174        if let Some(stem) = path.file_stem() {
175            models.push(model_from_slug(&stem.to_string_lossy()));
176        }
177    }
178    models.sort();
179    Ok(models)
180}
181
182/// One model's embedding inventory, for `videre stats`.
183#[derive(Debug, Clone, PartialEq, serde::Serialize)]
184pub struct ModelEmbeddingCount {
185    pub model_id: String,
186    pub count: i64,
187    /// Vector dimensions, derived from stored blob length rather than a
188    /// hardcoded per-model table, so an unfamiliar model still reports
189    /// honestly. 0 when the database holds no rows yet.
190    pub dims: i64,
191    pub size_bytes: i64,
192}
193
194/// Row count, dimensions and file size for each model in the selected library.
195pub fn counts_by_model_in(
196    ctx: &crate::library::LibraryContext,
197) -> Result<Vec<ModelEmbeddingCount>> {
198    let mut out = Vec::new();
199    for model_id in list_models_in(ctx)? {
200        let path = db_path_in(ctx, &model_id)?;
201        let size_bytes = std::fs::metadata(&path)
202            .with_context(|| format!("inspect {}", path.display()))?
203            .len() as i64;
204        let conn = Connection::open(&path).with_context(|| format!("open {}", path.display()))?;
205        let count: i64 = conn
206            .query_row("SELECT COUNT(*) FROM embeddings", [], |row| row.get(0))
207            .unwrap_or(0);
208        let dims: i64 = conn
209            .query_row(
210                "SELECT LENGTH(embedding) / 2 FROM embeddings LIMIT 1",
211                [],
212                |row| row.get(0),
213            )
214            .unwrap_or(0);
215        out.push(ModelEmbeddingCount {
216            model_id,
217            count,
218            dims,
219            size_bytes,
220        });
221    }
222    Ok(out)
223}
224
225/// Attach for a reader: a missing model database is an error naming the models
226/// that do exist.
227///
228/// Attach a selected library's existing model database for reading. Kept as a
229/// distinct name from `attach_in(.., false)` so call sites read as intent
230/// rather than as a boolean.
231pub fn attach_for_read_in(
232    conn: &Connection,
233    ctx: &crate::library::LibraryContext,
234    model_id: &str,
235) -> Result<()> {
236    attach_in(conn, ctx, model_id, false)
237}
238
239/// DETACH the model database. Needed before attaching a different model on
240/// the same connection, since the alias may bind only one file at a time.
241pub fn detach(conn: &Connection) -> Result<()> {
242    conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
243        .context("detach embeddings database")?;
244    Ok(())
245}
246
247/// Test-only: a fully initialized [`LibraryContext`](crate::library::LibraryContext)
248/// under a fresh per-tag directory, ready to hand to the `_in` model-store
249/// helpers. No process-global environment is touched: each library's stores
250/// live under its own root, so parallel tests never collide.
251///
252/// **`tag` must be unique across the whole test binary.** This wipes its
253/// directory on entry, so two tests sharing a tag delete each other's store
254/// mid-run and fail intermittently.
255#[cfg(test)]
256pub(crate) fn test_context(tag: &str) -> crate::library::LibraryContext {
257    let base = std::env::temp_dir().join(format!("videre-embdb-{}-{}", tag, std::process::id()));
258    let _ = std::fs::remove_dir_all(&base);
259    let root = base.join("lib");
260    std::fs::create_dir_all(&root).unwrap();
261    let ctx = crate::library::LibraryContext::new(&root, &base.join("cache")).unwrap();
262    // The model-store helpers verify the state directory exists before
263    // attaching, the one thing `library_db::initialize` would otherwise set up.
264    std::fs::create_dir_all(&ctx.paths.state).unwrap();
265    ctx
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn explicit_context(parent: &Path, name: &str) -> crate::library::LibraryContext {
273        let root = parent.join(name);
274        std::fs::create_dir(&root).unwrap();
275        crate::library::LibraryContext::new(&root, &parent.join("cache")).unwrap()
276    }
277
278    #[test]
279    fn explicit_model_stores_are_isolated_and_keep_f16_dimensions() {
280        let temp = tempfile::tempdir().unwrap();
281        let a = explicit_context(temp.path(), "a");
282        let b = explicit_context(temp.path(), "b");
283        let a_conn = crate::library_db::initialize(&a).unwrap();
284        let b_conn = crate::library_db::initialize(&b).unwrap();
285        for model in ["owner/model-a", "owner/model-b"] {
286            attach_in(&a_conn, &a, model, true).unwrap();
287            a_conn
288                .execute(
289                    "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
290                     VALUES ('shared', ?1, zeroblob(1536), 'now')",
291                    [model],
292                )
293                .unwrap();
294            detach(&a_conn).unwrap();
295        }
296        attach_in(&b_conn, &b, "owner/model-a", true).unwrap();
297        b_conn
298            .execute(
299                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
300                 VALUES ('shared', ?1, zeroblob(8), 'now')",
301                ["owner/model-a"],
302            )
303            .unwrap();
304        detach(&b_conn).unwrap();
305
306        assert_eq!(
307            list_models_in(&a).unwrap(),
308            vec!["owner/model-a", "owner/model-b"]
309        );
310        let a_counts = counts_by_model_in(&a).unwrap();
311        assert_eq!(a_counts.len(), 2);
312        assert!(a_counts
313            .iter()
314            .all(|count| count.count == 1 && count.dims == 768));
315        let b_counts = counts_by_model_in(&b).unwrap();
316        assert_eq!(b_counts[0].count, 1);
317        assert_eq!(b_counts[0].dims, 4);
318        assert_ne!(
319            db_path_in(&a, "owner/model-a").unwrap(),
320            db_path_in(&b, "owner/model-a").unwrap()
321        );
322    }
323
324    #[test]
325    fn explicit_read_attachment_does_not_create_a_missing_store() {
326        let temp = tempfile::tempdir().unwrap();
327        let ctx = explicit_context(temp.path(), "library");
328        let conn = crate::library_db::initialize(&ctx).unwrap();
329        let expected = db_path_in(&ctx, "owner/missing").unwrap();
330        let error = attach_for_read_in(&conn, &ctx, "owner/missing").unwrap_err();
331        assert!(format!("{error:#}").contains("no embeddings for owner/missing"));
332        assert!(!expected.exists());
333        assert!(!ctx.paths.embeddings.exists());
334    }
335
336    /// Gives one test its own directory under the shared per-binary
337    /// `VIDERE_HOME`. See `test_home` for why the home is not set per test.
338    #[test]
339    fn model_slug_replaces_the_owner_separator() {
340        assert_eq!(
341            model_slug("google/siglip2-base-patch16-384"),
342            "google--siglip2-base-patch16-384"
343        );
344    }
345
346    #[test]
347    fn model_slug_round_trips_through_model_from_slug() {
348        for id in [
349            "google/siglip2-base-patch16-384",
350            "google/siglip-so400m-patch14-384",
351            "google/siglip-base-patch16-224",
352        ] {
353            assert_eq!(model_from_slug(&model_slug(id)), id);
354        }
355    }
356
357    #[test]
358    fn model_slug_contains_no_path_separator() {
359        // The slug becomes a filename; a surviving '/' would silently create
360        // a nested directory instead of the intended file.
361        assert!(!model_slug("google/siglip2-base-patch16-384").contains('/'));
362    }
363
364    #[test]
365    fn db_path_in_joins_the_embeddings_dir_and_model_slug() {
366        let ctx = test_context("dbpath");
367        let p = db_path_in(&ctx, "google/siglip2-base-patch16-384").unwrap();
368        assert_eq!(
369            p.file_name().unwrap(),
370            "google--siglip2-base-patch16-384.db"
371        );
372        assert_eq!(p.parent().unwrap(), ctx.paths.embeddings);
373    }
374
375    #[test]
376    fn attach_with_create_makes_a_database_with_the_chosen_page_size() {
377        let ctx = test_context("create");
378        let conn = Connection::open_in_memory().unwrap();
379        attach_in(&conn, &ctx, "google/siglip2-base-patch16-384", true).unwrap();
380
381        // Read the pragma back rather than assuming the write took: a
382        // page_size set after the file has content is silently ignored.
383        let ps: i64 = conn
384            .query_row("PRAGMA emb.page_size", [], |r| r.get(0))
385            .unwrap();
386        assert_eq!(ps, PAGE_SIZE);
387    }
388
389    #[test]
390    fn attach_with_create_is_idempotent_and_preserves_rows() {
391        let ctx = test_context("idem");
392        let model = "google/siglip2-base-patch16-384";
393
394        let c1 = Connection::open_in_memory().unwrap();
395        attach_in(&c1, &ctx, model, true).unwrap();
396        c1.execute(
397            "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
398             VALUES ('h1', ?1, X'0102', '2026-08-05T00:00:00')",
399            [model],
400        )
401        .unwrap();
402        detach(&c1).unwrap();
403        drop(c1);
404
405        let c2 = Connection::open_in_memory().unwrap();
406        attach_in(&c2, &ctx, model, true).unwrap();
407        let n: i64 = c2
408            .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
409            .unwrap();
410        assert_eq!(n, 1, "re-attaching must not clobber existing rows");
411    }
412
413    #[test]
414    fn attach_with_create_repairs_a_file_that_exists_without_the_table() {
415        // `path.exists()` alone is not enough: initialisation can be cut short
416        // by a crash or a full disk, and attaching the resulting file leaves it
417        // broken forever because every later call sees the file and skips init.
418        // Surfaced as an intermittent "no such table: emb.embeddings" in tests.
419        let ctx = test_context("repair");
420        let model = "google/siglip2-base-patch16-384";
421        let path = db_path_in(&ctx, model).unwrap();
422        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
423        std::fs::write(&path, b"").unwrap(); // exists, but empty
424
425        let conn = Connection::open_in_memory().unwrap();
426        attach_in(&conn, &ctx, model, true).unwrap();
427        let n: i64 = conn
428            .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
429            .expect("the table must exist after attach_in(create: true)");
430        assert_eq!(n, 0);
431    }
432
433    #[test]
434    fn attach_without_create_errors_and_names_available_models() {
435        let ctx = test_context("missing");
436        let conn = Connection::open_in_memory().unwrap();
437        attach_in(&conn, &ctx, "google/siglip2-base-patch16-384", true).unwrap();
438        detach(&conn).unwrap();
439
440        let err = attach_in(&conn, &ctx, "google/siglip-base-patch16-224", false).unwrap_err();
441        let msg = format!("{err:#}");
442        assert!(
443            msg.contains("no embeddings for google/siglip-base-patch16-224"),
444            "{msg}"
445        );
446        assert!(
447            msg.contains("google/siglip2-base-patch16-384"),
448            "error must list what IS available: {msg}"
449        );
450        assert!(msg.contains("videre embed --model"), "{msg}");
451    }
452
453    #[test]
454    fn two_models_do_not_see_each_others_rows() {
455        let ctx = test_context("isolate");
456        let a = "google/siglip2-base-patch16-384";
457        let b = "google/siglip-base-patch16-224";
458
459        let conn = Connection::open_in_memory().unwrap();
460        attach_in(&conn, &ctx, a, true).unwrap();
461        conn.execute(
462            "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
463             VALUES ('h1', ?1, X'0102', 'now')",
464            [a],
465        )
466        .unwrap();
467        detach(&conn).unwrap();
468
469        attach_in(&conn, &ctx, b, true).unwrap();
470        let n: i64 = conn
471            .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
472            .unwrap();
473        assert_eq!(n, 0, "model b must not see model a's rows");
474    }
475
476    #[test]
477    fn attached_table_is_visible_through_emb_sqlite_master() {
478        // Regression guard. `sqlite_master` is per-database: the unqualified
479        // form returns 0 once the table is attached, and every caller treats
480        // 0 as "not embedded yet" rather than as an error, so the failure is
481        // silent. This test fails against the unqualified query.
482        let ctx = test_context("master");
483        let conn = Connection::open_in_memory().unwrap();
484        attach_in(&conn, &ctx, "google/siglip2-base-patch16-384", true).unwrap();
485
486        let found: i64 = conn
487            .query_row(
488                "SELECT COUNT(*) FROM emb.sqlite_master
489                 WHERE type='table' AND name='embeddings'",
490                [],
491                |r| r.get(0),
492            )
493            .unwrap();
494        assert_eq!(found, 1);
495
496        let unqualified: i64 = conn
497            .query_row(
498                "SELECT COUNT(*) FROM sqlite_master
499                 WHERE type='table' AND name='embeddings'",
500                [],
501                |r| r.get(0),
502            )
503            .unwrap();
504        assert_eq!(unqualified, 0, "documents exactly why emb. is required");
505    }
506
507    #[test]
508    fn detach_allows_attaching_a_different_model_on_the_same_connection() {
509        let ctx = test_context("reattach");
510        let conn = Connection::open_in_memory().unwrap();
511        attach_in(&conn, &ctx, "google/siglip2-base-patch16-384", true).unwrap();
512        detach(&conn).unwrap();
513        attach_in(&conn, &ctx, "google/siglip-base-patch16-224", true).unwrap();
514        detach(&conn).unwrap();
515    }
516
517    #[test]
518    fn list_models_returns_sorted_ids_and_ignores_unrelated_files() {
519        let ctx = test_context("list");
520        let conn = Connection::open_in_memory().unwrap();
521        for m in [
522            "google/siglip2-base-patch16-384",
523            "google/siglip-base-patch16-224",
524        ] {
525            attach_in(&conn, &ctx, m, true).unwrap();
526            detach(&conn).unwrap();
527        }
528        // WAL sidecars and stray files must not be mistaken for models.
529        std::fs::write(ctx.paths.embeddings.join("notes.txt"), b"x").unwrap();
530
531        let models = list_models_in(&ctx).unwrap();
532        assert_eq!(
533            models,
534            vec![
535                "google/siglip-base-patch16-224".to_string(),
536                "google/siglip2-base-patch16-384".to_string(),
537            ]
538        );
539    }
540
541    #[test]
542    fn list_models_on_a_library_with_no_embeddings_is_empty_not_an_error() {
543        let ctx = test_context("listempty");
544        assert!(list_models_in(&ctx).unwrap().is_empty());
545    }
546
547    #[test]
548    fn counts_by_model_reports_rows_dims_and_size() {
549        let ctx = test_context("counts");
550        let model = "google/siglip2-base-patch16-384";
551        let conn = Connection::open_in_memory().unwrap();
552        attach_in(&conn, &ctx, model, true).unwrap();
553        // 768 dims f16 = 1536 bytes
554        conn.execute(
555            "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
556             VALUES ('h1', ?1, zeroblob(1536), 'now')",
557            [model],
558        )
559        .unwrap();
560        detach(&conn).unwrap();
561
562        let counts = counts_by_model_in(&ctx).unwrap();
563        assert_eq!(counts.len(), 1);
564        assert_eq!(counts[0].model_id, model);
565        assert_eq!(counts[0].count, 1);
566        assert_eq!(
567            counts[0].dims, 768,
568            "dims derive from blob length, not a table"
569        );
570        assert!(counts[0].size_bytes > 0);
571    }
572
573    #[test]
574    fn counts_by_model_reports_zero_dims_for_an_empty_model_database() {
575        let ctx = test_context("countsempty");
576        let conn = Connection::open_in_memory().unwrap();
577        attach_in(&conn, &ctx, "google/siglip2-base-patch16-384", true).unwrap();
578        detach(&conn).unwrap();
579
580        let counts = counts_by_model_in(&ctx).unwrap();
581        assert_eq!(counts.len(), 1);
582        assert_eq!(counts[0].count, 0);
583        assert_eq!(counts[0].dims, 0);
584    }
585
586    #[test]
587    fn path_computation_creates_nothing() {
588        // Readers must be able to ask "which model database would this be"
589        // without bringing the store directory into existence.
590        let ctx = test_context("nocreate");
591        let _ = db_path_in(&ctx, "google/siglip2-base-patch16-384").unwrap();
592        assert!(
593            !ctx.paths.embeddings.exists(),
594            "path computation must not create {:?}",
595            ctx.paths.embeddings
596        );
597    }
598}