fix: reject mismatched dense dims in recommend average (#10374)

* fix: reject mismatched dense dims in recommend average

Stop silently truncating oversized negative examples during
average_vector merge. Validate dense dimensions within each example
group and between positive/negative averages before zip-merge.

Fixes #10369

* Simplify: keep only the merge-time dimension check

The zip truncation in merge_positive_and_negative_avg is the only place
an oversized negative can silently pass the downstream dimension check;
within-group mismatches already grow the average to the max length and
fail the segment-entry check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(query): make recommendation conversion explicit

* test: assert recommendation dimension errors

Issue: #10369

Make the regression test verify the exact WrongVectorDimension payload for mismatched recommendation vectors.

---------

Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mikemikimike
2026-09-03 12:45:59 +02:00
committed by timvisee
co-authored by Claude Fable 5 qdrant-cloud-bot generall
parent f8512cbf94
commit 602503a854
2 changed files with 23 additions and 2 deletions
@@ -234,6 +234,12 @@ fn merge_positive_and_negative_avg(
) -> OperationResult<VectorInternal> {
match (positive, negative) {
(VectorInternal::Dense(positive), VectorInternal::Dense(negative)) => {
if positive.len() != negative.len() {
return Err(OperationError::WrongVectorDimension {
expected_dim: positive.len(),
received_dim: negative.len(),
});
}
let vector: DenseVector = positive
.iter()
.zip(negative.iter())
@@ -268,6 +274,7 @@ mod test {
use sparse::common::sparse_vector::SparseVector;
use super::{avg_vector_for_recommendation, avg_vectors};
use crate::common::operation_error::OperationError;
use crate::data_types::vectors::{VectorInternal, VectorRef};
use crate::vector_storage::query::{Query, RecoBestScoreQuery, RecoQuery};
@@ -448,5 +455,19 @@ mod test {
)
.unwrap();
assert_eq!(vector, vec![1.0, 0.0].into());
// Negative average with a different dimension is rejected, not truncated by zip.
let negatives: Vec<VectorInternal> = vec![vec![0.0, 1.0, 2.0].into()];
let result = avg_vector_for_recommendation(
positives.iter().map(VectorRef::from),
negatives.iter().map(VectorRef::from).peekable(),
);
assert!(matches!(
result,
Err(OperationError::WrongVectorDimension {
expected_dim: 2,
received_dim: 3,
})
));
}
}
+2 -2
View File
@@ -427,12 +427,12 @@ impl From<QueryEnum> for grpc::QueryEnum {
},
QueryEnum::RecommendBestScore(named) => grpc::QueryEnum {
query: Some(grpc::query_enum::Query::RecommendBestScore(
named.query.into(),
grpc::RecoQuery::from(named.query),
)),
},
QueryEnum::RecommendSumScores(named) => grpc::QueryEnum {
query: Some(grpc::query_enum::Query::RecommendSumScores(
named.query.into(),
grpc::RecoQuery::from(named.query),
)),
},
QueryEnum::Discover(named) => grpc::QueryEnum {