Skip to content

Commit 5c652e1

Browse files
authored
fix: Allow BMW to be used for compound ORDER BY with a score prefix (#167)
Remove the override of `collect_segment_top_k` for `SortBySimilarityScore`, and instead bake score awareness into the default implementation of that method. To do so, introduces the `supports_bm25_pruning` and `bm25_pruning_threshold` methods to `SortKeyComputer`, which can pass through to wrapped implementations if BMW pruning is safe (for example, if a score is a prefix). This additionally allows for simplifying `SharedThreshold`, because equal values and tiebreakers are now handled directly in `collect_segment_top_k`. Benchmarks in paradedb/paradedb#5496 (review) demonstrate a speedup for `top_k-score-desc-tiebreaker`.
1 parent 0056e2d commit 5c652e1

10 files changed

Lines changed: 289 additions & 143 deletions

src/collector/sort_key/mod.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,5 +457,61 @@ pub(crate) mod tests {
457457
top_n_results
458458
);
459459
}
460+
461+
#[test]
462+
fn test_order_by_score_then_fast_field_prop(
463+
limit in 1..64_usize,
464+
offset in 0..64_usize,
465+
segments_docs in
466+
proptest::collection::vec(
467+
proptest::collection::vec(
468+
// (doc_terms, fast_field_val)
469+
(proptest::collection::vec(0..5_u8, 1..10_usize), 0..100_u64),
470+
1..32_usize
471+
),
472+
0..8_usize,
473+
)
474+
) {
475+
use crate::query::TermQuery;
476+
use crate::schema::IndexRecordOption;
477+
use crate::Term;
478+
479+
let mut schema_builder = Schema::builder();
480+
let text = schema_builder.add_text_field("text", TEXT);
481+
let value = schema_builder.add_u64_field("value", FAST);
482+
let schema = schema_builder.build();
483+
let index = Index::create_in_ram(schema);
484+
let mut index_writer = index.writer_for_tests().unwrap();
485+
486+
for segment_docs in segments_docs.into_iter() {
487+
for (term_ids, val) in segment_docs.into_iter() {
488+
let term_str = term_ids.into_iter().map(|id| format!("term{id}")).collect::<Vec<_>>().join(" ");
489+
index_writer.add_document(doc!(
490+
text => term_str,
491+
value => val,
492+
)).unwrap();
493+
}
494+
index_writer.commit().unwrap();
495+
}
496+
497+
let searcher = index.reader().unwrap().searcher();
498+
let query = TermQuery::new(Term::from_field_text(text, "term0"), IndexRecordOption::WithFreqs);
499+
500+
// 1. TopDocs with BMW (Score, u64)
501+
let top_collector = TopDocs::with_limit(limit).and_offset(offset).order_by::<(Score, Option<u64>)>((
502+
(SortBySimilarityScore::new(), Order::Desc),
503+
(SortByStaticFastValue::for_field("value"), Order::Desc),
504+
));
505+
let top_n_results: Vec<((Score, Option<u64>), DocAddress)> = searcher.search(&query, &top_collector).unwrap();
506+
507+
// 2. Golden baseline: collect all with scoring
508+
let all_collector = TopDocs::with_limit(1000).order_by::<(Score, Option<u64>)>((
509+
(SortBySimilarityScore::new(), Order::Desc),
510+
(SortByStaticFastValue::for_field("value"), Order::Desc),
511+
));
512+
let expected_docs = searcher.search(&query, &all_collector).unwrap().into_iter().skip(offset).take(limit).collect::<Vec<_>>();
513+
514+
prop_assert_eq!(expected_docs, top_n_results);
515+
}
460516
}
461517
}

