fix(rag): treat a zero min_score as no floor on Qdrant searches

parse_search_hits filtered on score > min_score, and the only caller passes
0.0. Qdrant Euclid collections score by negative distance, so every hit was
dropped and an attached Euclid collection returned nothing at all, silently.

This is the same trap the surrounding code already documents: score_threshold
is deliberately not sent because it is metric-aware and a 0.0 floor filters
everything out on Euclid. The local filter then reproduced it exactly. Only a
positive floor is now treated as a floor.
This commit is contained in:
2026-08-11 21:07:20 -06:00
parent 74bc613d94
commit de91ffa517
+40 -2
View File
@@ -104,7 +104,10 @@ fn parse_search_hits(
let score = pt["score"].as_f64()? as f32; let score = pt["score"].as_f64()? as f32;
Some((interner.document_id(&pt["id"])?, score)) Some((interner.document_id(&pt["id"])?, score))
}) })
.filter(|(_, score)| *score > min_score) // Only a positive floor is a floor. Euclid collections score by negative
// distance, so a 0.0 floor would drop every hit — the exact failure the
// caller avoids `score_threshold` to prevent.
.filter(|(_, score)| min_score <= 0.0 || *score > min_score)
.collect()) .collect())
} }
@@ -353,7 +356,8 @@ impl RagProvider for QdrantProvider {
// `score_threshold` is deliberately NOT sent. It is metric-aware: on Cosine // `score_threshold` is deliberately NOT sent. It is metric-aware: on Cosine
// collections 0.0 means "no floor" as expected, but Euclid collections score // collections 0.0 means "no floor" as expected, but Euclid collections score
// by negative distance, where 0.0 filters everything out. The attach wizard // by negative distance, where 0.0 filters everything out. The attach wizard
// does not pin the distance metric, so filter locally instead. // does not pin the distance metric, so filter locally instead — where a
// 0.0 floor is correctly treated as "no floor" (see `parse_search_hits`).
let body = serde_json::json!({ let body = serde_json::json!({
"vector": embedding, "vector": embedding,
"limit": top_k, "limit": top_k,
@@ -576,6 +580,40 @@ mod tests {
assert!(provider.fetch_content(&[]).await.unwrap().is_empty()); assert!(provider.fetch_content(&[]).await.unwrap().is_empty());
} }
/// Euclid collections score by NEGATIVE distance, so the 0.0 the caller
/// passes must mean "no floor". Filtering on it drops every hit — the exact
/// bug that keeps Qdrant's own `score_threshold` off the wire.
#[test]
fn a_zero_floor_keeps_negative_euclid_scores() {
let mut interner = PointIdInterner::default();
let search = serde_json::json!({
"result": [
{"id": 1, "score": -0.12},
{"id": 2, "score": -8.5},
]
});
let hits = parse_search_hits(&mut interner, &search, 0.0).unwrap();
assert_eq!(hits.len(), 2, "a 0.0 floor must not drop negative scores");
}
#[test]
fn a_positive_floor_still_filters() {
let mut interner = PointIdInterner::default();
let search = serde_json::json!({
"result": [
{"id": 1, "score": 0.9},
{"id": 2, "score": 0.2},
]
});
let hits = parse_search_hits(&mut interner, &search, 0.5).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, DocumentId(1));
}
/// A UUID-keyed collection has to survive the whole `vector_search` → /// A UUID-keyed collection has to survive the whole `vector_search` →
/// `fetch_content` round trip, and the fetch must ask Qdrant for the ORIGINAL /// `fetch_content` round trip, and the fetch must ask Qdrant for the ORIGINAL
/// string id. Parsing ids with `as_u64()` used to drop these hits inside a /// string id. Parsing ids with `as_u64()` used to drop these hits inside a