Skip to main content

ecp_core/search/index/
query.rs

1use std::collections::{BinaryHeap, HashSet};
2use std::sync::atomic::Ordering;
3use std::sync::{Arc, Mutex};
4
5use ndarray::{Array1, Array2};
6use ordered_float::NotNan;
7
8use super::{Index, persistence};
9use crate::error::{EcpError, Result};
10use crate::metric::{Metric, calculate_distances};
11
12/// Items a search found, as `(score, item_id)` pairs.
13pub type ScoredItems = Vec<(NotNan<f32>, u32)>;
14
15/// One open query's search state.
16pub(super) struct QueryState {
17    pub(super) query: Array1<f32>,
18    /// Nodes still to explore, best score first.
19    pub(super) tree_pq: BinaryHeap<HeapEntry>,
20    /// Items found but not yet returned, as `(score, item_id)`, best first.
21    pub(super) items: ScoredItems,
22    /// Item ids left out of every result, the union of each call's `exclude`.
23    pub(super) exclude: HashSet<u32>,
24}
25
26impl QueryState {
27    /// Adds `exclude` to the stored set and removes newly excluded ids from `items`.
28    fn add_exclude(&mut self, exclude: &HashSet<u32>) {
29        let before = self.exclude.len();
30        self.exclude.extend(exclude);
31        if self.exclude.len() > before {
32            let excluded = &self.exclude;
33            self.items.retain(|(_, id)| !excluded.contains(id));
34        }
35    }
36}
37
38/// A node waiting in a search's priority queue, ordered by `score`.
39#[derive(Debug, Clone)]
40pub(super) struct HeapEntry {
41    /// Higher means closer to the query.
42    pub(super) score: NotNan<f32>,
43    /// 1 for a leaf, 0 otherwise.
44    pub(super) is_leaf: i32,
45    /// 0-based; on disk the node is under `lvl_{level + 1}`.
46    pub(super) level: u32,
47    pub(super) node_id: u32,
48}
49
50// Equality and ordering use `score` only.
51impl PartialEq for HeapEntry {
52    fn eq(&self, other: &Self) -> bool {
53        self.score == other.score
54    }
55}
56impl Eq for HeapEntry {}
57
58impl PartialOrd for HeapEntry {
59    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
60        Some(self.cmp(other))
61    }
62}
63impl Ord for HeapEntry {
64    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
65        self.score.cmp(&other.score)
66    }
67}
68
69const QUERY_NEVER_PERSISTED: &str = "query was never persisted";
70
71impl Index {
72    /// Gets the state of query `query_id` from the cache, or from disk on a
73    /// miss. `Ok(None)` if it is in neither.
74    fn get_or_load_query(&self, query_id: usize) -> Result<Option<Arc<Mutex<QueryState>>>> {
75        match self.queries.try_get_with(query_id, || {
76            // moka can only say "here it is" or "something failed," so a
77            // clean "not found" is faked as this one error and unpacked below.
78            persistence::load_query(&self.store, query_id)?
79                .map(|state| Arc::new(Mutex::new(state)))
80                .ok_or_else(|| EcpError::NotFound(QUERY_NEVER_PERSISTED.to_string()))
81        }) {
82            Ok(state_arc) => Ok(Some(state_arc)),
83            Err(e) if matches!(&*e, EcpError::NotFound(msg) if msg == QUERY_NEVER_PERSISTED) => {
84                Ok(None)
85            }
86            Err(e) => Err((*e).clone()),
87        }
88    }
89
90    /// Removes and returns up to `k` of the best items buffered for query
91    /// `query_id`. Empty for an unknown `query_id`.
92    fn drain_items(&self, query_id: usize, k: usize) -> Result<ScoredItems> {
93        match self.get_or_load_query(query_id)? {
94            Some(state_arc) => {
95                let mut state = state_arc.lock().unwrap();
96                let cnt = state.items.len().min(k);
97                Ok(state.items.drain(0..cnt).collect())
98            }
99            None => Ok(Vec::new()),
100        }
101    }
102
103    /// Searches for `query` and returns the `k` best items as `(score, item_id)` pairs, lowest
104    /// score first (L2 distance, or negated inner product for IP), plus the query id. The query
105    /// keeps `exclude` and never returns those ids. Other arguments: [`Self::incremental_search`].
106    pub fn new_search(
107        &self,
108        query: Array1<f32>,
109        k: usize,
110        search_exp: u32,
111        max_increments: i32,
112        exclude: &HashSet<u32>,
113    ) -> Result<(ScoredItems, usize)> {
114        let query_id = self.next_query_id.fetch_add(1, Ordering::Relaxed);
115        if !self.accepting.load(Ordering::SeqCst) {
116            return Ok((Vec::new(), query_id));
117        }
118        log::debug!(
119            "new_search: query_id={query_id} k={k} search_exp={search_exp} \
120             max_increments={max_increments} exclude={exclude:?}"
121        );
122        self.queries.insert(
123            query_id,
124            Arc::new(Mutex::new(QueryState {
125                query,
126                tree_pq: BinaryHeap::new(),
127                items: Vec::new(),
128                exclude: exclude.clone(),
129            })),
130        );
131        let leaf_nodes_scanned =
132            self.incremental_search(query_id, k, search_exp, max_increments)?;
133        let items = self.drain_items(query_id, k)?;
134        log::info!(
135            "search: query_id={query_id} k={k} leaf_nodes_scanned={leaf_nodes_scanned} items_returned={}",
136            items.len()
137        );
138        Ok((items, query_id))
139    }
140
141    /// Scores `search_exp` more leaves for query `query_id`, buffering their items
142    /// except the query's excluded ids. If fewer than `k` items are buffered by then, it
143    /// doubles `search_exp` and goes on, at most `max_increments` times (`-1` for no limit).
144    /// Returns how many leaves this call visited.
145    pub fn incremental_search(
146        &self,
147        query_id: usize,
148        k: usize,
149        search_exp: u32,
150        max_increments: i32,
151    ) -> Result<u32> {
152        // No-op after shutdown or for an unknown query_id
153        if !self.accepting.load(Ordering::SeqCst) {
154            return Ok(0);
155        }
156        let Some(state_arc) = self.get_or_load_query(query_id)? else {
157            return Ok(0);
158        };
159        let mut leaf_cnt = 0;
160        {
161            let mut state = state_arc.lock().unwrap();
162            let QueryState {
163                query,
164                tree_pq,
165                items,
166                exclude,
167            } = &mut *state;
168
169            // BinaryHeap pops the largest score first, so negate L2's distance
170            // to make the nearest node the largest.
171            let sign = match self.metric {
172                Metric::L2 => -1.0,
173                Metric::IP => 1.0,
174            };
175            let mut search_exp = search_exp;
176
177            let mut increments = 0;
178
179            // On the first call, queue every root entry
180            if tree_pq.is_empty() {
181                let root_distances: Array1<f32> =
182                    calculate_distances(&self.root, query, &self.metric, self.is_normalized)?;
183                // In a 1-level index, root's entries point straight at leaves.
184                let is_root_leaf = self.levels == 1;
185                for i in 0..root_distances.len() {
186                    tree_pq.push(HeapEntry {
187                        score: NotNan::new(sign * root_distances[i]).unwrap(),
188                        is_leaf: is_root_leaf as i32,
189                        level: 0,
190                        node_id: i as u32,
191                    });
192                }
193            }
194
195            while !tree_pq.is_empty() {
196                let HeapEntry {
197                    score: _,
198                    is_leaf,
199                    level,
200                    node_id,
201                } = tree_pq.pop().unwrap();
202                let lvl = level as usize;
203                log::trace!("visiting node lvl={lvl} node={node_id} is_leaf={is_leaf}");
204                let node = self.node_at(lvl, node_id);
205                // Skip an id that was never written to disk
206                let embeddings_f32: &Array2<f32> = match node.embeddings()? {
207                    Some(embs) => embs,
208                    None => {
209                        log::warn!("lvl={lvl} node={node_id} has no embeddings on disk, skipping");
210                        continue;
211                    }
212                };
213
214                let distances: Array1<f32> =
215                    calculate_distances(embeddings_f32, query, &self.metric, self.is_normalized)?;
216                let children = node.children()?.ok_or_else(|| {
217                    EcpError::Corrupt(format!(
218                        "{} has embeddings but no children, likely an interrupted write",
219                        node.group_path
220                    ))
221                })?;
222                if is_leaf == 1 {
223                    // For a leaf, collect its items. items sorts ascending, so flip
224                    // the heap's score back to lower-is-better.
225                    let before = items.len();
226                    for i in 0..distances.len() {
227                        if !exclude.contains(&children[i]) {
228                            items.push((NotNan::new(-sign * distances[i]).unwrap(), children[i]));
229                        }
230                    }
231                    log::trace!(
232                        "lvl={lvl} node={node_id}: collected {} of {} items",
233                        items.len() - before,
234                        distances.len()
235                    );
236                    leaf_cnt += 1;
237                } else {
238                    // For an internal node, queue its children
239                    let children_are_leaves = level + 1 == self.levels - 1;
240                    for i in 0..distances.len() {
241                        tree_pq.push(HeapEntry {
242                            score: NotNan::new(sign * distances[i]).unwrap(),
243                            is_leaf: children_are_leaves as i32,
244                            level: level + 1,
245                            node_id: children[i],
246                        });
247                    }
248                }
249
250                // Read the node's arrays into cache
251                if self.memory_limit_bytes.is_some() {
252                    self.nodes.insert((lvl, node_id), node.clone());
253                }
254
255                // After search_exp leaves, stop or double search_exp
256                if leaf_cnt == search_exp {
257                    if items.len() >= k {
258                        break;
259                    }
260                    if increments < max_increments || max_increments == -1 {
261                        increments += 1;
262                        search_exp *= 2;
263                        log::debug!(
264                            "query_id={query_id}: only {} of {k} items after {leaf_cnt} leaves, \
265                             doubling search_exp to {search_exp}",
266                            items.len()
267                        );
268                    } else {
269                        break;
270                    }
271                }
272            }
273
274            // Sort once, whichever way the loop ended
275            items.sort_unstable_by_key(|&(first, _)| first);
276        }
277
278        // Read the query's updated state into cache
279        if self.memory_limit_bytes.is_some() {
280            self.queries.insert(query_id, state_arc);
281        }
282        Ok(leaf_cnt)
283    }
284
285    /// Adds `exclude` to query `query_id`'s exclude set, then returns its next `k` items,
286    /// searching further first if fewer are buffered; empty for an unknown `query_id` or after
287    /// `shutdown`. Other arguments: [`Self::incremental_search`].
288    pub fn get_next_k_items(
289        &self,
290        query_id: usize,
291        k: usize,
292        search_exp: u32,
293        max_increments: i32,
294        exclude: &HashSet<u32>,
295    ) -> Result<ScoredItems> {
296        if !self.accepting.load(Ordering::SeqCst) {
297            return Ok(Vec::new());
298        }
299        let Some(state_arc) = self.get_or_load_query(query_id)? else {
300            log::warn!(
301                "get_next_k_items: query_id={query_id} not found (evicted, invalid, \
302                 or never persisted), returning no items"
303            );
304            return Ok(Vec::new());
305        };
306        let needs_more_search = {
307            let mut state = state_arc.lock().unwrap();
308            state.add_exclude(exclude);
309            log::debug!(
310                "get_next_k_items: query_id={query_id} k={k} search_exp={search_exp} \
311                 max_increments={max_increments} exclude={exclude:?}"
312            );
313            state.items.len() < k && !state.tree_pq.is_empty()
314        };
315        let leaf_nodes_scanned = if needs_more_search {
316            self.incremental_search(query_id, k, search_exp, max_increments)?
317        } else {
318            0
319        };
320        let items = self.drain_items(query_id, k)?;
321        log::info!(
322            "search: query_id={query_id} k={k} leaf_nodes_scanned={leaf_nodes_scanned} items_returned={}",
323            items.len()
324        );
325        Ok(items)
326    }
327}
328
329#[cfg(test)]
330#[path = "utests/query.rs"]
331mod tests;