nodedb_lite/engine/fts/manager/
phrase.rs1use std::collections::HashMap;
6
7use nodedb_fts::IndexScope;
8use nodedb_fts::posting::QueryMode as FtsQueryMode;
9use nodedb_types::Surrogate;
10use nodedb_types::text_search::TextSearchParams;
11
12use super::registry::{FtsCollectionManager, FtsResult, read_err};
13use super::search::Resolved;
14use crate::error::LiteError;
15
16type TermPositions = HashMap<Surrogate, Vec<u32>>;
18
19fn term_positions(index: &Resolved<'_>, term: &str) -> TermPositions {
25 index
26 .idx
27 .memtable()
28 .get_postings(0, 0, IndexScope::document(&index.key), term)
29 .into_iter()
30 .map(|p| (p.doc_id, p.positions))
31 .collect()
32}
33
34fn phrase_anchor(doc: Surrogate, per_term: &[TermPositions]) -> Option<u32> {
37 let (first, rest) = per_term.split_first()?;
38 let anchors = first.get(&doc)?;
39 let rest: Vec<&Vec<u32>> = rest.iter().map(|t| t.get(&doc)).collect::<Option<_>>()?;
40 anchors.iter().copied().find(|&p| {
41 rest.iter().enumerate().all(|(i, positions)| {
42 let offset = i as u32 + 1;
43 p.checked_add(offset)
44 .is_some_and(|want| positions.binary_search(&want).is_ok())
45 })
46 })
47}
48
49impl FtsCollectionManager {
50 pub fn phrase_search(
60 &self,
61 collection: &str,
62 field: &str,
63 terms: &[String],
64 top_k: usize,
65 params: &TextSearchParams,
66 ) -> Result<Vec<FtsResult>, LiteError> {
67 if terms.is_empty() {
68 return Ok(Vec::new());
69 }
70 let Some(index) = self.resolve(collection, field)? else {
71 return Ok(Vec::new());
72 };
73 let phrase = terms.join(" ");
74 let tokens = index
75 .idx
76 .analyze_for_collection(0, 0, &index.key, &phrase)
77 .map_err(|e| read_err(collection, e))?;
78 if tokens.is_empty() {
79 return Ok(Vec::new());
80 }
81
82 let per_term: Vec<TermPositions> =
83 tokens.iter().map(|t| term_positions(&index, t)).collect();
84 let Some(first) = per_term.first() else {
85 return Ok(Vec::new());
86 };
87 let anchored: Vec<(Surrogate, u32)> = first
88 .keys()
89 .filter_map(|&doc| phrase_anchor(doc, &per_term).map(|p| (doc, p)))
90 .collect();
91 if anchored.is_empty() {
92 return Ok(Vec::new());
93 }
94
95 let scores: HashMap<Surrogate, (f32, bool)> = Self::bm25(
98 collection,
99 &index,
100 &phrase,
101 first.len(),
102 params.fuzzy,
103 FtsQueryMode::And,
104 )?
105 .into_iter()
106 .map(|r| (r.doc_id, (r.score, r.fuzzy)))
107 .collect();
108
109 let mut hits: Vec<FtsResult> = anchored
110 .into_iter()
111 .map(|(doc, earliest)| -> Result<FtsResult, LiteError> {
112 let (score, fuzzy) = scores.get(&doc).copied().unwrap_or((0.0, false));
113 let position_bonus = 1.0 / (1.0 + earliest as f32 * 0.01);
114 Ok(FtsResult {
115 doc_id: self.doc_id_of(collection, doc)?.to_owned(),
116 score: score * position_bonus,
117 fuzzy,
118 })
119 })
120 .collect::<Result<_, _>>()?;
121
122 hits.sort_by(|a, b| b.score.total_cmp(&a.score));
123 hits.truncate(top_k);
124 Ok(hits)
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use nodedb_types::text_search::{QueryMode, TextSearchParams};
131
132 use super::super::registry::test_governor;
133 use super::FtsCollectionManager;
134
135 fn default_params() -> TextSearchParams {
136 TextSearchParams {
137 fuzzy: false,
138 mode: QueryMode::Or,
139 }
140 }
141
142 fn phrase(mgr: &FtsCollectionManager, field: &str, words: &[&str]) -> Vec<String> {
143 let terms: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
144 mgr.phrase_search("col", field, &terms, 10, &default_params())
145 .expect("phrase search must succeed")
146 .into_iter()
147 .map(|r| r.doc_id)
148 .collect()
149 }
150
151 #[test]
152 fn phrase_search_finds_exact_phrase() {
153 let mut mgr = FtsCollectionManager::new(test_governor());
154 mgr.index_document("col", "doc1", "the quick brown fox jumps over")
155 .expect("index update must succeed");
156 mgr.index_document("col", "doc2", "the brown quick fox")
157 .expect("index update must succeed");
158
159 let ids = phrase(&mgr, "", &["quick", "brown"]);
160 assert!(ids.contains(&"doc1".to_owned()), "doc1 holds 'quick brown'");
161 assert!(
162 !ids.contains(&"doc2".to_owned()),
163 "doc2 holds 'brown quick'"
164 );
165 }
166
167 #[test]
168 fn phrase_search_no_results_for_nonexistent_phrase() {
169 let mut mgr = FtsCollectionManager::new(test_governor());
170 mgr.index_document("col", "doc1", "the quick brown fox")
171 .expect("index update must succeed");
172 assert!(phrase(&mgr, "", &["fox", "jumps"]).is_empty());
173 }
174
175 #[test]
176 fn phrase_terms_match_the_analyzed_tokens() {
177 let mut mgr = FtsCollectionManager::new(test_governor());
178 mgr.index_document("col", "doc1", "the quick brown fox")
179 .expect("index update must succeed");
180 assert_eq!(phrase(&mgr, "", &["Quick", "BROWN"]), vec!["doc1"]);
181 }
182
183 #[test]
184 fn phrase_search_is_scoped_to_the_named_field() {
185 let mut mgr = FtsCollectionManager::new(test_governor());
186 mgr.index_field("col", "title", "doc1", "quick brown fox")
187 .expect("index update must succeed");
188 mgr.index_field("col", "body", "doc2", "quick brown fox")
189 .expect("index update must succeed");
190 assert_eq!(phrase(&mgr, "title", &["quick", "brown"]), vec!["doc1"]);
191 }
192}