use tracing::debug;
use nodedb_fts::FtsSearchParams;
use nodedb_fts::posting::{MatchOffset, Posting, TextSearchResult};
use nodedb_types::{Surrogate, TenantId};
use super::core::InvertedIndex;
use super::errors::{fts_index_err, inverted_err};
use crate::engine::sparse::fts_redb::tables::POSTINGS;
pub struct PhraseSearchParams<'a> {
pub terms: &'a [String],
pub top_k: usize,
pub prefilter: Option<&'a nodedb_types::SurrogateBitmap>,
}
impl InvertedIndex {
pub fn phrase_search(
&self,
database_id: u64,
tid: TenantId,
collection: &str,
params: PhraseSearchParams<'_>,
) -> crate::Result<Vec<TextSearchResult>> {
let PhraseSearchParams {
terms,
top_k,
prefilter,
} = params;
if terms.is_empty() {
return Ok(Vec::new());
}
let t = tid.as_u64();
let db = self.inner.backend().db();
let read_txn = db.begin_read().map_err(|e| inverted_err("read txn", e))?;
let postings_table = read_txn
.open_table(POSTINGS)
.map_err(|e| inverted_err("open postings", e))?;
let mut term_lists: Vec<Vec<Posting>> = Vec::with_capacity(terms.len());
for term in terms {
let analyzed = self.analyze_for_collection(database_id, tid, collection, term)?;
let canonical = analyzed.into_iter().next().unwrap_or_else(|| term.clone());
let postings: Vec<Posting> = postings_table
.get((database_id, t, collection, canonical.as_str()))
.map_err(|e| inverted_err("read posting", e))?
.and_then(|v| zerompk::from_msgpack(v.value()).ok())
.unwrap_or_default();
term_lists.push(postings);
}
let first = &term_lists[0];
let mut matches: Vec<(Surrogate, u32)> = Vec::new();
'outer: for posting in first {
if prefilter.is_some_and(|bm| !bm.0.contains(posting.doc_id.as_u32())) {
continue;
}
let surrogate = posting.doc_id;
'pos: for &start_pos in &posting.positions {
for (offset, list) in term_lists[1..].iter().enumerate() {
let expected_pos = start_pos + (offset as u32) + 1;
let Some(other_posting) = list.iter().find(|p| p.doc_id == surrogate) else {
continue 'outer;
};
if !other_posting.positions.contains(&expected_pos) {
continue 'pos;
}
}
matches.push((surrogate, start_pos));
break; }
}
matches.sort_by_key(|(_, pos)| *pos);
let results: Vec<TextSearchResult> = matches
.into_iter()
.take(top_k)
.enumerate()
.map(|(rank, (doc_id, pos))| TextSearchResult {
doc_id,
score: 1.0 / (1.0 + pos as f32 + rank as f32),
fuzzy: false,
})
.collect();
debug!(
tid = t,
%collection,
terms = terms.len(),
hits = results.len(),
"phrase search"
);
Ok(results)
}
pub fn search(
&self,
database_id: u64,
tid: TenantId,
collection: &str,
params: FtsSearchParams<'_>,
) -> crate::Result<Vec<TextSearchResult>> {
self.inner
.search(database_id, tid.as_u64(), collection, params)
.map_err(fts_index_err)
}
pub fn highlight(&self, text: &str, query: &str, prefix: &str, suffix: &str) -> String {
self.inner.highlight(text, query, prefix, suffix)
}
pub fn offsets(&self, text: &str, query: &str) -> Vec<MatchOffset> {
self.inner.offsets(text, query)
}
}