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;
9
10use crate::lexer::{NormalizedToken, TokenLexer};
11use crate::query::{
12    QueryMatchScore, QueryResultScore, QuerySearchID, QuerySearchLimit, QuerySearchOffset,
13};
14use crate::store::StoreItem;
15use crate::store::fst::{StoreFSTActionBuilder, typo_factor};
16use crate::store::identifiers::{
17    StoreMetaKey, StoreMetaValue, StoreObjectIID, StoreTermHash, StoreTermHashed,
18};
19use crate::store::kv::{StoreKVAcquireMode, StoreKVAction, StoreKVActionBuilder};
20
21impl super::Executor {
22    pub fn search(
23        &self,
24        item: StoreItem,
25        _event_id: QuerySearchID,
26        lexer: TokenLexer,
27        limit: QuerySearchLimit,
28        offset: QuerySearchOffset,
29    ) -> Result<Vec<String>, ()> {
30        if let StoreItem(collection, Some(bucket), None) = item {
31            // Important: acquire database access read lock, and reference it in context. This \
32            //   prevents the database from being erased while using it in this block.
33            let _kv_read_guard = self.kv_pool.lock_read_access();
34            let _fst_read_guard = self.fst_pool.lock_read_access();
35
36            let (Ok(kv_store), Ok(fst_store)) = (
37                self.kv_pool
38                    .acquire(StoreKVAcquireMode::OpenOnly, collection),
39                self.fst_pool.acquire(collection, bucket),
40            ) else {
41                return Err(());
42            };
43
44            let (higher_limit, mut alternates_try) = (
45                self.app_conf.store.kv.retain_word_objects,
46                self.app_conf.search.query_alternates_try,
47            );
48
49            let (mut minimum_idf, idf_min_doc_count) = (
50                self.app_conf.search.query_minimum_term_idf_default,
51                (self.app_conf.search).query_minimum_term_idf_minimum_object_count,
52            );
53
54            let (prefix_matching_enabled, fuzzy_matching_enabled) = (
55                self.fst_pool.fst_action_config.prefix_matching_enabled,
56                self.fst_pool.fst_action_config.fuzzy_matching_enabled,
57            );
58
59            // Important: acquire bucket store read lock
60            executor_kv_lock_read!(kv_store);
61
62            let (kv_action, fst_action) = (
63                StoreKVActionBuilder::access(bucket, kv_store),
64                StoreFSTActionBuilder::access(fst_store),
65            );
66
67            let document_count = match kv_action.get_meta_to_value(StoreMetaKey::IIDIncr)? {
68                Some(StoreMetaValue::IIDIncr(last_iid)) => u64::from(last_iid) + 1,
69                None => 0,
70            };
71
72            if document_count < idf_min_doc_count {
73                tracing::debug!(
74                    "ignoring minimum_term_idf ({minimum_idf}) as document_count is too low ({document_count}<{idf_min_doc_count})"
75                );
76                minimum_idf = 0.;
77            }
78
79            // Collect all terms so we know the count right ahead.
80            // PERF: This helps allocating the correct amounts of memory.
81            let tokens: Vec<(NormalizedToken, StoreTermHashed, usize)> = lexer.collect();
82            let term_count = tokens.len();
83
84            // Store scores for each found IID. Results will then be sorted by
85            // score before being returned. Scores are basically the sum of
86            // Levenshtein distances for each term in the query. Lower score
87            // means better result.
88            // NOTE: We use `IndexMap` instead of `HashMap` to preserve
89            //   insertion order, which correlates to reverse data ingestion
90            //   order.
91            // NOTE: `capacity = 24` to reduce initial grows.
92            let mut scoring_matrix: IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>> =
93                IndexMap::with_capacity(24usize.min(usize::from(limit)));
94
95            // Look for exact matches.
96            'matches: for (idx, (token, term_hash, _)) in tokens.iter().enumerate() {
97                let mut iids = kv_action
98                    .get_term_to_iids(*term_hash)
99                    .unwrap_or(None)
100                    .unwrap_or_default();
101
102                // Look for exact matches normalized differently if the Sonic
103                // index isn’t normalized.
104                if !token.is_special()
105                    && self.app_conf.normalization.unicode_normalization.is_none()
106                {
107                    use unicode_normalization::UnicodeNormalization as _;
108
109                    let mut nfc = kv_action
110                        .get_term_to_iids(StoreTermHash::from(
111                            token.as_str().nfc().to_string().as_str(),
112                        ))
113                        .unwrap_or(None)
114                        .unwrap_or_default();
115                    iids.append(&mut nfc);
116
117                    let mut nfd = kv_action
118                        .get_term_to_iids(StoreTermHash::from(
119                            token.as_str().nfd().to_string().as_str(),
120                        ))
121                        .unwrap_or(None)
122                        .unwrap_or_default();
123                    iids.append(&mut nfd);
124                };
125
126                tracing::debug!("got exact search executor iids: {iids:?} for term: {token:?}");
127
128                let document_frequency = document_frequency(*term_hash, &kv_action);
129
130                // Filter out minimum IDF.
131                // PERF: Filtering `minimum_idf > 0` to save some computation.
132                if minimum_idf > 0. {
133                    let idf = (document_count as f32 / document_frequency as f32).ln();
134                    if idf < minimum_idf {
135                        tracing::debug!(
136                            "skipping term {token:?} because idf too low ({idf}<{minimum_idf})"
137                        );
138                        continue;
139                    }
140                }
141
142                let bm25_score = bm25_lite_idf(document_count, document_frequency);
143
144                for iid in iids.into_iter() {
145                    // Assign a base score of `1` as those are exact matches.
146                    let inserted =
147                        update_score(&mut scoring_matrix, iid, 1. * bm25_score, idx, term_count);
148
149                    if inserted {
150                        // Higher limit now reached?
151                        // Stop acquiring new suggested IIDs now.
152                        if scoring_matrix.len() >= higher_limit {
153                            tracing::trace!(?token, "got enough completed results for term");
154
155                            break 'matches;
156                        }
157                    }
158                }
159            }
160
161            #[cfg(debug_assertions)]
162            tracing::debug!(?scoring_matrix);
163
164            // Look for words containing `term` as prefix.
165            if scoring_matrix.len() < higher_limit && alternates_try > 0 && prefix_matching_enabled
166            {
167                tracing::debug!(
168                    "not enough iids were found ({}/{higher_limit}), looking for prefixes",
169                    scoring_matrix.len(),
170                );
171
172                'terms: for (idx, (token, _, original_len)) in tokens.iter().enumerate() {
173                    let Some(suggestions) = fst_action.lookup_begins(token, *original_len) else {
174                        tracing::trace!("did not get any completed word for term {token:?}");
175                        continue 'terms;
176                    };
177
178                    merge_suggestions(
179                        suggestions.map(|(w, distance)| (w, prefix_score(distance, *original_len))),
180                        &mut scoring_matrix,
181                        token,
182                        idx,
183                        term_count,
184                        &kv_action,
185                        &mut alternates_try,
186                        higher_limit,
187                        document_count,
188                        minimum_idf,
189                    );
190                }
191            }
192
193            #[cfg(debug_assertions)]
194            tracing::debug!(?scoring_matrix);
195
196            // Look for words like `term` (fuzzy matching).
197            if scoring_matrix.len() < higher_limit && alternates_try > 0 && fuzzy_matching_enabled {
198                tracing::debug!(
199                    "not enough iids were found ({}/{higher_limit}), looking for fuzzy matches",
200                    scoring_matrix.len(),
201                );
202
203                'terms: for (idx, (token, _, original_word_len)) in tokens.iter().enumerate() {
204                    let term = match token {
205                        NormalizedToken::Word(term) => term,
206                        // Skip term if it’s special (we want exact matches only).
207                        NormalizedToken::Special(term) => {
208                            tracing::debug!("skipping fuzzy search for {term:?}: term is special");
209                            continue 'terms;
210                        }
211                    };
212
213                    let max_typo_factor = typo_factor(*original_word_len);
214                    let mut typo_factor = 1u32;
215
216                    // TODO: Rework the Levenshtein query feature to avoid repeating
217                    //   the same query over and over again. Maybe try to see if
218                    //   `fst_levenshtein` can return distances in its response.
219                    while alternates_try > 0 && typo_factor <= max_typo_factor {
220                        let Some(suggestions) = fst_action.lookup_typos(term, typo_factor) else {
221                            tracing::trace!("did not get any completed word for term {term:?}");
222                            continue 'terms;
223                        };
224
225                        merge_suggestions(
226                            suggestions
227                                .map(|(w, distance)| (w, typo_score(distance, *original_word_len))),
228                            &mut scoring_matrix,
229                            term,
230                            idx,
231                            term_count,
232                            &kv_action,
233                            &mut alternates_try,
234                            higher_limit,
235                            document_count,
236                            minimum_idf,
237                        );
238
239                        typo_factor += 1;
240                    }
241                }
242            }
243
244            #[cfg(debug_assertions)]
245            tracing::debug!(?scoring_matrix);
246
247            // Switch to implicit `AND` if query contains a special token.
248            // NOTE: When a user queries for a special token (e.g. UUID),
249            //   they expect only exact matches to be returned. If one term is
250            //   considered special, we drop all results missing at least one
251            //   term. It’s not the most efficient (compared to not storing the
252            //   result in the first place) but it’s an edge case and the cost
253            //   is negligible.
254            let one_term_is_special = tokens.iter().any(|(token, _, _)| token.is_special());
255            if one_term_is_special {
256                let mut to_remove = Vec::<StoreObjectIID>::new();
257
258                for (&iid, scores) in scoring_matrix.iter() {
259                    if scores.iter().any(Option::is_none) {
260                        to_remove.push(iid);
261                    }
262                }
263
264                for iid in to_remove {
265                    scoring_matrix.swap_remove(&iid);
266                }
267            }
268
269            // Flatten scores, taking into account missing matches (thanks to
270            // `None`).
271            let found_iids = scoring_matrix
272                .into_iter()
273                .map(|(iid, scores)| (iid, overall_score(&scores)));
274
275            // Sort found IIDs.
276            let all_iids = {
277                let mut all_iids = found_iids.collect::<Vec<_>>();
278                all_iids.sort_by(|a, b| a.1.total_cmp(&b.1).reverse());
279                all_iids.into_iter().map(|(iid, _score)| iid)
280            };
281
282            // Resolve OIDs from IIDs
283            // Notice: we also proceed paging from there
284            let (limit_usize, offset_usize) = (limit as usize, offset as usize);
285            let mut result_oids = Vec::with_capacity(limit_usize);
286
287            'paging: for (index, found_iid) in all_iids.skip(offset_usize).enumerate() {
288                // Stop there?
289                if index >= limit_usize {
290                    break 'paging;
291                }
292
293                // Read IID-to-OID for this found IID
294                if let Ok(Some(oid)) = kv_action.get_iid_to_oid(found_iid) {
295                    result_oids.push(oid);
296                } else {
297                    tracing::error!("failed getting search executor iid-to-oid");
298                }
299            }
300
301            tracing::info!("got search executor final oids: {:?}", result_oids);
302
303            return Ok(result_oids);
304        }
305
306        Err(())
307    }
308}
309
310/// Inversely proportional to `lev_distance / word_len`, decreasing slowly
311/// towards `f(20) = 0.5`. Will never reach `0`.
312fn prefix_score(lev_distance: u16, word_len: usize) -> f32 {
313    // NOTE: Will be `> 1` in practice.
314    let lev_ratio = lev_distance as f32 / word_len as f32;
315
316    // NOTE: `20` means that auto-completed words 20 times longer than the
317    //   original word get a score of `0.5`. It’s just a magic number, it has
318    //   no further meaning. It just feels ok.
319    20. / (20. + lev_ratio)
320}
321
322#[cfg(test)]
323#[test]
324fn test_prefix_score() {
325    // Auto-complete 2 times longer.
326    for n in [2, 4, 8] {
327        assert_eq!(prefix_score(n, n as usize), 0.95238096);
328    }
329
330    // Auto-complete 4 times longer.
331    for n in [2, 4, 8] {
332        assert_eq!(prefix_score(3 * n, n as usize), 0.8695652);
333    }
334
335    // Auto-complete 1 character.
336    assert_eq!(prefix_score(1, 3), 0.9836065);
337    assert_eq!(prefix_score(1, 4), 0.9876543);
338    assert_eq!(prefix_score(1, 5), 0.99009895);
339    assert_eq!(prefix_score(1, 6), 0.9917356);
340
341    // Auto-complete 2 characters.
342    assert_eq!(prefix_score(2, 3), 0.96774197);
343    assert_eq!(prefix_score(2, 4), 0.9756098);
344    assert_eq!(prefix_score(2, 5), 0.98039216);
345    assert_eq!(prefix_score(2, 6), 0.9836065);
346
347    // More auto-complete means lower score.
348    for n in [1, 2, 4, 8] {
349        for word_len in [2, 4, 8, 10] {
350            assert!(prefix_score(n + 1, word_len as usize) < prefix_score(n, word_len as usize));
351        }
352    }
353}
354
355/// Levenshtein distance proportional to word length.
356fn typo_score(lev_distance: u16, word_len: usize) -> f32 {
357    debug_assert!(
358        (lev_distance as usize) < word_len,
359        "{lev_distance} >= {word_len}"
360    );
361
362    // SAFETY: `.min(1)` isn’t strictly necessary as `lev_distance` should
363    //   always be `< word_len`, but it’s there as a safety precaution.
364    let lev_ratio = (lev_distance as f32 / word_len as f32).min(1.);
365
366    1. - lev_ratio
367}
368
369#[cfg(test)]
370#[test]
371fn test_typo_score() {
372    // No typo.
373    assert_eq!(typo_score(0, 1), 1.);
374    assert_eq!(typo_score(0, 2), 1.);
375
376    // 1 typo.
377    assert_eq!(typo_score(1, 3), 0.6666666);
378    assert_eq!(typo_score(1, 4), 0.75);
379    assert_eq!(typo_score(1, 5), 0.8);
380    // 1 typo always scores lower than 0.
381    for n in 2..=8 {
382        assert!(typo_score(1, n) < typo_score(0, n), "n={n}");
383    }
384
385    // 2 typos.
386    assert_eq!(typo_score(2, 5), 0.6);
387    assert_eq!(typo_score(2, 6), 0.6666666);
388    assert_eq!(typo_score(2, 7), 0.71428573);
389    // 2 typos always scores lower than 1.
390    for n in 3..=8 {
391        assert!(typo_score(2, n) < typo_score(1, n), "n={n}");
392    }
393
394    // A lot of typos (length has no impact, proportion has).
395    assert_eq!(typo_score(1 * 20, 2 * 20), typo_score(1, 2));
396    assert_eq!(typo_score(3 * 20, 7 * 20), typo_score(3, 7));
397}
398
399fn overall_score(scores: &[Option<QueryMatchScore>]) -> QueryResultScore {
400    let total = scores.iter().map(|opt| opt.unwrap_or(0f32)).sum::<f32>();
401    let count = scores.len() as f32;
402
403    #[allow(clippy::let_and_return)]
404    let average = total / count;
405
406    average
407}
408
409#[cfg(test)]
410#[test]
411fn test_overall_score() {
412    const MISSING: Option<QueryMatchScore> = None;
413    const EXACT_MATCH: Option<QueryMatchScore> = Some(1.);
414
415    // Max score for exact matches.
416    assert_eq!(overall_score(&[EXACT_MATCH; 1]), 1.);
417    assert_eq!(overall_score(&[EXACT_MATCH; 2]), 1.);
418    assert_eq!(overall_score(&[EXACT_MATCH; 3]), 1.);
419    assert_eq!(overall_score(&[EXACT_MATCH; 4]), 1.);
420
421    // Lowest score for missing matches.
422    assert_eq!(overall_score(&[MISSING; 1]), 0.);
423    assert_eq!(overall_score(&[MISSING; 2]), 0.);
424    assert_eq!(overall_score(&[MISSING; 3]), 0.);
425    assert_eq!(overall_score(&[MISSING; 4]), 0.);
426
427    // Auto-complete > fuzzy matching (not always, but in most cases).
428    assert!(overall_score(&[Some(prefix_score(5, 4))]) > overall_score(&[Some(typo_score(1, 10))]));
429
430    // Missing one term.
431    assert_eq!(overall_score(&[MISSING, EXACT_MATCH]), 1. / 2.);
432    assert_eq!(overall_score(&[MISSING, EXACT_MATCH, EXACT_MATCH]), 2. / 3.);
433    // Missing one term is better than missing all terms.
434    assert!(overall_score(&[MISSING, EXACT_MATCH]) > overall_score(&[MISSING]));
435
436    // Term order has no meaning.
437    assert_eq!(
438        overall_score(&[
439            EXACT_MATCH,
440            Some(prefix_score(2, 3)),
441            Some(typo_score(2, 7))
442        ]),
443        overall_score(&[
444            Some(typo_score(2, 7)),
445            Some(prefix_score(2, 3)),
446            EXACT_MATCH
447        ])
448    );
449
450    // All typos in one term is like the same total across multiple terms.
451    // NOTE: This is not a requirement, it’s just a non-regression test.
452    assert_eq!(
453        overall_score(&[Some(typo_score(1, 7)); 2]),
454        overall_score(&[Some(typo_score(2, 7)), EXACT_MATCH])
455    );
456    assert_eq!(
457        overall_score(&[Some(typo_score(1, 7)); 3]),
458        overall_score(&[Some(typo_score(3, 7)), EXACT_MATCH, EXACT_MATCH])
459    );
460
461    // Examples for “The brown fox jumps over the lazy dog”:
462    // “brown fox jumps”
463    assert_eq!(overall_score(&[EXACT_MATCH, EXACT_MATCH, EXACT_MATCH]), 1.);
464    // “brown fox jum”
465    assert_eq!(
466        overall_score(&[EXACT_MATCH, EXACT_MATCH, Some(prefix_score(2, 3))]),
467        0.9892473
468    );
469    // “bron fox jum”
470    assert_eq!(
471        overall_score(&[
472            Some(typo_score(1, 5)),
473            EXACT_MATCH,
474            Some(prefix_score(2, 3))
475        ]),
476        0.92258066
477    );
478    // “brown fox”
479    assert_eq!(overall_score(&[EXACT_MATCH, EXACT_MATCH,]), 1.);
480    // “brown fox eats”
481    assert_eq!(
482        overall_score(&[EXACT_MATCH, EXACT_MATCH, MISSING]),
483        0.6666667
484    ); // 2/3
485}
486
487fn document_frequency(term_hash: StoreTermHashed, kv_action: &StoreKVAction<'_>) -> u64 {
488    kv_action
489        .get_term_to_iids(term_hash)
490        .inspect_err(|err| tracing::error!("{err:?}"))
491        .unwrap_or(None)
492        .map_or(0, |iids| iids.len()) as u64
493}
494
495fn bm25_lite_idf(document_count: u64, document_frequency: u64) -> f32 {
496    debug_assert!(
497        document_frequency <= document_count,
498        "{document_frequency} > {document_count}"
499    );
500
501    let document_count = document_count.max(document_frequency) as f64;
502    let df = document_frequency as f64;
503
504    (1.0 + (document_count - df + 0.5) / (df + 0.5)).ln() as f32
505}
506
507#[allow(clippy::too_many_arguments)] // We’ll refactor this someday, and it’ not public anyway.
508fn merge_suggestions(
509    suggestions: impl Iterator<Item = (String, QueryMatchScore)>,
510    scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>>,
511    term: &String,
512    term_idx: usize,
513    term_count: usize,
514    kv_action: &StoreKVAction<'_>,
515    alternates_try: &mut usize,
516    higher_limit: usize,
517    document_count: u64,
518    minimum_idf: f32,
519) {
520    'suggestions: for (suggested_word, base_score) in suggestions {
521        // Do not load base results twice for same term as base term
522        if suggested_word.eq(term) {
523            continue;
524        }
525
526        tracing::trace!(?term, ?suggested_word, "got completed word for term");
527
528        let suggested_term_hash = StoreTermHash::from(&suggested_word);
529        let suggested_iids = match kv_action.get_term_to_iids(suggested_term_hash) {
530            Ok(Some(suggested_iids)) => suggested_iids,
531            Ok(None) => continue,
532            Err(_) => continue,
533        };
534
535        let document_frequency = document_frequency(suggested_term_hash, kv_action);
536
537        // Filter out minimum IDF.
538        // PERF: Filtering `minimum_idf > 0` to save some computation.
539        if minimum_idf > 0. {
540            let idf = (document_count as f32 / document_frequency as f32).ln();
541            if idf < minimum_idf {
542                tracing::debug!(
543                    "skipping term {suggested_word:?} because idf too low ({idf}<{minimum_idf})"
544                );
545                continue;
546            }
547        }
548
549        let bm25_score = bm25_lite_idf(document_count, document_frequency);
550
551        let suggestion_score = base_score * bm25_score;
552
553        for suggested_iid in suggested_iids.into_iter().take(*alternates_try) {
554            // SAFETY: We can reach at most `alternates_try`.
555            *alternates_try = unsafe { alternates_try.unchecked_sub(1) };
556
557            let inserted = update_score(
558                scoring_matrix,
559                suggested_iid,
560                suggestion_score,
561                term_idx,
562                term_count,
563            );
564
565            if inserted {
566                // Higher limit now reached?
567                // Stop acquiring new suggested IIDs now.
568                if scoring_matrix.len() >= higher_limit {
569                    tracing::trace!(?term, "got enough completed results for term");
570
571                    break 'suggestions;
572                }
573            }
574        }
575    }
576
577    tracing::trace!(
578        ?term,
579        "done completing results for term, now {} total results",
580        scoring_matrix.len()
581    );
582}
583
584fn update_score(
585    scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<Option<QueryMatchScore>>>,
586    iid: StoreObjectIID,
587    score: QueryMatchScore,
588    term_idx: usize,
589    term_count: usize,
590) -> bool {
591    match scoring_matrix.entry(iid) {
592        // If entry already exists, use lowest score.
593        indexmap::map::Entry::Occupied(mut occupied_entry) => {
594            // SAFETY: We always initialize vecs with `term_count` entries.
595            let entry_score = unsafe { occupied_entry.get_mut().get_unchecked_mut(term_idx) };
596
597            let new_score = entry_score.map_or(score, |entry_score| score.min(entry_score));
598
599            tracing::trace!(entry_score, new_score, "Updating to min score");
600            *entry_score = Some(new_score);
601
602            false
603        }
604        // If entry does not exist, insert score.
605        indexmap::map::Entry::Vacant(vacant_entry) => {
606            let mut scores = vec![None; term_count];
607
608            tracing::trace!(new_score = score, "Inserting new score");
609            // SAFETY: `scores` has `term_count` elements.
610            unsafe { *scores.get_unchecked_mut(term_idx) = Some(score) };
611
612            vacant_entry.insert(scores);
613
614            true
615        }
616    }
617}