Skip to content

Commit 0e76ed0

Browse files
authored
Add ability to query with prefetch (#179)
1 parent 51c58a0 commit 0e76ed0

5 files changed

Lines changed: 398 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,12 +269,33 @@ batch. Supported kinds:
269269

270270
| `kind` | Fields | Notes |
271271
|--------|--------|-------|
272-
| `dense` | `size`, optional `using`, `source`, `filters` | Query a dense vector; `source: { type: dataset }` measures recall (see below) |
272+
| `dense` | `size`, optional `using`, `source`, `filters`, `multivector`, `prefetch` | Query a dense vector; `source: { type: dataset }` measures recall (see below) |
273273
| `sparse` | `using`, `source`, `filters` | Query a named sparse vector; `source: { type: dataset }` measures recall (see below) |
274274

275275
Filter entries reuse the same payload `type` / `source` vocabulary as the
276276
upload config.
277277

278+
A `dense` request can also query a multivector and run as a two-stage query,
279+
the usual ColBERT setup: an HNSW search on one named vector picks candidates,
280+
then the request's own vector rescores them.
281+
282+
```yaml
283+
requests:
284+
- kind: dense
285+
using: colbert # the rescoring vector
286+
size: 128
287+
multivector: { count: 16 } # query with 16 sub-vectors of 128
288+
prefetch: # first stage
289+
using: dense # a named dense vector
290+
size: 128
291+
limit: 500 # candidates handed to the rescore
292+
```
293+
294+
Both fields need `source: random`. The prefetch stage gets the run's search
295+
params (`--search-hnsw-ef`, `--search-exact`, quantization flags). `--prefetch`
296+
and `--search-quality` cannot be combined with a config that sets `prefetch`, and
297+
`prefetch.limit` must be at least `--search-limit`.
298+
278299
#### Measuring accuracy against a reference dataset
279300

280301
A `dense` or `sparse` request can draw its queries from a vector-db-benchmark

‎src/config/search.rs‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ pub enum SearchRequestConfig {
4747
source: VectorSource,
4848
#[serde(default)]
4949
filters: Vec<FilterPayloadConfig>,
50+
/// Query with several sub-vectors of `size` each (a multivector). Random source only.
51+
#[serde(default)]
52+
multivector: Option<QueryMultivectorConfig>,
53+
/// Two-stage query: this search picks candidates, then the request's own vector
54+
/// rescores them.
55+
#[serde(default)]
56+
prefetch: Option<PrefetchConfig>,
5057
},
5158
Sparse {
5259
using: String,
@@ -63,6 +70,28 @@ pub enum SearchRequestConfig {
6370
},
6471
}
6572

73+
/// Shape of a multivector query.
74+
#[derive(Debug, Clone, Serialize, Deserialize)]
75+
#[serde(deny_unknown_fields)]
76+
pub struct QueryMultivectorConfig {
77+
/// Sub-vectors per query.
78+
pub count: usize,
79+
}
80+
81+
/// The first stage of a two-stage query: a random dense query on its own named vector.
82+
#[derive(Debug, Clone, Serialize, Deserialize)]
83+
#[serde(deny_unknown_fields)]
84+
pub struct PrefetchConfig {
85+
/// Named dense vector the first stage searches.
86+
pub using: String,
87+
/// Its dimension.
88+
pub size: u64,
89+
#[serde(default)]
90+
pub datatype: DatatypeKind,
91+
/// Candidates handed to the second stage.
92+
pub limit: u64,
93+
}
94+
6695
/// Payload field used to build a filter condition for a search request.
6796
#[derive(Debug, Clone, Serialize, Deserialize)]
6897
#[serde(deny_unknown_fields)]
@@ -103,6 +132,32 @@ pub fn parse(text: &str, origin: &str) -> Result<SearchConfig> {
103132
}
104133

105134
impl SearchConfig {
135+
/// Whether any request runs as a two-stage (prefetch then rescore) query.
136+
pub fn has_prefetch(&self) -> bool {
137+
self.requests.iter().any(|r| {
138+
matches!(
139+
r,
140+
SearchRequestConfig::Dense {
141+
prefetch: Some(_),
142+
..
143+
}
144+
)
145+
})
146+
}
147+
148+
/// The smallest prefetch `limit` across requests, if any request prefetches.
149+
pub fn min_prefetch_limit(&self) -> Option<u64> {
150+
self.requests
151+
.iter()
152+
.filter_map(|r| match r {
153+
SearchRequestConfig::Dense {
154+
prefetch: Some(p), ..
155+
} => Some(p.limit),
156+
_ => None,
157+
})
158+
.min()
159+
}
160+
106161
pub fn validate(&self) -> Result<()> {
107162
if self.requests.is_empty() {
108163
bail!("search config must define at least one request");
@@ -135,6 +190,37 @@ impl SearchRequestConfig {
135190
}
136191
}
137192

193+
if let SearchRequestConfig::Dense {
194+
source,
195+
multivector,
196+
prefetch,
197+
..
198+
} = self
199+
{
200+
if let Some(multivector) = multivector {
201+
if multivector.count == 0 {
202+
bail!("requests[{index}]: `multivector.count` must be > 0");
203+
}
204+
if !matches!(source, VectorSource::Random) {
205+
bail!("requests[{index}]: `multivector` needs `source: random`");
206+
}
207+
}
208+
if let Some(prefetch) = prefetch {
209+
if prefetch.using.is_empty() {
210+
bail!("requests[{index}]: `prefetch.using` must not be empty");
211+
}
212+
if prefetch.size == 0 {
213+
bail!("requests[{index}]: `prefetch.size` must be > 0");
214+
}
215+
if prefetch.limit == 0 {
216+
bail!("requests[{index}]: `prefetch.limit` must be > 0");
217+
}
218+
if !matches!(source, VectorSource::Random) {
219+
bail!("requests[{index}]: `prefetch` needs `source: random`");
220+
}
221+
}
222+
}
223+
138224
match self {
139225
SearchRequestConfig::Dense { size, source, .. } => {
140226
match source {
@@ -183,6 +269,63 @@ mod tests {
183269
use super::*;
184270
use crate::config::DistributionKind;
185271

272+
const RESCORE_YAML: &str = r#"
273+
collection:
274+
name: bench
275+
requests:
276+
- kind: dense
277+
using: colbert
278+
size: 128
279+
multivector: { count: 16 }
280+
prefetch: { using: dense, size: 128, limit: 500 }
281+
"#;
282+
283+
#[test]
284+
fn parses_multivector_prefetch_request() {
285+
let cfg: SearchConfig = serde_yaml::from_str(RESCORE_YAML).unwrap();
286+
cfg.validate().unwrap();
287+
assert!(cfg.has_prefetch());
288+
match &cfg.requests[0] {
289+
SearchRequestConfig::Dense {
290+
multivector: Some(m),
291+
prefetch: Some(p),
292+
..
293+
} => {
294+
assert_eq!(m.count, 16);
295+
assert_eq!((p.using.as_str(), p.size, p.limit), ("dense", 128, 500));
296+
}
297+
other => panic!("unexpected request {other:?}"),
298+
}
299+
}
300+
301+
#[test]
302+
fn rejects_bad_multivector_and_prefetch() {
303+
for (from, to, message) in [
304+
("count: 16", "count: 0", "`multivector.count` must be > 0"),
305+
("limit: 500", "limit: 0", "`prefetch.limit` must be > 0"),
306+
(
307+
"{ using: dense,",
308+
"{ using: \"\",",
309+
"`prefetch.using` must not be empty",
310+
),
311+
(
312+
"size: 128, limit",
313+
"size: 0, limit",
314+
"`prefetch.size` must be > 0",
315+
),
316+
(
317+
"multivector: { count: 16 }",
318+
"multivector: { count: 16 }\n source: { type: file, path: q.fbin }",
319+
"`multivector` needs `source: random`",
320+
),
321+
] {
322+
let yaml = RESCORE_YAML.replace(from, to);
323+
let cfg: SearchConfig = serde_yaml::from_str(&yaml).unwrap();
324+
let err = cfg.validate().unwrap_err().to_string();
325+
assert!(err.contains(message), "{from} -> {to}: {err}");
326+
}
327+
}
328+
186329
#[test]
187330
fn parses_minimal_search_config() {
188331
let yaml = r#"

‎src/generators/queries.rs‎

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
66

77
use anyhow::Context;
88
use qdrant_client::qdrant::{
9-
Condition, Filter, GeoPoint, GeoRadius, Range, RepeatedStrings, SparseIndices,
9+
Condition, Filter, GeoPoint, GeoRadius, Range, RepeatedStrings, SparseIndices, VectorInput,
1010
r#match::MatchValue,
1111
};
1212
use rand::Rng;
@@ -32,10 +32,34 @@ const GEO_SPREAD_DEG: f64 = 1.0;
3232
const GEO_RADIUS_METERS_MIN: f64 = 1000.0;
3333
const GEO_RADIUS_METERS_MAX: f64 = 50000.0;
3434

35+
/// A dense query: one vector, or several sub-vectors for a multivector.
36+
#[derive(Debug, Clone, PartialEq)]
37+
pub enum DenseQuery {
38+
Single(Vec<f32>),
39+
Multi(Vec<Vec<f32>>),
40+
}
41+
42+
impl DenseQuery {
43+
pub fn into_vector_input(self) -> VectorInput {
44+
match self {
45+
DenseQuery::Single(vector) => VectorInput::new_dense(vector),
46+
DenseQuery::Multi(vectors) => VectorInput::new_multi(vectors),
47+
}
48+
}
49+
}
50+
51+
/// The first stage of a two-stage query.
52+
#[derive(Debug, Clone)]
53+
pub struct GeneratedPrefetch {
54+
pub vector: Vec<f32>,
55+
pub using: String,
56+
pub limit: u64,
57+
}
58+
3559
/// One query vector plus optional filter, ready to be turned into a gRPC request.
3660
#[derive(Debug, Clone)]
3761
pub struct GeneratedQuery {
38-
pub dense: Option<(Vec<f32>, Option<String>)>,
62+
pub dense: Option<(DenseQuery, Option<String>)>,
3963
pub sparse: Option<(Vec<f32>, SparseIndices, String)>,
4064
pub filter: Option<Filter>,
4165
/// Sparse-vector IDF corpus: restricts which points the IDF statistics are
@@ -45,6 +69,8 @@ pub struct GeneratedQuery {
4569
/// when the request draws queries from a reference dataset. Used to measure
4670
/// search accuracy (recall) against the dataset's known answers.
4771
pub expected_ids: Option<Vec<u64>>,
72+
/// Present for a two-stage query: its candidates are what the main query rescores.
73+
pub prefetch: Option<GeneratedPrefetch>,
4874
}
4975

5076
/// A reference dataset's query set, held in memory, with a cursor that hands out
@@ -492,10 +518,20 @@ impl ConfigSearchGenerator {
492518
datatype,
493519
source,
494520
filters: _,
521+
multivector,
522+
prefetch,
495523
} => {
496524
let (vector, expected_ids, dataset_filter) =
497525
if let Some(query_dataset) = &state.query_dataset {
498-
Self::read_dense_query(query_dataset)
526+
let (vector, ids, filter) = Self::read_dense_query(query_dataset);
527+
(DenseQuery::Single(vector), ids, filter)
528+
} else if let Some(multivector) = multivector {
529+
// Validation allows multivector only with random queries.
530+
let is_uint = *datatype == DatatypeKind::Uint8;
531+
let vectors = (0..multivector.count)
532+
.map(|_| random_dense_vector(rng, *size as usize, is_uint))
533+
.collect();
534+
(DenseQuery::Multi(vectors), None, None)
499535
} else {
500536
let vector = Self::gen_dense_vector(
501537
rng,
@@ -505,14 +541,24 @@ impl ConfigSearchGenerator {
505541
state.dense_reader.as_ref(),
506542
req_id,
507543
);
508-
(vector, None, None)
544+
(DenseQuery::Single(vector), None, None)
509545
};
546+
let prefetch = prefetch.as_ref().map(|p| GeneratedPrefetch {
547+
vector: random_dense_vector(
548+
rng,
549+
p.size as usize,
550+
p.datatype == DatatypeKind::Uint8,
551+
),
552+
using: p.using.clone(),
553+
limit: p.limit,
554+
});
510555
GeneratedQuery {
511556
dense: Some((vector, using.clone())),
512557
sparse: None,
513558
filter: dataset_filter.or_else(|| state.filters.build(rng)),
514559
idf_corpus: None,
515560
expected_ids,
561+
prefetch,
516562
}
517563
}
518564
SearchRequestConfig::Sparse {
@@ -537,6 +583,7 @@ impl ConfigSearchGenerator {
537583
filter: dataset_filter.or_else(|| state.filters.build(rng)),
538584
idf_corpus: state.idf_corpus.build(rng),
539585
expected_ids,
586+
prefetch: None,
540587
}
541588
}
542589
}
@@ -645,6 +692,26 @@ mod tests {
645692
ConfigSearchGenerator::new(&config).unwrap()
646693
}
647694

695+
#[test]
696+
fn generates_multivector_query_with_prefetch() {
697+
let generator = build_gen(
698+
"collection:\n name: x\nrequests:\n - kind: dense\n using: colbert\n size: 8\n multivector: { count: 4 }\n prefetch: { using: dense, size: 6, limit: 50 }\n",
699+
);
700+
let q = generator.make_query(0, &mut rand::rng());
701+
let (query, using) = q.dense.unwrap();
702+
assert_eq!(using.as_deref(), Some("colbert"));
703+
match query {
704+
DenseQuery::Multi(vectors) => {
705+
assert_eq!(vectors.len(), 4);
706+
assert!(vectors.iter().all(|v| v.len() == 8));
707+
}
708+
other => panic!("expected a multivector query, got {other:?}"),
709+
}
710+
let prefetch = q.prefetch.unwrap();
711+
assert_eq!((prefetch.using.as_str(), prefetch.limit), ("dense", 50));
712+
assert_eq!(prefetch.vector.len(), 6);
713+
}
714+
648715
#[test]
649716
fn generates_dense_and_sparse_queries() {
650717
let generator = build_gen(
@@ -696,11 +763,17 @@ mod tests {
696763
let q0 = generator.make_query_for(0, 0, &mut rng);
697764
let q1 = generator.make_query_for(0, 0, &mut rng);
698765
let q2 = generator.make_query_for(0, 0, &mut rng);
699-
assert_eq!(q0.dense.as_ref().unwrap().0, vec![0.0, 1.0, 2.0, 3.0]);
766+
assert_eq!(
767+
q0.dense.as_ref().unwrap().0,
768+
DenseQuery::Single(vec![0.0, 1.0, 2.0, 3.0])
769+
);
700770
assert_eq!(q0.expected_ids, Some(vec![0, 2]));
701771
assert_eq!(q1.expected_ids, Some(vec![1, 2]));
702772
// Wrapped back to the first query.
703-
assert_eq!(q2.dense.as_ref().unwrap().0, vec![0.0, 1.0, 2.0, 3.0]);
773+
assert_eq!(
774+
q2.dense.as_ref().unwrap().0,
775+
DenseQuery::Single(vec![0.0, 1.0, 2.0, 3.0])
776+
);
704777
assert_eq!(q2.expected_ids, Some(vec![0, 2]));
705778
}
706779

0 commit comments

Comments
 (0)