Skip to main content

nodedb_lite/engine/fts/manager/
search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! BM25 read side of [`FtsCollectionManager`]: index resolution, ranked
4//! search, allowed-set search, and the all-documents score scan.
5
6use 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
18/// Map the public query mode onto the index's boolean mode.
19pub(super) fn fts_mode(mode: QueryMode) -> FtsQueryMode {
20    FtsQueryMode::from(mode)
21}
22
23/// An index resolved for one read: its key and the index itself.
24pub(super) struct Resolved<'a> {
25    pub key: String,
26    pub idx: &'a LiteFtsIndex,
27}
28
29impl FtsCollectionManager {
30    /// Resolve the index a read of `field` in `collection` runs against.
31    ///
32    /// An empty `field` names the whole-document index. `None` means nothing
33    /// of the collection was ever text-indexed, so the read matches nothing.
34    /// Fails with [`LiteError::TextIndexMissing`] when the collection has
35    /// text-indexed documents but none under the named field.
36    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    /// Run one BM25 query against a resolved index.
55    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    /// Search the collection's `field` index. An empty `field` searches the whole-document index.
105    /// Collections without indexed text return an empty list.
106    ///
107    /// [`TextSearchParams`] controls boolean mode and fuzzy matching.
108    /// Queries without matching documents return an empty list. Read errors propagate.
109    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    /// Search only allowed surrogate IDs before ranking candidates.
132    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    // ── BM25ScoreScan: all docs with injected score (0.0 for non-matches) ────
169
170    /// Return every document the `field` index of `collection` holds, with
171    /// its BM25 score against `query`. Documents outside the BM25 hit set
172    /// score `0.0`. This powers the score columns of text reads.
173    ///
174    /// `allowed` restricts the documents to the named ones. The query's
175    /// AND-mode fallback is then decided over those documents only.
176    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        // An index holds a document exactly when it records its length.
189        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        // The member count bounds the hit set; `usize::MAX` would overflow
220        // the result heap allocation.
221        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        // A document appearing in all three sources ranks above one appearing
424        // in only one source — purely testing RRF math.
425        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}