Skip to main content

sonic/executor/
search.rs

1// Sonic
2//
3// Fast, lightweight and schema-less search backend
4// Copyright: 2019, Valerian Saliou <valerian@valeriansaliou.name>
5// Copyright: 2026, Rémi Bardon <remi@remibardon.name>
6// License: Mozilla Public License v2.0 (MPL v2.0)
7
8use indexmap::IndexMap;
9use std::collections::BTreeMap;
10
11use crate::lexer::TokenLexer;
12use crate::query::{QueryMatchScore, QuerySearchID, QuerySearchLimit, QuerySearchOffset};
13use crate::store::StoreItem;
14use crate::store::fst::{StoreFSTActionBuilder, typo_factor};
15use crate::store::identifiers::{StoreObjectIID, StoreTermHash, StoreTermHashed};
16use crate::store::kv::{StoreKVAcquireMode, StoreKVAction, StoreKVActionBuilder};
17
18const MISSING_MATCH_SCORE: u16 = 100;
19
20impl super::Executor {
21    pub fn search(
22        &self,
23        item: StoreItem,
24        _event_id: QuerySearchID,
25        lexer: TokenLexer,
26        limit: QuerySearchLimit,
27        offset: QuerySearchOffset,
28    ) -> Result<Vec<String>, ()> {
29        if let StoreItem(collection, Some(bucket), None) = item {
30            // Important: acquire database access read lock, and reference it in context. This \
31            //   prevents the database from being erased while using it in this block.
32            let _kv_read_guard = self.kv_pool.lock_read_access();
33            let _fst_read_guard = self.fst_pool.lock_read_access();
34
35            let (Ok(kv_store), Ok(fst_store)) = (
36                self.kv_pool
37                    .acquire(StoreKVAcquireMode::OpenOnly, collection),
38                self.fst_pool.acquire(collection, bucket),
39            ) else {
40                return Err(());
41            };
42
43            let (higher_limit, mut alternates_try) = (
44                self.app_conf.store.kv.retain_word_objects,
45                self.app_conf.search.query_alternates_try,
46            );
47
48            let (prefix_matching_enabled, fuzzy_matching_enabled) = (
49                self.fst_pool.fst_action_config.prefix_matching_enabled,
50                self.fst_pool.fst_action_config.fuzzy_matching_enabled,
51            );
52
53            // Important: acquire bucket store read lock
54            executor_kv_lock_read!(kv_store);
55
56            let (kv_action, fst_action) = (
57                StoreKVActionBuilder::access(bucket, kv_store),
58                StoreFSTActionBuilder::access(fst_store),
59            );
60
61            // Collect all terms so we know the count right ahead.
62            // PERF: This helps allocating the correct amounts of memory.
63            let terms: Vec<(String, StoreTermHashed, usize)> = lexer.collect();
64            let term_count = terms.len();
65
66            // Store scores for each found IID. Results will then be sorted by
67            // score before being returned. Scores are basically the sum of
68            // Levenshtein distances for each term in the query. Lower score
69            // means better result.
70            // NOTE: We use `IndexMap` instead of `HashMap` to preserve
71            //   insertion order, which correlates to reverse data ingestion
72            //   order.
73            // NOTE: `capacity = 24` to reduce initial grows.
74            let mut scoring_matrix: IndexMap<StoreObjectIID, Vec<QueryMatchScore>> =
75                IndexMap::with_capacity(24usize.min(usize::from(limit)));
76
77            // Look for exact matches.
78            'matches: for (idx, (term, term_hash, _)) in terms.iter().enumerate() {
79                let iids = kv_action
80                    .get_term_to_iids(*term_hash)
81                    .unwrap_or(None)
82                    .unwrap_or_default();
83
84                tracing::debug!("got exact search executor iids: {iids:?} for term: {term:?}");
85
86                for iid in iids.into_iter() {
87                    // Assign a score of `0` as those are exact matches.
88                    let inserted = update_score(&mut scoring_matrix, iid, 0, idx, term_count);
89
90                    if inserted {
91                        // Higher limit now reached?
92                        // Stop acquiring new suggested IIDs now.
93                        if scoring_matrix.len() >= higher_limit {
94                            tracing::trace!(?term, "got enough completed results for term");
95
96                            break 'matches;
97                        }
98                    }
99                }
100            }
101
102            #[cfg(debug_assertions)]
103            tracing::debug!(?scoring_matrix);
104
105            // Look for words containing `term` as prefix.
106            if scoring_matrix.len() < higher_limit && alternates_try > 0 && prefix_matching_enabled
107            {
108                tracing::debug!(
109                    "not enough iids were found ({}/{higher_limit}), looking for prefixes",
110                    scoring_matrix.len(),
111                );
112
113                'terms: for (idx, (term, _, original_len)) in terms.iter().enumerate() {
114                    // Skip term if it’s an ID (we want exact matches only).
115                    // FIXME: We’d like to enable prefix matching for IDs, but
116                    //   the way the tokenizer works causes false positives.
117                    if is_considered_id(&term) {
118                        tracing::debug!(
119                            "skipping prefix search for {term:?}: term is considered an ID"
120                        );
121                        continue 'terms;
122                    };
123
124                    let Some(suggestions) = fst_action.lookup_begins(term, *original_len) else {
125                        tracing::trace!("did not get any completed word for term {term:?}");
126                        continue 'terms;
127                    };
128
129                    merge_suggestions(
130                        suggestions,
131                        &mut scoring_matrix,
132                        term,
133                        idx,
134                        term_count,
135                        &kv_action,
136                        &mut alternates_try,
137                        higher_limit,
138                    );
139                }
140            }
141
142            #[cfg(debug_assertions)]
143            tracing::debug!(?scoring_matrix);
144
145            // Look for words like `term` (fuzzy matching).
146            if scoring_matrix.len() < higher_limit && alternates_try > 0 && fuzzy_matching_enabled {
147                tracing::debug!(
148                    "not enough iids were found ({}/{higher_limit}), looking for fuzzy matches",
149                    scoring_matrix.len(),
150                );
151
152                'terms: for (idx, (term, _, original_word_len)) in terms.iter().enumerate() {
153                    // Skip term if it’s an ID (we want exact matches only).
154                    if is_considered_id(&term) {
155                        tracing::debug!(
156                            "skipping fuzzy matching for {term:?}: term is considered an ID"
157                        );
158                        continue 'terms;
159                    };
160
161                    let max_typo_factor = typo_factor(*original_word_len);
162                    let mut typo_factor = 1u32;
163
164                    // TODO: Rework the Levenshtein query feature to avoid repeating
165                    //   the same query over and over again. Maybe try to see if
166                    //   `fst_levenshtein` can return distances in its response.
167                    while alternates_try > 0 && typo_factor <= max_typo_factor {
168                        let Some(suggestions) = fst_action.lookup_typos(term, typo_factor) else {
169                            tracing::trace!("did not get any completed word for term {term:?}");
170                            continue 'terms;
171                        };
172
173                        merge_suggestions(
174                            suggestions,
175                            &mut scoring_matrix,
176                            term,
177                            idx,
178                            term_count,
179                            &kv_action,
180                            &mut alternates_try,
181                            higher_limit,
182                        );
183
184                        typo_factor += 1;
185                    }
186                }
187            }
188
189            #[cfg(debug_assertions)]
190            tracing::debug!(?scoring_matrix);
191
192            // Switch to implicit `AND` if query contains an ID.
193            // NOTE: When a user queries for an ID (e.g. UUID), they expect only
194            //   exact matches to be returned. If one term is considered an ID,
195            //   we drop all results missing at least one term. It’s not the
196            //   most efficient (compared to not storing the result in the first
197            //   place) but it’s an edge case and the cost is negligible.
198            let one_term_is_an_id = terms.iter().any(|(term, _, _)| is_considered_id(term));
199            if one_term_is_an_id {
200                let mut to_remove = Vec::<StoreObjectIID>::new();
201
202                for (&iid, scores) in scoring_matrix.iter() {
203                    if scores.contains(&MISSING_MATCH_SCORE) {
204                        to_remove.push(iid);
205                    }
206                }
207
208                for iid in to_remove {
209                    scoring_matrix.swap_remove(&iid);
210                }
211            }
212
213            // Flatten scores, taking into account missing matches (thanks to
214            // `MISSING_MATCH_SCORE`).
215            let found_iids = scoring_matrix
216                .into_iter()
217                .map(|(iid, scores)| (iid, scores.into_iter().sum()));
218
219            // Sort found IIDs, then flatten.
220            let all_iids = sorted_groups(found_iids).flat_map(|(_, v)| v);
221
222            // Resolve OIDs from IIDs
223            // Notice: we also proceed paging from there
224            let (limit_usize, offset_usize) = (limit as usize, offset as usize);
225            let mut result_oids = Vec::with_capacity(limit_usize);
226
227            'paging: for (index, found_iid) in all_iids.skip(offset_usize).enumerate() {
228                // Stop there?
229                if index >= limit_usize {
230                    break 'paging;
231                }
232
233                // Read IID-to-OID for this found IID
234                if let Ok(Some(oid)) = kv_action.get_iid_to_oid(found_iid) {
235                    result_oids.push(oid);
236                } else {
237                    tracing::error!("failed getting search executor iid-to-oid");
238                }
239            }
240
241            tracing::info!("got search executor final oids: {:?}", result_oids);
242
243            return Ok(result_oids);
244        }
245
246        Err(())
247    }
248}
249
250fn is_considered_id(s: &str) -> bool {
251    s.chars().any(|c| c.is_ascii_digit() || c == '@')
252        || s.chars()
253            // Skip first char.
254            .skip(1)
255            // Skip last char.
256            .take(s.len().saturating_sub(2))
257            .any(is_non_prose_char)
258}
259
260fn is_non_prose_char(c: char) -> bool {
261    c == '.' || c == '_' || c == ':' || c == '\\' || c == '+' || c == '=' || c == '&'
262}
263
264/// Note that the tokenizer might split the tested strings in ways that make
265/// Sonic as a whole behave differently! This is just a simple unit test.
266#[cfg(test)]
267#[test]
268fn test_is_considered_id() {
269    // Sanity check.
270    assert!(!is_considered_id("Hello"));
271
272    // Phone number like.
273    assert!(is_considered_id("1234-567890-12"));
274    assert!(is_considered_id("0123456789"));
275
276    // UUID like.
277    assert!(is_considered_id("6db14cb4-b82e-4e49-8016-ef76c4290a2f"));
278
279    // Hash like.
280    assert!(is_considered_id("b244423d417369795292e9f4530d0c0e6fa07625"));
281    assert!(is_considered_id("b244423"));
282
283    // Code like.
284    assert!(is_considered_id("is_considered_id"));
285
286    // Domain name.
287    assert!(is_considered_id("example.org"));
288    assert!(!is_considered_id("endofsentence."));
289    assert!(!is_considered_id(".startofsentence"));
290
291    // URL.
292    assert!(is_considered_id("https://example.org/foo?id=123"));
293
294    // Email address.
295    assert!(is_considered_id("alice@example.org"));
296    assert!(is_considered_id("alice+foo@example.org"));
297
298    // IP addresses.
299    assert!(is_considered_id("192.168.1.0"));
300    assert!(is_considered_id("0.0.0.0"));
301    assert!(is_considered_id("2606:4700::6812:1c68"));
302    assert!(is_considered_id("::1"));
303    assert!(!is_considered_id("example:"));
304
305    // Username.
306    assert!(is_considered_id("@example"));
307}
308
309#[allow(clippy::too_many_arguments)] // We’ll refactor this someday, and it’ not public anyway.
310fn merge_suggestions(
311    suggestions: impl Iterator<Item = (String, QueryMatchScore)>,
312    scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
313    term: &String,
314    term_idx: usize,
315    term_count: usize,
316    kv_action: &StoreKVAction<'_>,
317    alternates_try: &mut usize,
318    higher_limit: usize,
319) {
320    'suggestions: for (suggested_word, suggestion_score) in suggestions {
321        // Do not load base results twice for same term as base term
322        if suggested_word.eq(term) {
323            continue;
324        }
325
326        tracing::trace!(?term, ?suggested_word, "got completed word for term");
327
328        let suggested_term_hash = StoreTermHash::from(&suggested_word);
329        let suggested_iids = match kv_action.get_term_to_iids(suggested_term_hash) {
330            Ok(Some(suggested_iids)) => suggested_iids,
331            Ok(None) => continue,
332            Err(_) => continue,
333        };
334
335        for suggested_iid in suggested_iids.into_iter().take(*alternates_try) {
336            // SAFETY: We can reach at most `alternates_try`.
337            *alternates_try = unsafe { alternates_try.unchecked_sub(1) };
338
339            let inserted = update_score(
340                scoring_matrix,
341                suggested_iid,
342                suggestion_score,
343                term_idx,
344                term_count,
345            );
346
347            if inserted {
348                // Higher limit now reached?
349                // Stop acquiring new suggested IIDs now.
350                if scoring_matrix.len() >= higher_limit {
351                    tracing::trace!(?term, "got enough completed results for term");
352
353                    break 'suggestions;
354                }
355            }
356        }
357    }
358
359    tracing::trace!(
360        ?term,
361        "done completing results for term, now {} total results",
362        scoring_matrix.len()
363    );
364}
365
366fn update_score(
367    scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
368    iid: StoreObjectIID,
369    score: QueryMatchScore,
370    term_idx: usize,
371    term_count: usize,
372) -> bool {
373    match scoring_matrix.entry(iid) {
374        // If entry already exists, use lowest score.
375        indexmap::map::Entry::Occupied(mut occupied_entry) => {
376            // SAFETY: We always initialize vecs with `term_count` entries.
377            let entry_score = unsafe { occupied_entry.get_mut().get_unchecked_mut(term_idx) };
378
379            let new_score = score.min(*entry_score);
380
381            tracing::trace!(entry_score, new_score, "Updating to min score");
382            *entry_score = new_score;
383
384            false
385        }
386        // If entry does not exist, insert score.
387        indexmap::map::Entry::Vacant(vacant_entry) => {
388            let mut scores = vec![MISSING_MATCH_SCORE; term_count];
389
390            tracing::trace!(new_score = score, "Inserting new score");
391            // SAFETY: `scores` has `term_count` elements.
392            unsafe { *scores.get_unchecked_mut(term_idx) = score };
393
394            vacant_entry.insert(scores);
395
396            true
397        }
398    }
399}
400
401fn sorted_groups(
402    map: impl ExactSizeIterator<Item = (StoreObjectIID, QueryMatchScore)>,
403) -> impl ExactSizeIterator<Item = (QueryMatchScore, Vec<StoreObjectIID>)> {
404    let mut btree: BTreeMap<QueryMatchScore, Vec<StoreObjectIID>> = BTreeMap::new();
405
406    for (k, v) in map.into_iter() {
407        btree.entry(v).or_default().push(k);
408    }
409
410    btree.into_iter()
411}