From 922f7df8d3a0ece0f99b45b7ba240f03b8f5f666 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 15 May 2026 16:56:10 -0400 Subject: [PATCH] clip-search: offset-based pagination on /photos/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `offset` query param (default 0) and `total_matching` + `offset` response fields. Backend already computes the full sorted list of above-threshold matches per query; pagination just slices it at [offset, offset+limit) instead of always returning the top window. Offsets past the end return an empty page cleanly so the client can stop fetching naturally. Re-scores on every page rather than caching the sorted list — at personal-library scale (~14k embeddings, 768d) the dot-product loop is sub-100ms and the lack of state means no eviction / staleness concerns. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clip_search.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/clip_search.rs b/src/clip_search.rs index d5a2368..5e8d5b9 100644 --- a/src/clip_search.rs +++ b/src/clip_search.rs @@ -33,10 +33,16 @@ use std::sync::Mutex; pub struct SearchQuery { /// Natural-language query. Required; empty triggers 400. pub q: String, - /// Max results to return. Capped to 200 server-side; the UI almost - /// always wants ≤50. Defaults to 20. + /// Max results to return in this page. Capped to 200 server-side. + /// Defaults to 20. Pair with `offset` for pagination. #[serde(default = "default_limit")] pub limit: usize, + /// Zero-based offset into the sorted-and-filtered result set. The + /// scoring loop still runs over the full embedding matrix on every + /// page (cheap at personal-library scale — sub-100ms — and avoids + /// stateful pagination cursors). Defaults to 0. + #[serde(default)] + pub offset: usize, /// Cosine-similarity floor below which results are dropped. /// 0.20 is the rough "this is plausibly relevant" line for OpenAI /// CLIP; tunable per call when sweeping. Defaults to 0.20. @@ -76,7 +82,14 @@ pub struct SearchResponse { pub query: String, pub model_version: String, pub threshold: f32, + /// Total embeddings scored (= every photo in scope with a stored + /// embedding). Same value across pages of the same query. pub considered: usize, + /// Count of results above threshold, before pagination. Lets the + /// client decide whether a "Load more" button is meaningful and + /// stop fetching when ``offset + results.len() >= total_matching``. + pub total_matching: usize, + pub offset: usize, pub results: Vec, } @@ -122,6 +135,7 @@ pub async fn search_photos( } let limit = query.limit.clamp(1, 200); + let offset = query.offset; let threshold = query.threshold.clamp(-1.0, 1.0); // 1. Encode the query text. Fast — Apollo's text encoder is ~50ms @@ -195,6 +209,8 @@ pub async fn search_photos( model_version: query_resp.model_version, threshold, considered, + total_matching: 0, + offset, results: Vec::new(), })); } @@ -216,7 +232,16 @@ pub async fn search_photos( scored.push((sim, hash)); } scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); - scored.truncate(limit); + let total_matching = scored.len(); + // Pagination — slice the sorted list at `[offset, offset+limit)`. + // Offsets past the end produce empty pages rather than an error so + // the client can stop fetching naturally on "load more" past the end. + let scored: Vec<(f32, String)> = if offset >= total_matching { + Vec::new() + } else { + let end = (offset + limit).min(total_matching); + scored[offset..end].to_vec() + }; if scored.is_empty() { return Ok(HttpResponse::Ok().json(SearchResponse { @@ -224,6 +249,8 @@ pub async fn search_photos( model_version: query_resp.model_version, threshold, considered, + total_matching, + offset, results: Vec::new(), })); } @@ -287,6 +314,8 @@ pub async fn search_photos( model_version: query_resp.model_version, threshold, considered, + total_matching, + offset, results, })) }