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, })) }