782cf0ea08
A shared test_support module in db.rs provides the fixture: sqlite-vec registered via auto-extension, an in-memory connection with foreign keys on, and both migrations applied - no refactoring of production code needed since every query function already takes &Connection. db.rs coverage: folder idempotency, upsert_image update semantics (favorite/rating preserved, AI tag state invalidated), the get_images filter matrix with pagination and count_images agreement, tag merge/rename/delete, user-tag precedence over AI tags in update_ai_tags, album CRUD with FK cascade, the embedding job queue (backfill, retry, consistency repair), tag search, and delete_folder cascades. vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with dimension validation, and find_similar_image_ids ranking on both the global KNN and folder-scoped brute-force paths.
643 lines
21 KiB
Rust
643 lines
21 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use rusqlite::{ffi::sqlite3_auto_extension, Connection, Error as SqliteError};
|
|
use sqlite_vec::sqlite3_vec_init;
|
|
use std::sync::Once;
|
|
|
|
pub const CLIP_MODEL_NAME: &str = "openclip-vit-b-32";
|
|
pub const CLIP_VECTOR_DIM: usize = 512;
|
|
|
|
static SQLITE_VEC_INIT: Once = Once::new();
|
|
|
|
pub fn register_sqlite_vec() {
|
|
SQLITE_VEC_INIT.call_once(|| unsafe {
|
|
sqlite3_auto_extension(Some(std::mem::transmute::<
|
|
*const (),
|
|
unsafe extern "C" fn(
|
|
*mut rusqlite::ffi::sqlite3,
|
|
*mut *mut std::os::raw::c_char,
|
|
*const rusqlite::ffi::sqlite3_api_routines,
|
|
) -> i32,
|
|
>(sqlite3_vec_init as *const ())));
|
|
});
|
|
}
|
|
|
|
pub fn migrate(conn: &Connection) -> Result<()> {
|
|
conn.execute_batch(&format!(
|
|
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
|
|
image_id INTEGER PRIMARY KEY,
|
|
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
|
|
image_id INTEGER PRIMARY KEY,
|
|
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
|
|
);"
|
|
))?;
|
|
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])?;
|
|
// Advance the revision so any cached HNSW index is invalidated after deletions.
|
|
conn.execute(
|
|
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
|
|
ON CONFLICT(key) DO UPDATE SET value = value + 1",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn delete_caption_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
|
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn upsert_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
|
|
conn.execute(
|
|
"INSERT INTO image_vec (image_id, embedding) VALUES (?1, ?2)",
|
|
(&image_id, &packed),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn upsert_caption_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
|
|
conn.execute(
|
|
"INSERT INTO caption_vec (image_id, embedding) VALUES (?1, ?2)",
|
|
(&image_id, &packed),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn find_similar_image_ids(
|
|
conn: &Connection,
|
|
image_id: i64,
|
|
limit: usize,
|
|
folder_id: Option<i64>,
|
|
) -> Result<Vec<i64>> {
|
|
let embedding: Vec<u8> = match conn.query_row(
|
|
"SELECT embedding FROM image_vec WHERE image_id = ?1",
|
|
[image_id],
|
|
|row| row.get(0),
|
|
) {
|
|
Ok(embedding) => embedding,
|
|
Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
|
|
if let Some(folder_id) = folder_id {
|
|
// Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit
|
|
// and returns exact nearest neighbours within the folder.
|
|
let mut stmt = conn.prepare(
|
|
"SELECT v.image_id
|
|
FROM image_vec v
|
|
JOIN images i ON i.id = v.image_id
|
|
WHERE i.folder_id = ?2
|
|
AND v.image_id != ?3
|
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
|
LIMIT ?4",
|
|
)?;
|
|
let rows = stmt.query_map((&embedding, folder_id, image_id, limit as i64), |row| {
|
|
row.get::<_, i64>(0)
|
|
})?;
|
|
return Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?);
|
|
}
|
|
|
|
// Global KNN search (no folder filter) — use the ANN index.
|
|
let mut stmt = conn.prepare(
|
|
"SELECT image_id
|
|
FROM image_vec
|
|
WHERE embedding MATCH vec_f32(?1)
|
|
AND k = ?2",
|
|
)?;
|
|
let rows = stmt
|
|
.query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
|
|
let mut ids = Vec::new();
|
|
for row in rows {
|
|
if row != image_id {
|
|
ids.push(row);
|
|
}
|
|
if ids.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
// pub fn find_similar_image_matches(
|
|
// conn: &Connection,
|
|
// image_id: i64,
|
|
// folder_id: Option<i64>,
|
|
// threshold: f32,
|
|
// offset: usize,
|
|
// limit: usize,
|
|
// ) -> Result<Vec<(i64, f32)>> {
|
|
// let embedding: Vec<u8> = match conn.query_row(
|
|
// "SELECT embedding FROM image_vec WHERE image_id = ?1",
|
|
// [image_id],
|
|
// |row| row.get(0),
|
|
// ) {
|
|
// Ok(embedding) => embedding,
|
|
// Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
|
|
// Err(error) => return Err(error.into()),
|
|
// };
|
|
|
|
// let query = match folder_id {
|
|
// Some(_) => {
|
|
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
|
|
// FROM image_vec v
|
|
// JOIN images i ON i.id = v.image_id
|
|
// WHERE i.folder_id = ?2
|
|
// AND v.image_id != ?3
|
|
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4
|
|
// ORDER BY distance ASC
|
|
// LIMIT ?5 OFFSET ?6"
|
|
// }
|
|
// None => {
|
|
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
|
|
// FROM image_vec v
|
|
// WHERE v.image_id != ?2
|
|
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3
|
|
// ORDER BY distance ASC
|
|
// LIMIT ?4 OFFSET ?5"
|
|
// }
|
|
// };
|
|
|
|
// let mut stmt = conn.prepare(query)?;
|
|
// match folder_id {
|
|
// Some(folder_id) => Ok(stmt
|
|
// .query_map(
|
|
// (
|
|
// &embedding,
|
|
// folder_id,
|
|
// image_id,
|
|
// threshold,
|
|
// limit as i64,
|
|
// offset as i64,
|
|
// ),
|
|
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
|
|
// )?
|
|
// .collect::<rusqlite::Result<Vec<_>>>()?),
|
|
// None => Ok(stmt
|
|
// .query_map(
|
|
// (&embedding, image_id, threshold, limit as i64, offset as i64),
|
|
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
|
|
// )?
|
|
// .collect::<rusqlite::Result<Vec<_>>>()?),
|
|
// }
|
|
// }
|
|
|
|
pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result<Option<Vec<f32>>> {
|
|
let embedding: Result<Vec<u8>, rusqlite::Error> = conn.query_row(
|
|
"SELECT embedding FROM image_vec WHERE image_id = ?1",
|
|
[image_id],
|
|
|row| row.get(0),
|
|
);
|
|
|
|
match embedding {
|
|
Ok(bytes) => Ok(Some(unpack_f32(&bytes))),
|
|
Err(SqliteError::QueryReturnedNoRows) => Ok(None),
|
|
Err(error) => Err(error.into()),
|
|
}
|
|
}
|
|
|
|
pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
|
|
// Use the monotonically incremented app_kv counter so that two embeddings
|
|
// saved within the same clock second still advance the revision, preventing
|
|
// the HNSW cache from serving stale vectors.
|
|
let revision: i64 = conn
|
|
.query_row(
|
|
"SELECT COALESCE((SELECT value FROM app_kv WHERE key = 'embedding_revision'), 0)",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap_or(0);
|
|
Ok(revision.to_string())
|
|
}
|
|
|
|
// fn image_ids_for_folder(
|
|
// conn: &Connection,
|
|
// folder_id: i64,
|
|
// ) -> Result<std::collections::HashSet<i64>> {
|
|
// let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?;
|
|
// let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
|
|
// Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?)
|
|
// }
|
|
|
|
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
|
|
/// Each entry is `(image_id, normalized_f32_embedding)`.
|
|
/// Returns `(count, hash)` over the stored embedding image IDs for the scope in a
|
|
/// single ordered pass, without loading any embedding blobs. The hash covers the
|
|
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
|
|
/// image between folders changes it even when the count happens to stay the same.
|
|
/// Used (together with the embedding revision, which catches an image being
|
|
/// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't
|
|
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
|
|
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
|
|
use xxhash_rust::xxh3::Xxh3;
|
|
let mut hasher = Xxh3::new();
|
|
let mut count: i64 = 0;
|
|
let mut hash_row = |id: i64| {
|
|
hasher.update(&id.to_le_bytes());
|
|
count += 1;
|
|
};
|
|
match folder_id {
|
|
Some(fid) => {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT image_id FROM image_vec
|
|
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)
|
|
ORDER BY image_id",
|
|
)?;
|
|
let mut rows = stmt.query([fid])?;
|
|
while let Some(row) = rows.next()? {
|
|
hash_row(row.get(0)?);
|
|
}
|
|
}
|
|
None => {
|
|
let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?;
|
|
let mut rows = stmt.query([])?;
|
|
while let Some(row) = rows.next()? {
|
|
hash_row(row.get(0)?);
|
|
}
|
|
}
|
|
}
|
|
Ok((count, hasher.digest()))
|
|
}
|
|
|
|
pub fn get_all_image_embeddings_with_ids(
|
|
conn: &Connection,
|
|
folder_id: Option<i64>,
|
|
) -> Result<Vec<(i64, Vec<f32>)>> {
|
|
let packed_rows: Vec<(i64, Vec<u8>)> = match folder_id {
|
|
Some(fid) => {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT image_id, embedding FROM image_vec
|
|
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
|
|
)?;
|
|
let rows: Vec<(i64, Vec<u8>)> = stmt
|
|
.query_map([fid], |row| {
|
|
Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
|
|
})?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
rows
|
|
}
|
|
None => {
|
|
let mut stmt = conn.prepare("SELECT image_id, embedding FROM image_vec")?;
|
|
let rows: Vec<(i64, Vec<u8>)> = stmt
|
|
.query_map([], |row| {
|
|
Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
|
|
})?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
rows
|
|
}
|
|
};
|
|
|
|
Ok(packed_rows
|
|
.into_iter()
|
|
.map(|(id, b)| (id, unpack_f32(&b)))
|
|
.collect())
|
|
}
|
|
|
|
fn unpack_f32(bytes: &[u8]) -> Vec<f32> {
|
|
bytes
|
|
.chunks_exact(4)
|
|
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
|
.collect()
|
|
}
|
|
|
|
pub fn search_image_ids_by_embedding(
|
|
conn: &Connection,
|
|
embedding: &[f32],
|
|
limit: usize,
|
|
) -> Result<Vec<i64>> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT image_id
|
|
FROM image_vec
|
|
WHERE embedding MATCH vec_f32(?1)
|
|
AND k = ?2",
|
|
)?;
|
|
let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?;
|
|
|
|
let mut ids = Vec::new();
|
|
for row in rows {
|
|
ids.push(row?);
|
|
if ids.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
/// Brute-force cosine search scoped to a single folder, ordered by ascending distance.
|
|
/// Used for region-based similarity search where we want folder-scoped results.
|
|
pub fn search_image_ids_by_embedding_in_folder(
|
|
conn: &Connection,
|
|
embedding: &[f32],
|
|
folder_id: i64,
|
|
exclude_image_id: Option<i64>,
|
|
limit: usize,
|
|
) -> Result<Vec<i64>> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
let exclude_id = exclude_image_id.unwrap_or(-1);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT v.image_id
|
|
FROM image_vec v
|
|
JOIN images i ON i.id = v.image_id
|
|
WHERE i.folder_id = ?2
|
|
AND v.image_id != ?3
|
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
|
LIMIT ?4",
|
|
)?;
|
|
let rows = stmt.query_map((&packed, folder_id, exclude_id, limit as i64), |row| {
|
|
row.get::<_, i64>(0)
|
|
})?;
|
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
|
}
|
|
|
|
/// Brute-force cosine search scoped to a single album (membership via
|
|
/// `album_images`), ordered by ascending distance. Mirrors the folder-scoped
|
|
/// variant for region-based similarity search.
|
|
pub fn search_image_ids_by_embedding_in_album(
|
|
conn: &Connection,
|
|
embedding: &[f32],
|
|
album_id: i64,
|
|
exclude_image_id: Option<i64>,
|
|
limit: usize,
|
|
) -> Result<Vec<i64>> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
let exclude_id = exclude_image_id.unwrap_or(-1);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT v.image_id
|
|
FROM image_vec v
|
|
JOIN album_images ai ON ai.image_id = v.image_id
|
|
WHERE ai.album_id = ?2
|
|
AND v.image_id != ?3
|
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
|
LIMIT ?4",
|
|
)?;
|
|
let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| {
|
|
row.get::<_, i64>(0)
|
|
})?;
|
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn search_caption_ids_by_embedding(
|
|
conn: &Connection,
|
|
embedding: &[f32],
|
|
limit: usize,
|
|
) -> Result<Vec<i64>> {
|
|
if embedding.len() != CLIP_VECTOR_DIM {
|
|
return Err(anyhow!(
|
|
"expected {}-dimensional embedding, got {}",
|
|
CLIP_VECTOR_DIM,
|
|
embedding.len()
|
|
));
|
|
}
|
|
|
|
let packed = pack_f32(embedding);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT image_id
|
|
FROM caption_vec
|
|
WHERE embedding MATCH vec_f32(?1)
|
|
AND k = ?2",
|
|
)?;
|
|
let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?;
|
|
|
|
let mut ids = Vec::new();
|
|
for row in rows {
|
|
ids.push(row?);
|
|
if ids.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
pub fn count_image_vectors(conn: &Connection) -> Result<i64> {
|
|
conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn count_caption_vectors(conn: &Connection) -> Result<i64> {
|
|
conn.query_row("SELECT COUNT(*) FROM caption_vec", [], |row| row.get(0))
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
pub fn delete_orphaned_embeddings(conn: &Connection) -> Result<usize> {
|
|
let image_ids = {
|
|
let mut stmt = conn.prepare("SELECT id FROM images")?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, i64>(0))?
|
|
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
|
|
rows
|
|
};
|
|
let vector_ids = {
|
|
let mut stmt = conn.prepare("SELECT image_id FROM image_vec")?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, i64>(0))?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
rows
|
|
};
|
|
let orphaned_ids = vector_ids
|
|
.into_iter()
|
|
.filter(|image_id| !image_ids.contains(image_id))
|
|
.collect::<Vec<_>>();
|
|
|
|
for image_id in &orphaned_ids {
|
|
delete_embedding(conn, *image_id)?;
|
|
}
|
|
|
|
Ok(orphaned_ids.len())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn delete_orphaned_caption_embeddings(conn: &Connection) -> Result<usize> {
|
|
let image_ids = {
|
|
let mut stmt = conn.prepare("SELECT id FROM images")?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, i64>(0))?
|
|
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
|
|
rows
|
|
};
|
|
let vector_ids = {
|
|
let mut stmt = conn.prepare("SELECT image_id FROM caption_vec")?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, i64>(0))?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
rows
|
|
};
|
|
let orphaned_ids = vector_ids
|
|
.into_iter()
|
|
.filter(|image_id| !image_ids.contains(image_id))
|
|
.collect::<Vec<_>>();
|
|
|
|
for image_id in &orphaned_ids {
|
|
delete_caption_embedding(conn, *image_id)?;
|
|
}
|
|
|
|
Ok(orphaned_ids.len())
|
|
}
|
|
|
|
pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result<bool> {
|
|
conn.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM image_vec WHERE image_id = ?1)",
|
|
[image_id],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.map(|value| value != 0)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn pack_f32(values: &[f32]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(std::mem::size_of_val(values));
|
|
for value in values {
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::test_support::{test_conn, test_image};
|
|
|
|
#[test]
|
|
fn pack_unpack_roundtrip() {
|
|
let values = vec![0.0f32, 1.5, -2.25, f32::MIN_POSITIVE, 1e10];
|
|
assert_eq!(unpack_f32(&pack_f32(&values)), values);
|
|
assert!(unpack_f32(&pack_f32(&[])).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_embedding_rejects_wrong_dimension() {
|
|
let conn = test_conn();
|
|
let error = upsert_embedding(&conn, 1, &[0.5f32; 3]).unwrap_err();
|
|
assert!(error.to_string().contains("dimension"));
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_and_delete_embedding_roundtrip() {
|
|
let conn = test_conn();
|
|
let embedding = vec![0.25f32; CLIP_VECTOR_DIM];
|
|
upsert_embedding(&conn, 42, &embedding).unwrap();
|
|
assert!(has_image_vector(&conn, 42).unwrap());
|
|
|
|
// Upsert replaces rather than duplicates.
|
|
upsert_embedding(&conn, 42, &embedding).unwrap();
|
|
let rows: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM image_vec WHERE image_id = 42",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(rows, 1);
|
|
|
|
delete_embedding(&conn, 42).unwrap();
|
|
assert!(!has_image_vector(&conn, 42).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn find_similar_image_ids_ranks_by_cosine_distance() {
|
|
let conn = test_conn();
|
|
let folder_id = crate::db::insert_folder(&conn, "C:/a", "a").unwrap();
|
|
let base_id =
|
|
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/base.jpg")).unwrap();
|
|
let close_id =
|
|
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/close.jpg")).unwrap();
|
|
let far_id =
|
|
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/far.jpg")).unwrap();
|
|
|
|
let mut base = vec![0.0f32; CLIP_VECTOR_DIM];
|
|
base[0] = 1.0;
|
|
let mut close = vec![0.0f32; CLIP_VECTOR_DIM];
|
|
close[0] = 1.0;
|
|
close[1] = 0.2;
|
|
let mut far = vec![0.0f32; CLIP_VECTOR_DIM];
|
|
far[1] = 1.0;
|
|
upsert_embedding(&conn, base_id, &base).unwrap();
|
|
upsert_embedding(&conn, close_id, &close).unwrap();
|
|
upsert_embedding(&conn, far_id, &far).unwrap();
|
|
|
|
// Global KNN path: nearest first, query image excluded.
|
|
let global = find_similar_image_ids(&conn, base_id, 2, None).unwrap();
|
|
assert_eq!(global, vec![close_id, far_id]);
|
|
|
|
// Folder-scoped brute-force path returns the same ranking.
|
|
let scoped = find_similar_image_ids(&conn, base_id, 2, Some(folder_id)).unwrap();
|
|
assert_eq!(scoped, vec![close_id, far_id]);
|
|
|
|
// Images without an embedding yield no matches instead of an error.
|
|
assert!(find_similar_image_ids(&conn, 9999, 5, None)
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
}
|