1use anyhow::{Context, Result};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14
15pub const ATTACH_ALIAS: &str = "emb";
17
18const DB_EXT: &str = "db";
20
21pub fn model_slug(model_id: &str) -> String {
27 model_id.replace('/', "--")
28}
29
30pub 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
42pub 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
57pub const PAGE_SIZE: i64 = 16384;
73
74fn 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
101pub 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
153pub 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#[derive(Debug, Clone, PartialEq, serde::Serialize)]
184pub struct ModelEmbeddingCount {
185 pub model_id: String,
186 pub count: i64,
187 pub dims: i64,
191 pub size_bytes: i64,
192}
193
194pub 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
225pub 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
239pub fn detach(conn: &Connection) -> Result<()> {
242 conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
243 .context("detach embeddings database")?;
244 Ok(())
245}
246
247#[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 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 #[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 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 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 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(); 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 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 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 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 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}