src/collector/sort_key/order.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,9 +524,14 @@ where
524524
segment_reader: &crate::SegmentReader,
525525
) -> crate::Result<Self::Child> {
526526
let child = self.0.segment_sort_key_computer(segment_reader)?;
527+
let bmw_supported = matches!(
528+
self.1,
529+
ComparatorEnum::Natural | ComparatorEnum::NaturalNoneHigher
530+
);
527531
Ok(SegmentSortKeyComputerWithComparator {
528532
segment_sort_key_computer: child,
529533
comparator: self.comparator(),
534+
bmw_supported,
530535
})
531536
}
532537
}
@@ -570,9 +575,11 @@ where
570575
segment_reader: &crate::SegmentReader,
571576
) -> crate::Result<Self::Child> {
572577
let child = self.0.segment_sort_key_computer(segment_reader)?;
578+
let bmw_supported = self.1 == Order::Desc;
573579
Ok(SegmentSortKeyComputerWithComparator {
574580
segment_sort_key_computer: child,
575581
comparator: self.comparator(),
582+
bmw_supported,
576583
})
577584
}
578585
}
@@ -581,6 +588,7 @@ where
581588
pub struct SegmentSortKeyComputerWithComparator<TSegmentSortKeyComputer, TComparator> {
582589
segment_sort_key_computer: TSegmentSortKeyComputer,
583590
comparator: TComparator,
591+
bmw_supported: bool,
584592
}
585593

586594
impl<TSegmentSortKeyComputer, TSegmentSortKey, TComparator> SegmentSortKeyComputer
@@ -611,6 +619,20 @@ where
611619
self.segment_sort_key_computer
612620
.convert_segment_sort_key(sort_key)
613621
}
622+
623+
fn supports_bm25_pruning(&self) -> bool {
624+
self.bmw_supported && self.segment_sort_key_computer.supports_bm25_pruning()
625+
}
626+
627+
fn bm25_pruning_threshold(
628+
&self,
629+
threshold: &Self::SegmentSortKey,
630+
segment_ord: crate::SegmentOrdinal,
631+
threshold_ord: crate::SegmentOrdinal,
632+
) -> Option<Score> {
633+
self.segment_sort_key_computer
634+
.bm25_pruning_threshold(threshold, segment_ord, threshold_ord)
635+
}
614636
}
615637

616638
#[cfg(test)]

src/collector/sort_key/shared_threshold.rs

Lines changed: 0 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -34,35 +34,6 @@ pub trait SharedThreshold<T>: Send + Sync {
3434
expected_threshold: &Option<(T, SegmentOrdinal)>,
3535
new_threshold: (T, SegmentOrdinal),
3636
) -> Result<(), Option<(T, SegmentOrdinal)>>;
37-
38-
/// Returns a threshold value `T` that is strictly better than the current shared threshold
39-
/// if the given `segment_ord` would lose a tie-break against the `threshold_ord`.
40-
///
41-
/// This is used for Block-Max WAND pruning to ensure determinism when multiple segments
42-
/// have documents with the same score. Currently, this is only used for score-based
43-
/// pushdown; for other types, it can safely return the value unchanged.
44-
///
45-
/// # Tie-breaking Logic
46-
/// Tantivy breaks ties on sort keys by favoring documents with a lower [`DocAddress`].
47-
/// A [`DocAddress`] is composed of a `segment_ord` and a `doc_id`. Since documents within
48-
/// a segment are processed in ascending `doc_id` order, any new document in a segment
49-
/// with `segment_ord >= threshold_ord` will have a strictly higher [`DocAddress`] than the
50-
/// document that set the threshold.
51-
///
52-
/// Therefore, if `segment_ord >= threshold_ord`, this method should return a threshold that
53-
/// is "one step better" than `value`. For floating-point scores, this is typically
54-
/// `value.next_up()`. If `segment_ord < threshold_ord`, the document could still win the
55-
/// tie-break, so the `value` should be returned unchanged.
56-
fn competitive_threshold(
57-
&self,
58-
value: T,
59-
threshold_ord: SegmentOrdinal,
60-
segment_ord: SegmentOrdinal,
61-
) -> T {
62-
let _ = threshold_ord;
63-
let _ = segment_ord;
64-
value
65-
}
6637
}
6738

6839
#[inline]
@@ -151,25 +122,6 @@ impl SharedThreshold<Score> for AtomicSharedThreshold {
151122
}
152123
}
153124
}
154-
155-
fn competitive_threshold(
156-
&self,
157-
value: Score,
158-
threshold_ord: SegmentOrdinal,
159-
segment_ord: SegmentOrdinal,
160-
) -> Score {
161-
if segment_ord < threshold_ord {
162-
// If our segment wins tie-breaks against the threshold setter, we want to accept
163-
// documents with a score GREATER THAN OR EQUAL to the threshold.
164-
// Since Tantivy's pruning loop uses a strict `score > threshold` check, we
165-
// return `next_down()` to effectively relax the check to `>=`.
166-
value.next_down()
167-
} else {
168-
// If our segment loses tie-breaks, we demand a score STRICTLY GREATER than the
169-
// threshold. The pruning loop's `score > threshold` check already achieves this.
170-
value
171-
}
172-
}
173125
}
174126

