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 linked_hash_set::LinkedHashSet;
9use std::iter::FromIterator;
10
11use crate::lexer::TokenLexer;
12use crate::query::{QuerySearchID, QuerySearchLimit, QuerySearchOffset};
13use crate::store::StoreItem;
14use crate::store::fst::StoreFSTActionBuilder;
15use crate::store::identifiers::{StoreObjectIID, StoreTermHash};
16use crate::store::kv::{StoreKVAcquireMode, StoreKVActionBuilder};
17
18impl super::Executor {
19 pub fn search(
20 &self,
21 item: StoreItem,
22 _event_id: QuerySearchID,
23 lexer: TokenLexer,
24 limit: QuerySearchLimit,
25 offset: QuerySearchOffset,
26 ) -> Result<Vec<String>, ()> {
27 if let StoreItem(collection, Some(bucket), None) = item {
28 // Important: acquire database access read lock, and reference it in context. This \
29 // prevents the database from being erased while using it in this block.
30 let _kv_read_guard = self.kv_pool.lock_read_access();
31 let _fst_read_guard = self.fst_pool.lock_read_access();
32
33 if let (Ok(kv_store), Ok(fst_store)) = (
34 self.kv_pool
35 .acquire(StoreKVAcquireMode::OpenOnly, collection),
36 self.fst_pool.acquire(collection, bucket),
37 ) {
38 // Important: acquire bucket store read lock
39 executor_kv_lock_read!(kv_store);
40
41 let (kv_action, fst_action) = (
42 StoreKVActionBuilder::access(bucket, kv_store),
43 StoreFSTActionBuilder::access(fst_store),
44 );
45
46 // Try to resolve existing search terms to IIDs, and perform an algebraic AND on \
47 // all resulting IIDs for each given term.
48 let mut found_iids: LinkedHashSet<StoreObjectIID> = LinkedHashSet::new();
49
50 'lexing: for (term, term_hashed) in lexer {
51 let mut iids = LinkedHashSet::from_iter(
52 kv_action
53 .get_term_to_iids(term_hashed)
54 .unwrap_or(None)
55 .unwrap_or_default(),
56 );
57
58 // No IIDs? Try to complete with a suggested alternate word
59 // Notice: this may sound dirty to try generating as many results as the \
60 // 'retain_word_objects' value, but as we do not know if another lexed word \
61 // comes next we need to exhaust all search space as to intersect it with \
62 // the (likely) upcoming word.
63 let (higher_limit, alternates_try) = (
64 self.app_conf.store.kv.retain_word_objects,
65 self.app_conf.search.query_alternates_try,
66 );
67
68 if iids.len() < higher_limit && alternates_try > 0 {
69 tracing::debug!(
70 "not enough iids were found ({}/{}), completing for term: {}",
71 iids.len(),
72 higher_limit,
73 term
74 );
75
76 // Suggest N words, in case the first one is found in FST as an exact \
77 // match of term, we can pick next ones to complete search even further.
78 // Notice: we add '1' to the 'alternates_try' number as to account for \
79 // exact match suggestion that comes as first result and is to be ignored.
80 if let Some(suggested_words) =
81 fst_action.suggest_words(&term, alternates_try + 1, Some(1))
82 {
83 let mut iids_new_len = iids.len();
84
85 // This loop will be broken early if we get enough results at some \
86 // iteration
87 'suggestions: for suggested_word in suggested_words {
88 // Do not load base results twice for same term as base term
89 if suggested_word == term {
90 continue 'suggestions;
91 }
92
93 tracing::debug!(
94 "got completed word: {} for term: {}",
95 suggested_word,
96 term
97 );
98
99 if let Some(suggested_iids) = kv_action
100 .get_term_to_iids(StoreTermHash::from(&suggested_word))
101 .unwrap_or(None)
102 {
103 for suggested_iid in suggested_iids {
104 // Do not append the same IID twice (can happen a lot \
105 // when completing from suggested results that point \
106 // to the same end-OID)
107 if !iids.contains(&suggested_iid) {
108 iids.insert(suggested_iid);
109
110 iids_new_len += 1;
111
112 // Higher limit now reached? Stop acquiring new \
113 // suggested IIDs now.
114 if iids_new_len >= higher_limit {
115 tracing::debug!(
116 "got enough completed results for term: {}",
117 term
118 );
119
120 break 'suggestions;
121 }
122 }
123 }
124 }
125 }
126
127 tracing::debug!(
128 "done completing results for term: {}, now {} results",
129 term,
130 iids_new_len
131 );
132 } else {
133 tracing::debug!("did not get any completed word for term: {}", term);
134 }
135 }
136
137 tracing::debug!("got search executor iids: {:?} for term: {}", iids, term);
138
139 // Intersect found IIDs with previous batch
140 if found_iids.is_empty() {
141 found_iids = iids;
142 } else {
143 found_iids = found_iids.intersection(&iids).copied().collect();
144 }
145
146 tracing::debug!(
147 "got search executor iid intersection: {:?} for term: {}",
148 found_iids,
149 term
150 );
151
152 // No IID found? (stop there)
153 if found_iids.is_empty() {
154 tracing::info!(
155 "stop search executor as no iid was found in common for term: {}",
156 term
157 );
158
159 break 'lexing;
160 }
161 }
162
163 // Resolve OIDs from IIDs
164 // Notice: we also proceed paging from there
165 let (limit_usize, offset_usize) = (limit as usize, offset as usize);
166 let mut result_oids = Vec::with_capacity(limit_usize);
167
168 'paging: for (index, found_iid) in found_iids.iter().skip(offset_usize).enumerate()
169 {
170 // Stop there?
171 if index >= limit_usize {
172 break 'paging;
173 }
174
175 // Read IID-to-OID for this found IID
176 if let Ok(Some(oid)) = kv_action.get_iid_to_oid(*found_iid) {
177 result_oids.push(oid);
178 } else {
179 tracing::error!("failed getting search executor iid-to-oid");
180 }
181 }
182
183 tracing::info!("got search executor final oids: {:?}", result_oids);
184
185 return Ok(result_oids);
186 }
187 }
188
189 Err(())
190 }
191}