Skip to main content

sparse_vector/wand/
search.rs

1//! The search loop: window-batched scoring with WAND pruning.
2
3use super::cursor::PostingCursor;
4use super::frontier::{Frontier, Lane, Skip};
5use super::sink::{ScoreSink, TopKSink};
6use super::{DimId, RecordId, Weight};
7
8/// Largest accepted [`SearchOptions::window`]: bounds the scratch buffers
9/// (4 M slots, 20 MB) whatever the caller asks for.
10pub const MAX_WINDOW: u64 = 1 << 22;
11
12/// Tunables of the search loop.
13#[derive(Clone, Copy, Debug)]
14pub struct SearchOptions {
15    /// Skip id ranges that cannot reach the top-k. Disabling it scores
16    /// every record present in any query lane; results are identical.
17    pub pruning: bool,
18    /// Width, in record ids, of one scoring window (clamped to
19    /// `1..=MAX_WINDOW`). Each window costs a pass over the scores buffer,
20    /// so it should stay small when ids are sparse and large when they are
21    /// dense. The default of 4096 suits dense, contiguous ids.
22    pub window: u64,
23}
24
25impl Default for SearchOptions {
26    fn default() -> Self {
27        Self {
28            pruning: true,
29            window: 4096,
30        }
31    }
32}
33
34impl SearchOptions {
35    pub fn exhaustive() -> Self {
36        Self {
37            pruning: false,
38            ..Self::default()
39        }
40    }
41}
42
43/// Reusable buffers for window scoring. Keep one per thread and pass it to
44/// [`search_with`] to avoid allocating on every query.
45#[derive(Debug, Default)]
46pub struct Scratch {
47    scores: Vec<f32>,
48    seen: Vec<bool>,
49    lanes: Vec<(DimId, Weight)>,
50}
51
52impl Scratch {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    fn prepare(&mut self, len: usize) {
58        self.scores.clear();
59        self.scores.resize(len, 0.0);
60        self.seen.clear();
61        self.seen.resize(len, false);
62    }
63
64    /// Fold the query into one `(dimension, weight)` per dimension: repeated
65    /// dimensions have their weights summed (a query is a sparse vector, and
66    /// a sparse vector has one coordinate per dimension), and dimensions
67    /// whose weight is zero are dropped since they cannot change a score.
68    fn merge_query(&mut self, query: &[(DimId, Weight)]) -> &[(DimId, Weight)] {
69        self.lanes.clear();
70        self.lanes.extend_from_slice(query);
71        self.lanes.sort_by_key(|&(dim, _)| dim);
72        self.lanes.dedup_by(|later, earlier| {
73            if later.0 == earlier.0 {
74                earlier.1 += later.1;
75                true
76            } else {
77                false
78            }
79        });
80        self.lanes.retain(|&(_, w)| w != 0.0);
81        &self.lanes
82    }
83}
84
85/// Top-k search by dot product.
86///
87/// `query` is a list of `(dimension, weight)`; `cursors` resolves a
88/// dimension to a cursor over its posting list (`None` when the dimension is
89/// unknown or empty). Records rejected by `filter` are never returned. The
90/// result holds at most `top_k` `(id, score)` pairs, score descending then
91/// id ascending, where the score is the exact f32 sum over query dimensions
92/// of `query_weight * record_weight`, accumulated in dimension order.
93///
94/// A dimension listed several times in the query counts once, with the sum
95/// of its weights (see [`search_with`]).
96pub fn search<C, F, R>(
97    query: &[(DimId, Weight)],
98    top_k: usize,
99    filter: F,
100    cursors: R,
101) -> Vec<(RecordId, f32)>
102where
103    C: PostingCursor,
104    F: Fn(RecordId) -> bool,
105    R: FnMut(DimId) -> Option<C>,
106{
107    let mut scratch = Scratch::new();
108    search_with(
109        query,
110        filter,
111        cursors,
112        TopKSink::new(top_k),
113        SearchOptions::default(),
114        &mut scratch,
115    )
116}
117
118/// Score only `ids` — sorted ascending, without duplicates — by seeking
119/// every lane to each of them, and offer the ones present in at least one
120/// lane to the sink. Cost O(|ids| × lanes × log): the path for a caller who
121/// already knows the candidates (a database pre-filter) when they are few
122/// against the postings a window search would walk. Scores are the same
123/// f32 sums as [`search_with`]: contributions added lane by lane in query
124/// order.
125pub fn search_ids<C, R, S>(
126    query: &[(DimId, Weight)],
127    ids: &[RecordId],
128    mut cursors: R,
129    mut sink: S,
130    scratch: &mut Scratch,
131) -> Vec<(RecordId, f32)>
132where
133    C: PostingCursor,
134    R: FnMut(DimId) -> Option<C>,
135    S: ScoreSink,
136{
137    debug_assert!(ids.windows(2).all(|w| w[0] < w[1]), "ids must be sorted and unique");
138    let mut lanes: Vec<(Weight, C)> = scratch
139        .merge_query(query)
140        .iter()
141        .filter_map(|&(dim, w)| cursors(dim).map(|c| (w, c)))
142        .collect();
143    for &id in ids {
144        let mut score = 0.0f32;
145        let mut seen = false;
146        for (w, cursor) in lanes.iter_mut() {
147            if let Some(p) = cursor.seek(id) {
148                if p.id == id {
149                    score += *w * p.weight;
150                    seen = true;
151                }
152            }
153        }
154        if seen {
155            sink.offer(id, score);
156        }
157    }
158    sink.into_results()
159}
160
161/// [`search`] with an explicit sink, options and scratch buffers.
162///
163/// Before any lane is built the query is normalised: duplicated dimensions
164/// are merged by summing their weights and zero weights are dropped, so
165/// `[(3, 1.0), (3, 0.5)]` scores exactly like `[(3, 1.5)]`.
166///
167/// Per window, the loop scores every record present in a lane, then offers
168/// to the sink only the records whose score strictly exceeds the sink's
169/// current threshold — the threshold only rises, so a record that cannot
170/// beat it now never could — and only those go through `filter`. Pruning
171/// (a sort of the lanes) is attempted once per distinct threshold value: a
172/// window that did not move the threshold does not pay for it.
173pub fn search_with<C, F, R, S>(
174    query: &[(DimId, Weight)],
175    filter: F,
176    mut cursors: R,
177    mut sink: S,
178    options: SearchOptions,
179    scratch: &mut Scratch,
180) -> Vec<(RecordId, f32)>
181where
182    C: PostingCursor,
183    F: Fn(RecordId) -> bool,
184    R: FnMut(DimId) -> Option<C>,
185    S: ScoreSink,
186{
187    let lanes = scratch
188        .merge_query(query)
189        .iter()
190        .filter_map(|&(dim, w)| cursors(dim).map(|c| Lane::new(w, c)));
191    let mut frontier = Frontier::new(lanes);
192    let window = options.window.clamp(1, MAX_WINDOW);
193    // Threshold the frontier was last pruned against.
194    let mut pruned_at: Option<f32> = None;
195
196    loop {
197        frontier.retire_exhausted();
198        if frontier.is_empty() {
199            break;
200        }
201
202        if options.pruning {
203            if let Some(threshold) = sink.threshold() {
204                if pruned_at != Some(threshold) {
205                    pruned_at = Some(threshold);
206                    if frontier.skip_below(threshold) == Skip::Nothing {
207                        break;
208                    }
209                }
210            }
211        }
212
213        let Some(lo) = frontier.min_id() else {
214            break;
215        };
216        // No lane holds an id beyond its last one, so the window never
217        // needs to reach further than that.
218        let last = frontier.max_last_id().unwrap_or(lo).max(lo);
219        let hi = lo.saturating_add(window - 1).min(last);
220        let len = (hi - lo) as usize + 1;
221        scratch.prepare(len);
222        frontier.score_window(lo, hi, &mut scratch.scores, &mut scratch.seen);
223
224        // Records must strictly beat the threshold; `NEG_INFINITY` while the
225        // sink still welcomes everything.
226        let mut floor = sink.threshold().unwrap_or(f32::NEG_INFINITY);
227        for slot in 0..len {
228            if !scratch.seen[slot] {
229                continue;
230            }
231            let score = scratch.scores[slot];
232            if score <= floor {
233                continue;
234            }
235            let id = lo + slot as RecordId;
236            if filter(id) {
237                sink.offer(id, score);
238                floor = sink.threshold().unwrap_or(f32::NEG_INFINITY);
239            }
240        }
241    }
242
243    sink.into_results()
244}