1use std::collections::{HashMap, HashSet};
7
8use nodedb_fts::TextSearchResult;
9use nodedb_fts::backend::FtsBackend;
10use nodedb_fts::posting::QueryMode as FtsQueryMode;
11use nodedb_fts::{FtsSearchParams, IndexScope};
12use nodedb_types::text_search::{QueryMode, TextSearchParams};
13
14use super::registry::{FtsCollectionManager, FtsResult, index_key, read_err, search_err};
15use crate::engine::fts::LiteFtsIndex;
16use crate::error::LiteError;
17
18pub(super) fn fts_mode(mode: QueryMode) -> FtsQueryMode {
20 FtsQueryMode::from(mode)
21}
22
23pub(super) struct Resolved<'a> {
25 pub key: String,
26 pub idx: &'a LiteFtsIndex,
27}
28
29impl FtsCollectionManager {
30 pub(super) fn resolve(
37 &self,
38 collection: &str,
39 field: &str,
40 ) -> Result<Option<Resolved<'_>>, LiteError> {
41 let key = index_key(collection, field);
42 if let Some(idx) = self.indices.get(&key) {
43 return Ok(Some(Resolved { key, idx }));
44 }
45 if field.is_empty() || !self.has_text_index(collection) {
46 return Ok(None);
47 }
48 Err(LiteError::TextIndexMissing {
49 collection: collection.to_owned(),
50 field: field.to_owned(),
51 })
52 }
53
54 pub(super) fn bm25(
56 collection: &str,
57 index: &Resolved<'_>,
58 query: &str,
59 top_k: usize,
60 fuzzy: bool,
61 mode: FtsQueryMode,
62 ) -> Result<Vec<TextSearchResult>, LiteError> {
63 Self::bm25_query(
64 collection,
65 index,
66 FtsSearchParams {
67 query,
68 top_k,
69 fuzzy_enabled: fuzzy,
70 mode,
71 prefilter: None,
72 },
73 )
74 }
75
76 fn bm25_query(
77 collection: &str,
78 index: &Resolved<'_>,
79 params: FtsSearchParams<'_>,
80 ) -> Result<Vec<TextSearchResult>, LiteError> {
81 index
82 .idx
83 .search(0, 0, &index.key, params)
84 .map_err(|e| search_err(collection, e))
85 }
86
87 fn materialize_results(
88 &self,
89 collection: &str,
90 results: Vec<TextSearchResult>,
91 ) -> Result<Vec<FtsResult>, LiteError> {
92 results
93 .into_iter()
94 .map(|r| {
95 Ok(FtsResult {
96 doc_id: self.doc_id_of(collection, r.doc_id)?.to_owned(),
97 score: r.score,
98 fuzzy: r.fuzzy,
99 })
100 })
101 .collect()
102 }
103
104 pub fn search(
110 &self,
111 collection: &str,
112 field: &str,
113 query: &str,
114 top_k: usize,
115 params: &TextSearchParams,
116 ) -> Result<Vec<FtsResult>, LiteError> {
117 let Some(index) = self.resolve(collection, field)? else {
118 return Ok(Vec::new());
119 };
120 let raw = Self::bm25(
121 collection,
122 &index,
123 query,
124 top_k,
125 params.fuzzy,
126 fts_mode(params.mode),
127 )?;
128 self.materialize_results(collection, raw)
129 }
130
131 pub(crate) fn search_with_allowed(
133 &self,
134 collection: &str,
135 field: &str,
136 query: &str,
137 top_k: usize,
138 params: &TextSearchParams,
139 allowed: &HashSet<String>,
140 ) -> Result<Vec<FtsResult>, LiteError> {
141 let Some(index) = self.resolve(collection, field)? else {
142 return Ok(Vec::new());
143 };
144 if top_k == 0 || allowed.is_empty() {
145 return Ok(Vec::new());
146 }
147 let prefilter: nodedb_types::SurrogateBitmap = allowed
148 .iter()
149 .filter_map(|id| self.lookup_surrogate(id))
150 .collect();
151 if prefilter.is_empty() {
152 return Ok(Vec::new());
153 }
154 let raw = Self::bm25_query(
155 collection,
156 &index,
157 FtsSearchParams {
158 query,
159 top_k,
160 fuzzy_enabled: params.fuzzy,
161 mode: fts_mode(params.mode),
162 prefilter: Some(&prefilter),
163 },
164 )?;
165 self.materialize_results(collection, raw)
166 }
167
168 pub fn scan_all_with_scores(
177 &self,
178 collection: &str,
179 field: &str,
180 query: &str,
181 params: &TextSearchParams,
182 allowed: Option<&HashSet<String>>,
183 ) -> Result<Vec<(String, f32)>, LiteError> {
184 let Some(index) = self.resolve(collection, field)? else {
185 return Ok(Vec::new());
186 };
187
188 let mut members: Vec<(u32, &str)> = Vec::new();
190 for (&sur, doc_id) in &self.surrogate_to_id {
191 if allowed.is_some_and(|ids| !ids.contains(doc_id)) {
192 continue;
193 }
194 let held = index
195 .idx
196 .backend()
197 .read_doc_length(
198 0,
199 0,
200 IndexScope::document(&index.key),
201 nodedb_types::Surrogate(sur),
202 )
203 .map_err(|e| read_err(collection, e))?
204 .is_some();
205 if held {
206 members.push((sur, doc_id.as_str()));
207 }
208 }
209 if members.is_empty() {
210 return Ok(Vec::new());
211 }
212
213 let prefilter: Option<nodedb_types::SurrogateBitmap> = allowed.map(|_| {
214 members
215 .iter()
216 .map(|&(sur, _)| nodedb_types::Surrogate(sur))
217 .collect()
218 });
219 let hits: HashMap<u32, f32> = Self::bm25_query(
222 collection,
223 &index,
224 FtsSearchParams {
225 query,
226 top_k: members.len(),
227 fuzzy_enabled: params.fuzzy,
228 mode: fts_mode(params.mode),
229 prefilter: prefilter.as_ref(),
230 },
231 )?
232 .into_iter()
233 .map(|r| (r.doc_id.0, r.score))
234 .collect();
235
236 Ok(members
237 .into_iter()
238 .map(|(sur, doc_id)| (doc_id.to_owned(), hits.get(&sur).copied().unwrap_or(0.0)))
239 .collect())
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use std::collections::HashSet;
246
247 use nodedb_types::text_search::{QueryMode, TextSearchParams};
248
249 use super::super::registry::test_governor;
250 use super::FtsCollectionManager;
251 use crate::error::LiteError;
252
253 fn default_params() -> TextSearchParams {
254 TextSearchParams {
255 fuzzy: false,
256 mode: QueryMode::Or,
257 }
258 }
259
260 #[test]
261 fn bm25_score_scan_nonmatching_docs_get_zero_score() {
262 let mut mgr = FtsCollectionManager::new(test_governor());
263 mgr.index_document("col", "doc1", "the quick brown fox")
264 .expect("index update must succeed");
265 mgr.index_document("col", "doc2", "unrelated content about databases")
266 .expect("index update must succeed");
267
268 let scored = mgr
269 .scan_all_with_scores("col", "", "quick", &default_params(), None)
270 .expect("scan must succeed");
271 let score_of = |id: &str| scored.iter().find(|(d, _)| d == id).map(|(_, s)| *s);
272
273 assert!(
274 score_of("doc1").is_some_and(|s| s > 0.0),
275 "doc1 matches 'quick'"
276 );
277 assert!(
278 score_of("doc2").is_some_and(|s| s.abs() < f32::EPSILON),
279 "doc2 does not match 'quick' — score must be 0.0"
280 );
281 }
282
283 #[test]
284 fn bm25_score_scan_lists_only_the_collections_own_documents() {
285 let mut mgr = FtsCollectionManager::new(test_governor());
286 mgr.index_document("col", "doc1", "the quick brown fox")
287 .expect("index update must succeed");
288 mgr.index_document("other", "doc9", "quick thinking")
289 .expect("index update must succeed");
290 mgr.index_document("col", "gone", "quick exit")
291 .expect("index update must succeed");
292 mgr.remove_document("col", "gone")
293 .expect("removal must succeed");
294
295 let ids: Vec<String> = mgr
296 .scan_all_with_scores("col", "", "quick", &default_params(), None)
297 .expect("scan must succeed")
298 .into_iter()
299 .map(|(id, _)| id)
300 .collect();
301 assert_eq!(ids, vec!["doc1".to_owned()]);
302 }
303
304 #[test]
305 fn reading_an_unindexed_field_of_an_indexed_collection_is_an_error() {
306 let mut mgr = FtsCollectionManager::new(test_governor());
307 mgr.index_field("col", "title", "doc1", "hello")
308 .expect("index update must succeed");
309
310 let err = mgr
311 .search("col", "body", "hello", 10, &default_params())
312 .err()
313 .expect("an unindexed field must be refused");
314 assert!(
315 matches!(&err, LiteError::TextIndexMissing { field, .. } if field == "body"),
316 "{err:?}"
317 );
318 for (top_k, allowed) in [
319 (10, HashSet::new()),
320 (0, HashSet::from(["doc1".to_owned()])),
321 ] {
322 let err = mgr
323 .search_with_allowed("col", "body", "hello", top_k, &default_params(), &allowed)
324 .err()
325 .expect("an unindexed field must be refused regardless of result limits");
326 assert!(
327 matches!(&err, LiteError::TextIndexMissing { field, .. } if field == "body"),
328 "{err:?}"
329 );
330 }
331 }
332
333 #[test]
334 fn reading_a_collection_with_no_text_is_an_empty_result() {
335 let mgr = FtsCollectionManager::new(test_governor());
336 assert!(
337 mgr.search("nothing", "", "query", 10, &default_params())
338 .expect("search must succeed")
339 .is_empty()
340 );
341 assert!(
342 mgr.search("nothing", "title", "query", 10, &default_params())
343 .expect("search must succeed")
344 .is_empty()
345 );
346 assert!(
347 mgr.scan_all_with_scores("nothing", "", "query", &default_params(), None)
348 .expect("scan must succeed")
349 .is_empty()
350 );
351 }
352
353 #[test]
354 fn a_query_the_index_rejects_is_an_error_not_an_empty_result() {
355 let mut mgr = FtsCollectionManager::new(test_governor());
356 mgr.index_document("col", "doc1", "rust and python")
357 .expect("index update must succeed");
358
359 let err = mgr
360 .search("col", "", "NOT python", 10, &default_params())
361 .err()
362 .expect("a negation-only query must fail the search");
363 assert!(matches!(err, LiteError::FtsQueryInvalid { .. }), "{err:?}");
364
365 let err = mgr
366 .scan_all_with_scores("col", "", "NOT python", &default_params(), None)
367 .expect_err("a negation-only query must fail the scan");
368 assert!(matches!(err, LiteError::FtsQueryInvalid { .. }), "{err:?}");
369 }
370
371 #[test]
372 fn a_term_no_document_holds_is_an_empty_result() {
373 let mut mgr = FtsCollectionManager::new(test_governor());
374 mgr.index_document("col", "doc1", "rust and python")
375 .expect("index update must succeed");
376
377 let results = mgr
378 .search("col", "", "haskell", 10, &default_params())
379 .expect("search must succeed");
380 assert!(results.is_empty());
381 }
382
383 #[test]
384 fn search_with_allowed_ids_excludes_non_members() {
385 let mut mgr = FtsCollectionManager::new(test_governor());
386 mgr.index_document("col", "doc-a", "rust programming language memory safe")
387 .expect("index update must succeed");
388 mgr.index_document("col", "doc-b", "rust is fast and compiled")
389 .expect("index update must succeed");
390 mgr.index_document("col", "doc-c", "python is also a language")
391 .expect("index update must succeed");
392
393 let allowed: HashSet<String> = ["doc-a".to_string()].into_iter().collect();
394 let results = mgr
395 .search_with_allowed("col", "", "rust", 10, &default_params(), &allowed)
396 .expect("search must succeed");
397 let ids: Vec<&str> = results.iter().map(|r| r.doc_id.as_str()).collect();
398 assert_eq!(ids, vec!["doc-a"], "only allowed members may appear");
399 }
400
401 #[test]
402 fn allowed_search_preserves_unknown_ids_and_zero_limits() {
403 let mut mgr = FtsCollectionManager::new(test_governor());
404 mgr.index_document("col", "known", "rust")
405 .expect("index update must succeed");
406 let unknown = HashSet::from(["unknown".to_owned()]);
407 assert!(
408 mgr.search_with_allowed("col", "", "rust", 1, &default_params(), &unknown)
409 .expect("search must succeed")
410 .is_empty()
411 );
412 assert!(mgr.lookup_surrogate("unknown").is_none());
413 let known = HashSet::from(["known".to_owned()]);
414 assert!(
415 mgr.search_with_allowed("col", "", "rust", 0, &default_params(), &known)
416 .expect("search must succeed")
417 .is_empty()
418 );
419 }
420
421 #[test]
422 fn hybrid_triple_rrf_score_ordering() {
423 use nodedb_query::fusion::{RankedResult, reciprocal_rank_fusion_weighted};
426
427 let ranked = |ids: &[(&str, f32)], source: &'static str| -> Vec<RankedResult<String>> {
428 ids.iter()
429 .enumerate()
430 .map(|(rank, (id, score))| RankedResult {
431 document_id: (*id).into(),
432 rank,
433 score: *score,
434 source,
435 })
436 .collect()
437 };
438 let fused = reciprocal_rank_fusion_weighted(
439 &[
440 ranked(&[("A", 0.9), ("B", 0.5)], "vector"),
441 ranked(&[("A", 0.8)], "text"),
442 ranked(&[("A", 0.0)], "graph"),
443 ],
444 &[60.0, 60.0, 60.0],
445 10,
446 );
447
448 assert_eq!(fused[0].document_id, "A", "A appears in all three sources");
449 assert!(
450 fused[0].rrf_score > fused[1].rrf_score,
451 "A's score must exceed B's"
452 );
453 }
454}