Files
qdrant/lib/segment/tests/integration/sparse_discover_test.rs
Andrey Vasnetsov 1d4d6f02da Per-query IDF corpus for sparse vector search (#9661)
* 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>
2026-07-14 11:16:47 +02:00

336 lines
12 KiB
Rust

use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use ahash::AHashSet;
use common::counter::hardware_counter::HardwareCounterCell;
use common::types::TelemetryDetail;
use common::universal_io::MmapFs;
use itertools::Itertools;
use rand::prelude::StdRng;
use rand::{Rng, RngExt, SeedableRng};
use segment::data_types::named_vectors::NamedVectors;
use segment::data_types::query_context::{QueryContext, VectorQueryContext};
use segment::data_types::vectors::{QueryVector, VectorElementType, VectorInternal};
use segment::entry::entry_point::SegmentEntry;
use segment::fixtures::payload_fixtures::random_vector;
use segment::index::VectorIndexRead;
use segment::index::sparse_index::sparse_index_config::{SparseIndexConfig, SparseIndexType};
use segment::index::sparse_index::sparse_vector_index::SparseVectorIndexOpenArgs;
use segment::segment_constructor::{build_segment, create_sparse_vector_index_test};
use segment::types::{
Condition, DEFAULT_SPARSE_FULL_SCAN_THRESHOLD, Distance, ExtendedPointId, Filter,
HasIdCondition, Indexes, PointIdType, SegmentConfig, SeqNumberType, SparseVectorDataConfig,
SparseVectorStorageType, VectorDataConfig, VectorStorageDatatype, VectorStorageType,
};
use segment::vector_storage::query::{ContextPair, DiscoverQuery};
use sparse::common::sparse_vector::SparseVector;
use tempfile::Builder;
use crate::fixtures::segment::SPARSE_VECTOR_NAME;
const MAX_EXAMPLE_PAIRS: usize = 3;
fn convert_to_sparse_vector(vector: &[VectorElementType]) -> SparseVector {
let mut sparse_vector = SparseVector::default();
for (idx, value) in vector.iter().enumerate() {
sparse_vector.indices.push(idx as u32);
sparse_vector.values.push(*value);
}
sparse_vector
}
fn random_named_vector<R: Rng + ?Sized>(
rnd: &mut R,
dim: usize,
) -> (NamedVectors<'_>, NamedVectors<'_>) {
let dense_vector = random_vector(rnd, dim);
let sparse_vector = convert_to_sparse_vector(&dense_vector);
let mut sparse_result = NamedVectors::default();
sparse_result.insert(SPARSE_VECTOR_NAME.to_owned(), sparse_vector.into());
let mut dense_result = NamedVectors::default();
dense_result.insert(SPARSE_VECTOR_NAME.to_owned(), dense_vector.into());
(sparse_result, dense_result)
}
fn random_discover_query<R: Rng + ?Sized>(rnd: &mut R, dim: usize) -> (QueryVector, QueryVector) {
let num_pairs: usize = rnd.random_range(1..MAX_EXAMPLE_PAIRS);
let dense_target = random_vector(rnd, dim);
let sparse_target = convert_to_sparse_vector(&dense_target);
let dense_pairs = (0..num_pairs)
.map(|_| {
let positive = random_vector(rnd, dim);
let negative = random_vector(rnd, dim);
(positive, negative)
})
.collect_vec();
let sparse_pairs = (0..num_pairs)
.map(|i| {
let positive = convert_to_sparse_vector(&dense_pairs[i].0);
let negative = convert_to_sparse_vector(&dense_pairs[i].1);
(positive, negative)
})
.collect_vec();
let dense_query = DiscoverQuery::new(
dense_target.into(),
dense_pairs
.into_iter()
.map(|(positive, negative)| ContextPair {
positive: positive.into(),
negative: negative.into(),
})
.collect(),
)
.into();
let sparse_query = DiscoverQuery::new(
sparse_target.into(),
sparse_pairs
.into_iter()
.map(|(positive, negative)| ContextPair {
positive: positive.into(),
negative: negative.into(),
})
.collect(),
)
.into();
(sparse_query, dense_query)
}
fn random_nearest_query<R: Rng + ?Sized>(rnd: &mut R, dim: usize) -> (QueryVector, QueryVector) {
let dense_target = random_vector(rnd, dim);
let sparse_target = convert_to_sparse_vector(&dense_target);
(sparse_target.into(), dense_target.into())
}
#[test]
fn sparse_index_discover_test() {
let stopped = AtomicBool::new(false);
let dim = 8;
let num_vectors: u64 = 5_000;
let distance = Distance::Dot;
let mut rnd = StdRng::seed_from_u64(42);
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
let index_dir = Builder::new().prefix("hnsw_dir").tempdir().unwrap();
let sparse_config = SegmentConfig {
vector_data: Default::default(),
sparse_vector_data: HashMap::from([(
SPARSE_VECTOR_NAME.to_owned(),
SparseVectorDataConfig {
index: SparseIndexConfig {
memory: None,
full_scan_threshold: Some(DEFAULT_SPARSE_FULL_SCAN_THRESHOLD),
index_type: SparseIndexType::MutableRam,
datatype: Some(VectorStorageDatatype::Float32),
},
storage_type: SparseVectorStorageType::default(),
modifier: None,
},
)]),
payload_storage_type: Default::default(),
};
let dense_config = SegmentConfig {
vector_data: HashMap::from([(
SPARSE_VECTOR_NAME.to_owned(),
VectorDataConfig {
size: dim,
distance,
storage_type: VectorStorageType::default(),
index: Indexes::Plain {},
quantization_config: None,
multivector_config: None,
datatype: None,
},
)]),
payload_storage_type: Default::default(),
sparse_vector_data: Default::default(),
};
let (mut sparse_segment, _) = build_segment(dir.path(), &sparse_config, None, true).unwrap();
let (mut dense_segment, _) = build_segment(dir.path(), &dense_config, None, true).unwrap();
let hw_counter = HardwareCounterCell::new();
for n in 0..num_vectors {
let (sparse_vector, dense_vector) = random_named_vector(&mut rnd, dim);
let idx = n.into();
sparse_segment
.upsert_point(n as SeqNumberType, idx, sparse_vector, &hw_counter)
.unwrap();
dense_segment
.upsert_point(n as SeqNumberType, idx, dense_vector, &hw_counter)
.unwrap();
}
let payload_index_ptr = sparse_segment.payload_index.clone();
let vector_storage = &sparse_segment.vector_data[SPARSE_VECTOR_NAME].vector_storage;
let sparse_index = create_sparse_vector_index_test(SparseVectorIndexOpenArgs {
fs: &MmapFs,
config: SparseIndexConfig {
memory: None,
full_scan_threshold: Some(DEFAULT_SPARSE_FULL_SCAN_THRESHOLD),
index_type: SparseIndexType::ImmutableRam,
datatype: Some(VectorStorageDatatype::Float32),
},
id_tracker: sparse_segment.id_tracker.clone(),
vector_storage: vector_storage.clone(),
payload_index: payload_index_ptr,
path: index_dir.path(),
stopped: &stopped,
tick_progress: || (),
})
.unwrap();
let top = 3;
let attempts = 100;
for i in 0..attempts {
// do discover search
let (sparse_query, dense_query) = random_discover_query(&mut rnd, dim);
let vec_context = VectorQueryContext::default();
let sparse_discover_result = sparse_index
.search(&[&sparse_query], None, top, None, &vec_context)
.unwrap();
let dense_discover_result = dense_segment.vector_data[SPARSE_VECTOR_NAME]
.vector_index
.borrow()
.search(&[&dense_query], None, top, None, &vec_context)
.unwrap();
// check id only because scores can be epsilon-size different
assert_eq!(
sparse_discover_result[0]
.iter()
.map(|r| r.idx)
.collect_vec(),
dense_discover_result[0].iter().map(|r| r.idx).collect_vec(),
);
// do regular nearest search
let (sparse_query, dense_query) = random_nearest_query(&mut rnd, dim);
let query_context = QueryContext::default();
let segment_query_context = query_context.get_segment_query_context();
let vector_context = segment_query_context.get_vector_context(SPARSE_VECTOR_NAME, None);
let sparse_search_result = sparse_index
.search(&[&sparse_query], None, top, None, &vector_context)
.unwrap();
let cpu_usage = query_context.hardware_usage_accumulator().get_cpu();
assert!(cpu_usage > 0);
let dense_search_result = dense_segment.vector_data[SPARSE_VECTOR_NAME]
.vector_index
.borrow()
.search(&[&dense_query], None, top, None, &vector_context)
.unwrap();
// check that nearest search uses sparse index
let telemetry = sparse_index.get_telemetry_data(TelemetryDetail::default());
assert_eq!(telemetry.unfiltered_sparse.count, i + 1);
// check id only because scores can be epsilon-size different
assert_eq!(
sparse_search_result[0].iter().map(|r| r.idx).collect_vec(),
dense_search_result[0].iter().map(|r| r.idx).collect_vec(),
);
}
}
#[test]
fn sparse_index_hardware_measurement_test() {
let stopped = AtomicBool::new(false);
let dim = 8;
let num_vectors: u64 = 5_000;
let mut rnd = StdRng::seed_from_u64(42);
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
let index_dir = Builder::new().prefix("hnsw_dir").tempdir().unwrap();
let sparse_config = SegmentConfig {
vector_data: Default::default(),
sparse_vector_data: HashMap::from([(
SPARSE_VECTOR_NAME.to_owned(),
SparseVectorDataConfig {
index: SparseIndexConfig {
memory: None,
full_scan_threshold: Some(DEFAULT_SPARSE_FULL_SCAN_THRESHOLD),
index_type: SparseIndexType::MutableRam,
datatype: Some(VectorStorageDatatype::Float32),
},
storage_type: SparseVectorStorageType::default(),
modifier: None,
},
)]),
payload_storage_type: Default::default(),
};
let (mut sparse_segment, _) = build_segment(dir.path(), &sparse_config, None, true).unwrap();
let hw_counter = HardwareCounterCell::new();
for n in 0..num_vectors {
let (sparse_vector, _) = random_named_vector(&mut rnd, dim);
let idx = n.into();
sparse_segment
.upsert_point(n as SeqNumberType, idx, sparse_vector, &hw_counter)
.unwrap();
}
let payload_index_ptr = sparse_segment.payload_index.clone();
let vector_storage = &sparse_segment.vector_data[SPARSE_VECTOR_NAME].vector_storage;
let sparse_index = create_sparse_vector_index_test(SparseVectorIndexOpenArgs {
fs: &MmapFs,
config: SparseIndexConfig {
memory: None,
full_scan_threshold: Some(DEFAULT_SPARSE_FULL_SCAN_THRESHOLD),
index_type: SparseIndexType::ImmutableRam,
datatype: Some(VectorStorageDatatype::Float32),
},
id_tracker: sparse_segment.id_tracker.clone(),
vector_storage: vector_storage.clone(),
payload_index: payload_index_ptr,
path: index_dir.path(),
stopped: &stopped,
tick_progress: || (),
})
.unwrap();
let query_vec = QueryVector::Nearest(VectorInternal::Sparse(
SparseVector::new(vec![0, 1, 2], vec![42.0, 42.42, 42.4242]).unwrap(),
));
let query_context = QueryContext::default();
let segment_query_context = query_context.get_segment_query_context();
let vector_context = segment_query_context.get_vector_context(SPARSE_VECTOR_NAME, None);
let cpu_usage = query_context.hardware_usage_accumulator().get_cpu();
assert_eq!(cpu_usage, 0);
// Some filter so we do plain sparse search
let ids: AHashSet<PointIdType> = (0..3).map(ExtendedPointId::NumId).collect();
let filter = Filter::new_must(Condition::HasId(HasIdCondition::from(ids)));
sparse_index
.search(&[&query_vec], Some(&filter), 1, None, &vector_context)
.unwrap();
let cpu_usage = query_context.hardware_usage_accumulator().get_cpu();
assert!(cpu_usage > 0);
}