mirror of
https://github.com/qdrant/qdrant.git
synced 2026-08-05 17:40:55 -05:00
* Add per-query IDF corpus for sparse vector search
Let the caller choose, per query, which population sparse IDF statistics
are computed over. `params.idf` is either `"global"` (default, unchanged
behavior) or `{"corpus": <filter>}`, where the corpus filter is
independent of - and usually broader than - the retrieval filter.
Decoupling the two keeps the score scale stable when the retrieval
filter tightens: term importance is measured against a population the
user names, not against whatever subset the filter happens to select.
Design decisions:
- Corpus grammar is restricted to a conjunction (`must`) of `match`
conditions on payload fields; loosening later is backward compatible.
- Strict mode validates the corpus filter like a read filter
(unindexed fields rejected).
- `idf` on a vector without the IDF modifier is a validation error,
never silently ignored.
- An empty corpus yields degenerate but corpus-scoped scores (smoothed
IDF over N=0), never a fallback to global statistics - in multi-tenant
collections a fallback would leak term statistics across tenants.
Implementation:
- QueryContext IDF stats are keyed by corpus, so one batch can mix
requests with different corpora.
- Statistics come from the sparse index: df(term) is counted over the
query terms' posting lists only, never by scanning stored vectors.
Small corpora (under ~1/32 of the segment, by cardinality estimate)
are kept as a sorted id list galloping through posting lists via
skip_to; large ones as a dense membership mask filled streaming from
the filtered-points iterator. A misestimated small corpus degrades
into the mask.
- Exposed uniformly: REST (`params.idf`), gRPC (`IdfParams` message),
edge python bindings; OpenAPI schema regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Apply rustfmt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix clippy manual_is_multiple_of in sparse IDF corpus test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow any filter as IDF corpus
Drop the must+match grammar restriction on the corpus filter. A
restriction enforced only as a validation step over the full Filter
type buys nothing; if a narrower corpus syntax is ever wanted, it
should be a dedicated API-level type instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix build: add memory field to SparseIndexConfig in idf corpus test
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
10 KiB
Rust
312 lines
10 KiB
Rust
use std::collections::{BTreeMap, HashMap};
|
|
use std::num::NonZeroU32;
|
|
|
|
use api::rest::SearchRequestInternal;
|
|
use collection::collection::Collection;
|
|
use collection::config::{CollectionConfigInternal, CollectionParams, WalConfig};
|
|
use collection::operations::point_ops::{
|
|
PointInsertOperationsInternal, PointOperations, PointStructPersisted, VectorStructPersisted,
|
|
WriteOrdering,
|
|
};
|
|
use collection::operations::shard_selector_internal::ShardSelectorInternal;
|
|
use collection::operations::types::{SparseVectorParams, UpdateStatus};
|
|
use collection::operations::vector_params_builder::VectorParamsBuilder;
|
|
use collection::operations::{CollectionUpdateOperations, point_ops};
|
|
use common::counter::hardware_accumulator::HwMeasurementAcc;
|
|
use segment::data_types::modifier::Modifier;
|
|
use segment::data_types::vectors::NamedSparseVector;
|
|
use segment::json_path::JsonPath;
|
|
use segment::types::{
|
|
Condition, Distance, FieldCondition, Filter, IdfCorpusParams, IdfParams, IdfScope, ScoredPoint,
|
|
SearchParams,
|
|
};
|
|
use serde_json::json;
|
|
use sparse::common::sparse_vector::SparseVector;
|
|
use tempfile::Builder;
|
|
|
|
use crate::common::{TEST_OPTIMIZERS_CONFIG, new_local_collection};
|
|
|
|
const SPARSE_VECTOR_NAME: &str = "sparse";
|
|
|
|
/// Advanced IDF formula, as implemented by `VectorQueryContext::remap_idf_weights`.
|
|
fn expected_idf(n: usize, df: usize) -> f32 {
|
|
let (n, df) = (n as f32, df as f32);
|
|
((n - df + 0.5) / (df + 0.5) + 1.0).ln()
|
|
}
|
|
|
|
fn tenant_filter(value: &str) -> Filter {
|
|
Filter::new_must(Condition::Field(FieldCondition::new_match(
|
|
JsonPath::new("tenant"),
|
|
value.to_string().into(),
|
|
)))
|
|
}
|
|
|
|
fn idf_corpus_params(corpus: Filter) -> SearchParams {
|
|
SearchParams {
|
|
idf: Some(IdfParams::Corpus(IdfCorpusParams { corpus })),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Collection with a dense vector and an IDF-modified sparse vector,
|
|
/// single shard so IDF statistics cover all points:
|
|
///
|
|
/// | point | tenant | sparse dims |
|
|
/// |-------|--------|-------------|
|
|
/// | 0 | a | 0, 1 |
|
|
/// | 1 | a | 0 |
|
|
/// | 2 | b | 0, 1, 2 |
|
|
/// | 3 | b | 1 |
|
|
async fn sparse_idf_collection_fixture(path: &std::path::Path) -> Collection {
|
|
let collection_params = CollectionParams {
|
|
vectors: VectorParamsBuilder::new(4, Distance::Dot).build().into(),
|
|
sparse_vectors: Some(BTreeMap::from([(
|
|
SPARSE_VECTOR_NAME.to_owned(),
|
|
SparseVectorParams {
|
|
index: None,
|
|
modifier: Some(Modifier::Idf),
|
|
},
|
|
)])),
|
|
shard_number: NonZeroU32::new(1).unwrap(),
|
|
..CollectionParams::empty()
|
|
};
|
|
|
|
let collection_config = CollectionConfigInternal {
|
|
params: collection_params,
|
|
optimizer_config: TEST_OPTIMIZERS_CONFIG.clone(),
|
|
wal_config: WalConfig {
|
|
wal_capacity_mb: 1,
|
|
wal_segments_ahead: 0,
|
|
wal_retain_closed: 1,
|
|
},
|
|
hnsw_config: Default::default(),
|
|
quantization_config: Default::default(),
|
|
strict_mode_config: Default::default(),
|
|
uuid: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let snapshot_path = path.join("snapshots");
|
|
let collection =
|
|
new_local_collection("test".to_string(), path, &snapshot_path, &collection_config)
|
|
.await
|
|
.unwrap();
|
|
|
|
let points: &[(&str, &[u32])] = &[("a", &[0, 1]), ("a", &[0]), ("b", &[0, 1, 2]), ("b", &[1])];
|
|
|
|
let points = points
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(point_id, (tenant, dims))| {
|
|
let sparse_vector = SparseVector::new(dims.to_vec(), vec![1.0; dims.len()]).unwrap();
|
|
PointStructPersisted {
|
|
id: (point_id as u64).into(),
|
|
vector: VectorStructPersisted::Named(HashMap::from([(
|
|
SPARSE_VECTOR_NAME.to_owned(),
|
|
point_ops::VectorPersisted::Sparse(sparse_vector),
|
|
)])),
|
|
payload: Some(serde_json::from_value(json!({ "tenant": tenant })).unwrap()),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let insert_points = CollectionUpdateOperations::PointOperation(PointOperations::UpsertPoints(
|
|
PointInsertOperationsInternal::PointsList(points),
|
|
));
|
|
let result = collection
|
|
.update_from_client_simple(
|
|
insert_points,
|
|
true,
|
|
None,
|
|
WriteOrdering::default(),
|
|
HwMeasurementAcc::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.status, UpdateStatus::Completed);
|
|
|
|
collection
|
|
}
|
|
|
|
async fn search(
|
|
collection: &Collection,
|
|
filter: Option<Filter>,
|
|
params: Option<SearchParams>,
|
|
) -> Vec<ScoredPoint> {
|
|
let request = SearchRequestInternal {
|
|
vector: api::rest::NamedVectorStruct::Sparse(NamedSparseVector {
|
|
name: SPARSE_VECTOR_NAME.to_owned(),
|
|
vector: SparseVector::new(vec![0, 1, 2], vec![1.0, 1.0, 1.0]).unwrap(),
|
|
}),
|
|
filter,
|
|
params,
|
|
limit: 10,
|
|
offset: None,
|
|
with_payload: None,
|
|
with_vector: None,
|
|
score_threshold: None,
|
|
};
|
|
|
|
collection
|
|
.search(
|
|
request.into(),
|
|
None,
|
|
None,
|
|
&ShardSelectorInternal::All,
|
|
None,
|
|
HwMeasurementAcc::new(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn scores_by_id(points: &[ScoredPoint]) -> HashMap<u64, f32> {
|
|
points
|
|
.iter()
|
|
.map(|point| {
|
|
let id = match point.id {
|
|
segment::types::ExtendedPointId::NumId(id) => id,
|
|
segment::types::ExtendedPointId::Uuid(_) => panic!("numeric ids expected"),
|
|
};
|
|
(id, point.score)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn assert_score(scores: &HashMap<u64, f32>, id: u64, expected: f32) {
|
|
let score = scores[&id];
|
|
assert!(
|
|
(score - expected).abs() < 1e-5,
|
|
"point {id}: got {score}, expected {expected}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn sparse_idf_corpus_search() {
|
|
let collection_dir = Builder::new().prefix("collection").tempdir().unwrap();
|
|
let collection = sparse_idf_collection_fixture(collection_dir.path()).await;
|
|
|
|
// Stored values are 1.0, query values are 1.0, so each point scores the
|
|
// sum of IDF weights of its dims that occur in the query.
|
|
|
|
// Global statistics: N = 4, df = [3, 3, 1].
|
|
let global = |df: usize| expected_idf(4, df);
|
|
let baseline = search(&collection, None, None).await;
|
|
let scores = scores_by_id(&baseline);
|
|
assert_score(&scores, 0, global(3) + global(3));
|
|
assert_score(&scores, 1, global(3));
|
|
assert_score(&scores, 2, global(3) + global(3) + global(1));
|
|
assert_score(&scores, 3, global(3));
|
|
|
|
// Explicit `"global"` must reproduce the default exactly.
|
|
let explicit_global = search(
|
|
&collection,
|
|
None,
|
|
Some(SearchParams {
|
|
idf: Some(IdfParams::Scope(IdfScope::Global)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(baseline, explicit_global);
|
|
|
|
// Corpus = tenant a: N = 2, df = [2, 1, 0] — while retrieval stays
|
|
// unfiltered. Scoring changes for every point, including tenant b's.
|
|
let corpus_a = |df: usize| expected_idf(2, df);
|
|
let decoupled = search(
|
|
&collection,
|
|
None,
|
|
Some(idf_corpus_params(tenant_filter("a"))),
|
|
)
|
|
.await;
|
|
let scores = scores_by_id(&decoupled);
|
|
assert_score(&scores, 0, corpus_a(2) + corpus_a(1));
|
|
assert_score(&scores, 1, corpus_a(2));
|
|
assert_score(&scores, 2, corpus_a(2) + corpus_a(1) + corpus_a(0));
|
|
assert_score(&scores, 3, corpus_a(1));
|
|
|
|
// Corpus and retrieval filter combined: same corpus-scoped scores,
|
|
// narrowed result set.
|
|
let filtered = search(
|
|
&collection,
|
|
Some(tenant_filter("a")),
|
|
Some(idf_corpus_params(tenant_filter("a"))),
|
|
)
|
|
.await;
|
|
let scores = scores_by_id(&filtered);
|
|
assert_eq!(scores.len(), 2);
|
|
assert_score(&scores, 0, corpus_a(2) + corpus_a(1));
|
|
assert_score(&scores, 1, corpus_a(2));
|
|
|
|
// Tightening the retrieval filter must NOT move the scores when the
|
|
// corpus stays fixed — the score scale is defined by the corpus alone.
|
|
let corpus_b = |df: usize| expected_idf(2, df);
|
|
let broad = search(
|
|
&collection,
|
|
None,
|
|
Some(idf_corpus_params(tenant_filter("b"))),
|
|
)
|
|
.await;
|
|
let narrow = search(
|
|
&collection,
|
|
Some(tenant_filter("b")),
|
|
Some(idf_corpus_params(tenant_filter("b"))),
|
|
)
|
|
.await;
|
|
let broad_scores = scores_by_id(&broad);
|
|
let narrow_scores = scores_by_id(&narrow);
|
|
for id in [2, 3] {
|
|
assert!((broad_scores[&id] - narrow_scores[&id]).abs() < 1e-6);
|
|
}
|
|
// Corpus tenant b: N = 2, df = [1, 2, 1].
|
|
assert_score(&broad_scores, 2, corpus_b(1) + corpus_b(2) + corpus_b(1));
|
|
|
|
// An empty corpus yields degenerate but corpus-scoped scores — never a
|
|
// fallback to global statistics. idf(0, 0) = ln(2) for every term.
|
|
let empty = search(
|
|
&collection,
|
|
None,
|
|
Some(idf_corpus_params(tenant_filter("missing"))),
|
|
)
|
|
.await;
|
|
let scores = scores_by_id(&empty);
|
|
let ln2 = expected_idf(0, 0);
|
|
assert_score(&scores, 0, 2.0 * ln2);
|
|
assert_score(&scores, 2, 3.0 * ln2);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn sparse_idf_params_require_idf_modifier() {
|
|
let collection_dir = Builder::new().prefix("collection").tempdir().unwrap();
|
|
let collection = sparse_idf_collection_fixture(collection_dir.path()).await;
|
|
|
|
// The `idf` search param on a vector without the IDF modifier (the dense
|
|
// default vector here) is rejected instead of being silently ignored.
|
|
let request = SearchRequestInternal {
|
|
vector: vec![1.0, 0.0, 0.0, 0.0].into(),
|
|
filter: None,
|
|
params: Some(idf_corpus_params(tenant_filter("a"))),
|
|
limit: 10,
|
|
offset: None,
|
|
with_payload: None,
|
|
with_vector: None,
|
|
score_threshold: None,
|
|
};
|
|
|
|
let error = collection
|
|
.search(
|
|
request.into(),
|
|
None,
|
|
None,
|
|
&ShardSelectorInternal::All,
|
|
None,
|
|
HwMeasurementAcc::new(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("idf"),
|
|
"unexpected error: {error}"
|
|
);
|
|
}
|