feature/clip-semantic-search #96
@@ -181,10 +181,7 @@ impl ClipClient {
|
||||
|
||||
/// Encode a natural-language query to an embedding. Used by the
|
||||
/// search route to rank stored image embeddings by cosine sim.
|
||||
pub async fn encode_text(
|
||||
&self,
|
||||
text: &str,
|
||||
) -> std::result::Result<EncodeResponse, ClipError> {
|
||||
pub async fn encode_text(&self, text: &str) -> std::result::Result<EncodeResponse, ClipError> {
|
||||
let Some(base) = self.base_url.as_deref() else {
|
||||
return Err(ClipError::Disabled);
|
||||
};
|
||||
@@ -206,9 +203,10 @@ impl ClipClient {
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let body: EncodeResponse = resp.json().await.map_err(|e| {
|
||||
ClipError::Transient(anyhow::anyhow!("clip response decode: {e}"))
|
||||
})?;
|
||||
let body: EncodeResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
|
||||
return Ok(body);
|
||||
}
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
@@ -246,9 +244,10 @@ impl ClipClient {
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let body: EncodeResponse = resp.json().await.map_err(|e| {
|
||||
ClipError::Transient(anyhow::anyhow!("clip response decode: {e}"))
|
||||
})?;
|
||||
let body: EncodeResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
|
||||
return Ok(body);
|
||||
}
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
@@ -273,10 +273,12 @@ pub fn process_clip_backlog(
|
||||
|
||||
let candidates: Vec<crate::clip_watch::ClipCandidate> = rows
|
||||
.into_iter()
|
||||
.map(|(rel_path, content_hash)| crate::clip_watch::ClipCandidate {
|
||||
rel_path,
|
||||
content_hash,
|
||||
})
|
||||
.map(
|
||||
|(rel_path, content_hash)| crate::clip_watch::ClipCandidate {
|
||||
rel_path,
|
||||
content_hash,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
crate::clip_watch::run_clip_encoding_pass(
|
||||
|
||||
@@ -122,7 +122,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
.into_iter()
|
||||
.find(|l| l.id == args.library)
|
||||
.ok_or_else(|| anyhow::anyhow!("library id {} not found", args.library))?;
|
||||
info!("probing library #{} ({}) at {}", lib.id, lib.name, lib.root_path);
|
||||
info!(
|
||||
"probing library #{} ({}) at {}",
|
||||
lib.id, lib.name, lib.root_path
|
||||
);
|
||||
|
||||
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
|
||||
let ctx = opentelemetry::Context::new();
|
||||
@@ -136,9 +139,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let query_vec = decode_f32_vec(&query_resp.embedding)?;
|
||||
info!(
|
||||
"query encoded ({}d, {}ms): {:?}",
|
||||
query_resp.embedding_dim,
|
||||
query_resp.duration_ms,
|
||||
args.query
|
||||
query_resp.embedding_dim, query_resp.duration_ms, args.query
|
||||
);
|
||||
|
||||
// Page through (id, rel_path), filter to images on disk, encode up
|
||||
@@ -245,7 +246,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
scores.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let elapsed = started.elapsed();
|
||||
println!();
|
||||
println!("── top {} for query: {:?} ──", args.top.min(scores.len()), args.query);
|
||||
println!(
|
||||
"── top {} for query: {:?} ──",
|
||||
args.top.min(scores.len()),
|
||||
args.query
|
||||
);
|
||||
for (i, (sim, path)) in scores.iter().take(args.top).enumerate() {
|
||||
println!("[{:>2}] sim={:.3} {}", i + 1, sim, path);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub async fn search_photos(
|
||||
}));
|
||||
}
|
||||
|
||||
let limit = query.limit.min(200).max(1);
|
||||
let limit = query.limit.clamp(1, 200);
|
||||
let threshold = query.threshold.clamp(-1.0, 1.0);
|
||||
|
||||
// 1. Encode the query text. Fast — Apollo's text encoder is ~50ms
|
||||
@@ -174,7 +174,10 @@ pub async fn search_photos(
|
||||
match dao.list_clip_index(
|
||||
&ctx,
|
||||
&library_ids,
|
||||
query.model_version.as_deref().or(Some(&query_resp.model_version)),
|
||||
query
|
||||
.model_version
|
||||
.as_deref()
|
||||
.or(Some(&query_resp.model_version)),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -257,7 +260,8 @@ pub async fn search_photos(
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"clip_search: find_by_content_hash failed for {}: {:?}",
|
||||
hash, e
|
||||
hash,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -142,10 +142,7 @@ async fn process_one(
|
||||
let emb_bytes = match resp.decode_embedding() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"clip_watch: bad embedding for {}: {:?}",
|
||||
cand.rel_path, e
|
||||
);
|
||||
warn!("clip_watch: bad embedding for {}: {:?}", cand.rel_path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -450,6 +450,7 @@ impl AppState {
|
||||
insight_chat,
|
||||
preview_dao,
|
||||
FaceClient::new(None), // disabled in test
|
||||
ClipClient::new(None), // disabled in test
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user