175127
/// A shared threshold for `Option<u64>` values.
@@ -215,15 +167,6 @@ impl SharedThreshold<Option<u64>> for RwLockSharedThresholdOptionU64 {
215167
Err(*guard)
216168
}
217169
}
218-
219-
fn competitive_threshold(
220-
&self,
221-
value: Option<u64>,
222-
_threshold_ord: SegmentOrdinal,
223-
_segment_ord: SegmentOrdinal,
224-
) -> Option<u64> {
225-
value
226-
}
227170
}
228171

229172
#[cfg(test)]
@@ -305,34 +248,6 @@ mod tests {
305248
assert_eq!(t.load(), Some((0.9, 10)));
306249
}
307250

308-
#[test]
309-
fn test_competitive_threshold() {
310-
let t = AtomicSharedThreshold::default();
311-
let is_better = |a: &Score, a_ord: SegmentOrdinal, b: &Score, b_ord: SegmentOrdinal| {
312-
if a > b {
313-
true
314-
} else if a == b {
315-
a_ord < b_ord
316-
} else {
317-
false
318-
}
319-
};
320-
update_helper(&t, 0.5, 5, &(), &is_better);
321-
322-
// Segment 5 itself: should require strictly greater score for new docs.
323-
// The pruning loop uses `score > threshold`, so returning 0.5 unchanged
324-
// means we only accept scores > 0.5.
325-
assert_eq!(t.competitive_threshold(0.5, 5, 5), 0.5);
326-
327-
// Segment 6 (later): should require strictly greater score.
328-
assert_eq!(t.competitive_threshold(0.5, 5, 6), 0.5);
329-
330-
// Segment 4 (earlier): can accept equal score (>= 0.5).
331-
// Since pruning loop is `score > threshold`, we must return `next_down(0.5)`
332-
// so that `0.5 > next_down(0.5)` is true.
333-
assert_eq!(t.competitive_threshold(0.5, 5, 4), 0.5f32.next_down());
334-
}
335-
336251
#[test]
337252
fn test_rwlock_shared_threshold_option_u64_asc() {
338253
let t = RwLockSharedThresholdOptionU64::new();

src/collector/sort_key/sort_by_bytes.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ impl SegmentSortKeyComputer for ByBytesColumnSegmentSortKeyComputer {
8888
.ok()?;
8989
Some(bytes)
9090
}
91+
92+
fn supports_bm25_pruning(&self) -> bool {
93+
false
94+
}
95+
fn bm25_pruning_threshold(
96+
&self,
97+
_threshold: &Self::SegmentSortKey,
98+
_segment_ord: crate::SegmentOrdinal,
99+
_threshold_ord: crate::SegmentOrdinal,
100+
) -> Option<crate::Score> {
101+
None
102+
}
91103
}
92104

93105
#[cfg(test)]

src/collector/sort_key/sort_by_erased_type.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,18 @@ impl SegmentSortKeyComputer for ErasedColumnSegmentSortKeyComputer {
246246
fn convert_segment_sort_key(&self, segment_sort_key: Self::SegmentSortKey) -> OwnedValue {
247247
self.inner.convert_segment_sort_key(segment_sort_key)
248248
}
249+
250+
fn supports_bm25_pruning(&self) -> bool {
251+
false
252+
}
253+
fn bm25_pruning_threshold(
254+
&self,
255+
_threshold: &Self::SegmentSortKey,
256+
_segment_ord: crate::SegmentOrdinal,
257+
_threshold_ord: crate::SegmentOrdinal,
258+
) -> Option<crate::Score> {
259+
None
260+
}
249261
}
250262

251263
#[cfg(test)]

src/collector/sort_key/sort_by_score.rs

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::sync::Arc;
22

