1use 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 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 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 let terms: Vec<(String, StoreTermHashed, usize)> = lexer.collect();
64 let term_count = terms.len();
65
66 let mut scoring_matrix: IndexMap<StoreObjectIID, Vec<QueryMatchScore>> =
75 IndexMap::with_capacity(24usize.min(usize::from(limit)));
76
77 '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 let inserted = update_score(&mut scoring_matrix, iid, 0, idx, term_count);
89
90 if inserted {
91 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 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 if term.chars().any(is_considered_id_char) {
116 tracing::debug!(
117 "skipping prefix search for {term:?}: term is considered an ID"
118 );
119 continue 'terms;
120 };
121
122 let Some(suggestions) = fst_action.lookup_begins(term, *original_len) else {
123 tracing::trace!("did not get any completed word for term {term:?}");
124 continue 'terms;
125 };
126
127 merge_suggestions(
128 suggestions,
129 &mut scoring_matrix,
130 term,
131 idx,
132 term_count,
133 &kv_action,
134 &mut alternates_try,
135 higher_limit,
136 );
137 }
138 }
139
140 #[cfg(debug_assertions)]
141 tracing::debug!(?scoring_matrix);
142
143 if scoring_matrix.len() < higher_limit && alternates_try > 0 && fuzzy_matching_enabled {
145 tracing::debug!(
146 "not enough iids were found ({}/{higher_limit}), looking for fuzzy matches",
147 scoring_matrix.len(),
148 );
149
150 'terms: for (idx, (term, _, original_word_len)) in terms.iter().enumerate() {
151 if term.chars().any(is_considered_id_char) {
153 tracing::debug!(
154 "skipping fuzzy matching for {term:?}: term is considered an ID"
155 );
156 continue 'terms;
157 };
158
159 let max_typo_factor = typo_factor(*original_word_len);
160 let mut typo_factor = 1u32;
161
162 while alternates_try > 0 && typo_factor <= max_typo_factor {
166 let Some(suggestions) = fst_action.lookup_typos(term, typo_factor) else {
167 tracing::trace!("did not get any completed word for term {term:?}");
168 continue 'terms;
169 };
170
171 merge_suggestions(
172 suggestions,
173 &mut scoring_matrix,
174 term,
175 idx,
176 term_count,
177 &kv_action,
178 &mut alternates_try,
179 higher_limit,
180 );
181
182 typo_factor += 1;
183 }
184 }
185 }
186
187 #[cfg(debug_assertions)]
188 tracing::debug!(?scoring_matrix);
189
190 let one_term_is_an_id = terms.iter().any(|(term, _, _)| is_considered_id(term));
197 if one_term_is_an_id {
198 let mut to_remove = Vec::<StoreObjectIID>::new();
199
200 for (&iid, scores) in scoring_matrix.iter() {
201 if scores.contains(&MISSING_MATCH_SCORE) {
202 to_remove.push(iid);
203 }
204 }
205
206 for iid in to_remove {
207 scoring_matrix.swap_remove(&iid);
208 }
209 }
210
211 let found_iids = scoring_matrix
214 .into_iter()
215 .map(|(iid, scores)| (iid, scores.into_iter().sum()));
216
217 let all_iids = sorted_groups(found_iids).flat_map(|(_, v)| v);
219
220 let (limit_usize, offset_usize) = (limit as usize, offset as usize);
223 let mut result_oids = Vec::with_capacity(limit_usize);
224
225 'paging: for (index, found_iid) in all_iids.skip(offset_usize).enumerate() {
226 if index >= limit_usize {
228 break 'paging;
229 }
230
231 if let Ok(Some(oid)) = kv_action.get_iid_to_oid(found_iid) {
233 result_oids.push(oid);
234 } else {
235 tracing::error!("failed getting search executor iid-to-oid");
236 }
237 }
238
239 tracing::info!("got search executor final oids: {:?}", result_oids);
240
241 return Ok(result_oids);
242 }
243
244 Err(())
245 }
246}
247
248fn is_considered_id(s: &str) -> bool {
249 s.chars().any(is_considered_id_char)
250}
251
252fn is_considered_id_char(c: char) -> bool {
253 c.is_ascii_digit()
254}
255
256#[allow(clippy::too_many_arguments)] fn merge_suggestions(
258 suggestions: impl Iterator<Item = (String, QueryMatchScore)>,
259 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
260 term: &String,
261 term_idx: usize,
262 term_count: usize,
263 kv_action: &StoreKVAction<'_>,
264 alternates_try: &mut usize,
265 higher_limit: usize,
266) {
267 'suggestions: for (suggested_word, suggestion_score) in suggestions {
268 if suggested_word.eq(term) {
270 continue;
271 }
272
273 tracing::trace!(?term, ?suggested_word, "got completed word for term");
274
275 let suggested_term_hash = StoreTermHash::from(&suggested_word);
276 let suggested_iids = match kv_action.get_term_to_iids(suggested_term_hash) {
277 Ok(Some(suggested_iids)) => suggested_iids,
278 Ok(None) => continue,
279 Err(_) => continue,
280 };
281
282 for suggested_iid in suggested_iids.into_iter().take(*alternates_try) {
283 *alternates_try = unsafe { alternates_try.unchecked_sub(1) };
285
286 let inserted = update_score(
287 scoring_matrix,
288 suggested_iid,
289 suggestion_score,
290 term_idx,
291 term_count,
292 );
293
294 if inserted {
295 if scoring_matrix.len() >= higher_limit {
298 tracing::trace!(?term, "got enough completed results for term");
299
300 break 'suggestions;
301 }
302 }
303 }
304 }
305
306 tracing::trace!(
307 ?term,
308 "done completing results for term, now {} total results",
309 scoring_matrix.len()
310 );
311}
312
313fn update_score(
314 scoring_matrix: &mut IndexMap<StoreObjectIID, Vec<QueryMatchScore>>,
315 iid: StoreObjectIID,
316 score: QueryMatchScore,
317 term_idx: usize,
318 term_count: usize,
319) -> bool {
320 match scoring_matrix.entry(iid) {
321 indexmap::map::Entry::Occupied(mut occupied_entry) => {
323 let entry_score = unsafe { occupied_entry.get_mut().get_unchecked_mut(term_idx) };
325
326 let new_score = score.min(*entry_score);
327
328 tracing::trace!(entry_score, new_score, "Updating to min score");
329 *entry_score = new_score;
330
331 false
332 }
333 indexmap::map::Entry::Vacant(vacant_entry) => {
335 let mut scores = vec![MISSING_MATCH_SCORE; term_count];
336
337 tracing::trace!(new_score = score, "Inserting new score");
338 unsafe { *scores.get_unchecked_mut(term_idx) = score };
340
341 vacant_entry.insert(scores);
342
343 true
344 }
345 }
346}
347
348fn sorted_groups(
349 map: impl ExactSizeIterator<Item = (StoreObjectIID, QueryMatchScore)>,
350) -> impl ExactSizeIterator<Item = (QueryMatchScore, Vec<StoreObjectIID>)> {
351 let mut btree: BTreeMap<QueryMatchScore, Vec<StoreObjectIID>> = BTreeMap::new();
352
353 for (k, v) in map.into_iter() {
354 btree.entry(v).or_default().push(k);
355 }
356
357 btree.into_iter()
358}