Files
phokus/src-tauri/src/vector.rs
T
LyAhn e3fde46e91 feat: add album scope for similar image search
Similar search scoping:
- Add current_album as a similar-scope option and remember the source album when similar or region searches are launched from an album.
- Route gallery and lightbox similar actions through scope-aware store helpers so Album/Folder/All choices are applied consistently.
- Keep pagination and scope toggles working for both whole-image and region-search result sets.

Backend filtering:
- Extend find_similar_images and find_similar_by_region params with album_id, giving album scope precedence over folder scope.
- Add album_membership filtering for HNSW whole-image search and brute-force crop embedding search.
2026-06-28 01:21:31 +01:00

527 lines
16 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)`.
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
}