use std::collections::HashMap;
use nodedb_fts::IndexScope;
use nodedb_fts::posting::QueryMode as FtsQueryMode;
use nodedb_types::Surrogate;
use nodedb_types::text_search::TextSearchParams;
use super::registry::{FtsCollectionManager, FtsResult, read_err};
use super::search::Resolved;
use crate::error::LiteError;
type TermPositions = HashMap<Surrogate, Vec<u32>>;
fn term_positions(index: &Resolved<'_>, term: &str) -> TermPositions {
index
.idx
.memtable()
.get_postings(0, 0, IndexScope::document(&index.key), term)
.into_iter()
.map(|p| (p.doc_id, p.positions))
.collect()
}
fn phrase_anchor(doc: Surrogate, per_term: &[TermPositions]) -> Option<u32> {
let (first, rest) = per_term.split_first()?;
let anchors = first.get(&doc)?;
let rest: Vec<&Vec<u32>> = rest.iter().map(|t| t.get(&doc)).collect::<Option<_>>()?;
anchors.iter().copied().find(|&p| {
rest.iter().enumerate().all(|(i, positions)| {
let offset = i as u32 + 1;
p.checked_add(offset)
.is_some_and(|want| positions.binary_search(&want).is_ok())
})
})
}
impl FtsCollectionManager {
pub fn phrase_search(
&self,
collection: &str,
field: &str,
terms: &[String],
top_k: usize,
params: &TextSearchParams,
) -> Result<Vec<FtsResult>, LiteError> {
if terms.is_empty() {
return Ok(Vec::new());
}
let Some(index) = self.resolve(collection, field)? else {
return Ok(Vec::new());
};
let phrase = terms.join(" ");
let tokens = index
.idx
.analyze_for_collection(0, 0, &index.key, &phrase)
.map_err(|e| read_err(collection, e))?;
if tokens.is_empty() {
return Ok(Vec::new());
}
let per_term: Vec<TermPositions> =
tokens.iter().map(|t| term_positions(&index, t)).collect();
let Some(first) = per_term.first() else {
return Ok(Vec::new());
};
let anchored: Vec<(Surrogate, u32)> = first
.keys()
.filter_map(|&doc| phrase_anchor(doc, &per_term).map(|p| (doc, p)))
.collect();
if anchored.is_empty() {
return Ok(Vec::new());
}
let scores: HashMap<Surrogate, (f32, bool)> = Self::bm25(
collection,
&index,
&phrase,
first.len(),
params.fuzzy,
FtsQueryMode::And,
)?
.into_iter()
.map(|r| (r.doc_id, (r.score, r.fuzzy)))
.collect();
let mut hits: Vec<FtsResult> = anchored
.into_iter()
.map(|(doc, earliest)| -> Result<FtsResult, LiteError> {
let (score, fuzzy) = scores.get(&doc).copied().unwrap_or((0.0, false));
let position_bonus = 1.0 / (1.0 + earliest as f32 * 0.01);
Ok(FtsResult {
doc_id: self.doc_id_of(collection, doc)?.to_owned(),
score: score * position_bonus,
fuzzy,
})
})
.collect::<Result<_, _>>()?;
hits.sort_by(|a, b| b.score.total_cmp(&a.score));
hits.truncate(top_k);
Ok(hits)
}
}
#[cfg(test)]
mod tests {
use nodedb_types::text_search::{QueryMode, TextSearchParams};
use super::super::registry::test_governor;
use super::FtsCollectionManager;
fn default_params() -> TextSearchParams {
TextSearchParams {
fuzzy: false,
mode: QueryMode::Or,
}
}
fn phrase(mgr: &FtsCollectionManager, field: &str, words: &[&str]) -> Vec<String> {
let terms: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
mgr.phrase_search("col", field, &terms, 10, &default_params())
.expect("phrase search must succeed")
.into_iter()
.map(|r| r.doc_id)
.collect()
}
#[test]
fn phrase_search_finds_exact_phrase() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "the quick brown fox jumps over")
.expect("index update must succeed");
mgr.index_document("col", "doc2", "the brown quick fox")
.expect("index update must succeed");
let ids = phrase(&mgr, "", &["quick", "brown"]);
assert!(ids.contains(&"doc1".to_owned()), "doc1 holds 'quick brown'");
assert!(
!ids.contains(&"doc2".to_owned()),
"doc2 holds 'brown quick'"
);
}
#[test]
fn phrase_search_no_results_for_nonexistent_phrase() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "the quick brown fox")
.expect("index update must succeed");
assert!(phrase(&mgr, "", &["fox", "jumps"]).is_empty());
}
#[test]
fn phrase_terms_match_the_analyzed_tokens() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_document("col", "doc1", "the quick brown fox")
.expect("index update must succeed");
assert_eq!(phrase(&mgr, "", &["Quick", "BROWN"]), vec!["doc1"]);
}
#[test]
fn phrase_search_is_scoped_to_the_named_field() {
let mut mgr = FtsCollectionManager::new(test_governor());
mgr.index_field("col", "title", "doc1", "quick brown fox")
.expect("index update must succeed");
mgr.index_field("col", "body", "doc2", "quick brown fox")
.expect("index update must succeed");
assert_eq!(phrase(&mgr, "title", &["quick", "brown"]), vec!["doc1"]);
}
}