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}