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.
This commit is contained in:
2026-06-28 01:19:53 +01:00
parent a12e81d8bd
commit e3fde46e91
8 changed files with 162 additions and 45 deletions
+20 -2
View File
@@ -58,6 +58,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams {
pub image_id: i64,
pub folder_id: Option<i64>,
/// When set, restrict results to images in this album (takes precedence
/// over `folder_id`). Used by the "Similar: Album" scope.
pub album_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub threshold: Option<f32>,
@@ -72,6 +75,8 @@ pub struct FindSimilarByRegionParams {
pub crop_w: f32,
pub crop_h: f32,
pub folder_id: Option<i64>,
/// Restrict to an album (takes precedence over `folder_id`).
pub album_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>,
}
@@ -704,6 +709,7 @@ pub async fn find_similar_images(
&conn,
params.image_id,
params.folder_id,
params.album_id,
threshold,
offset,
limit + 1,
@@ -749,8 +755,19 @@ pub async fn find_similar_by_region(
)
.map_err(|e| e.to_string())?;
// Search for similar images using the crop embedding
let image_ids = match params.folder_id {
// Search for similar images using the crop embedding. Album scope takes
// precedence over folder scope.
let image_ids = if let Some(album_id) = params.album_id {
vector::search_image_ids_by_embedding_in_album(
&conn,
&embedding,
album_id,
Some(params.image_id),
offset + limit + 1,
)
.map_err(|e| e.to_string())?
} else {
match params.folder_id {
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
&conn,
&embedding,
@@ -768,6 +785,7 @@ pub async fn find_similar_by_region(
ids.retain(|&id| id != params.image_id);
ids
}
}
};
let has_more = image_ids.len() > offset + limit;
+12 -4
View File
@@ -92,6 +92,7 @@ pub fn find_similar_image_matches(
conn: &Connection,
image_id: i64,
folder_id: Option<i64>,
album_id: Option<i64>,
threshold: f32,
offset: usize,
limit: usize,
@@ -103,10 +104,17 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()),
};
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
// Build the allowed-id set *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock.
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
// concurrent ensure_index call waiting for a write lock. Album scope takes
// precedence over folder scope; both reuse the HNSW filtered search.
let allowed_image_ids: Option<Vec<i64>> = if let Some(album_id) = album_id {
let mut stmt = conn.prepare("SELECT image_id FROM album_images WHERE album_id = ?1")?;
let ids = stmt
.query_map([album_id], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(ids)
} else if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.map(|(id, _)| id)
@@ -122,7 +130,7 @@ pub fn find_similar_image_matches(
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
let neighbours: Vec<Neighbour> = if let Some(image_ids) = allowed_image_ids {
let mut allowed_ids = image_ids
.into_iter()
.filter_map(|allowed_image_id| {
+35
View File
@@ -372,6 +372,41 @@ pub fn search_image_ids_by_embedding_in_folder(
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,