33
use super::shared_threshold::{AtomicSharedThreshold, SharedThresholdArcOpt};
44
use crate::collector::sort_key::NaturalComparator;
5-
use crate::collector::sort_key_top_collector::TopBySortKeySegmentCollector;
65
use crate::collector::{SegmentSortKeyComputer, SortKeyComputer};
76
use crate::{DocId, Score, SegmentOrdinal};
87

@@ -65,42 +64,6 @@ impl SortKeyComputer for SortBySimilarityScore {
6564
) -> crate::Result<Self::Child> {
6665
Ok(self.clone())
6766
}
68-
69-
fn collect_segment_top_k(
70-
&self,
71-
weight: &dyn crate::query::Weight,
72-
reader: &crate::SegmentReader,
73-
segment_collector: &mut TopBySortKeySegmentCollector<Self::Child, Self::Comparator>,
74-
) -> crate::Result<()> {
75-
let top_n = &mut segment_collector.topn_computer;
76-
77-
let (initial_score, initial_ord) = top_n
78-
.shared_threshold
79-
.as_ref()
80-
.and_then(|s| s.load())
81-
.unwrap_or((Score::MIN, SegmentOrdinal::MAX));
82-
83-
top_n.set_threshold((initial_score, initial_ord));
84-
85-
let pruning_threshold = top_n.pruning_threshold.unwrap_or(Score::MIN);
86-
87-
if let Some(alive_bitset) = reader.alive_bitset() {
88-
weight.for_each_pruning(pruning_threshold, reader, &mut |doc, score| {
89-
if alive_bitset.is_deleted(doc) {
90-
return top_n.pruning_threshold.unwrap_or(Score::MIN);
91-
}
92-
top_n.push(score, doc);
93-
top_n.pruning_threshold.unwrap_or(Score::MIN)
94-
})?;
95-
} else {
96-
weight.for_each_pruning(pruning_threshold, reader, &mut |doc, score| {
97-
top_n.push(score, doc);
98-
top_n.pruning_threshold.unwrap_or(Score::MIN)
99-
})?;
100-
}
101-
102-
Ok(())
103-
}
10467
}
10568

10669
impl SegmentSortKeyComputer for SortBySimilarityScore {
@@ -116,4 +79,21 @@ impl SegmentSortKeyComputer for SortBySimilarityScore {
11679
fn convert_segment_sort_key(&self, score: Score) -> Score {
11780
score
11881
}
82+
83+
fn supports_bm25_pruning(&self) -> bool {
84+
true
85+
}
86+
87+
fn bm25_pruning_threshold(
88+
&self,
89+
threshold: &Score,
90+
segment_ord: SegmentOrdinal,
91+
threshold_ord: SegmentOrdinal,
92+
) -> Option<Score> {
93+
if segment_ord < threshold_ord {
94+
Some(threshold.next_down())
95+
} else {
96+
Some(*threshold)
97+
}
98+
}
11999
}

src/collector/sort_key/sort_by_static_fast_value.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,16 @@ impl<T: FastValue> SegmentSortKeyComputer for SortByFastValueSegmentSortKeyCompu
132132
fn convert_segment_sort_key(&self, sort_key: Self::SegmentSortKey) -> Self::SortKey {
133133
sort_key.map(T::from_u64)
134134
}
135+
136+
fn supports_bm25_pruning(&self) -> bool {
137+
false
138+
}
139+
fn bm25_pruning_threshold(
140+
&self,
141+
_threshold: &Self::SegmentSortKey,
142+
_segment_ord: crate::SegmentOrdinal,
143+
_threshold_ord: crate::SegmentOrdinal,
144+
) -> Option<crate::Score> {
145+
None
146+
}
135147
}

src/collector/sort_key/sort_by_string.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,16 @@ impl SegmentSortKeyComputer for ByStringColumnSegmentSortKeyComputer {
8282
.ok()?;
8383
String::try_from(bytes).ok()
8484
}
85+
86+
fn supports_bm25_pruning(&self) -> bool {
87+
false
88+
}
89+
fn bm25_pruning_threshold(
90+
&self,
91+
_threshold: &Self::SegmentSortKey,
92+
_segment_ord: crate::SegmentOrdinal,
93+
_threshold_ord: crate::SegmentOrdinal,
94+
) -> Option<crate::Score> {
95+
None
96+
}
8597
}

0 commit comments

Comments
 (0)