use std::collections::HashSet;
use nodedb_types::{Surrogate, SurrogateBitmap};
use crate::backend::FtsBackend;
use crate::block::{CompactPosting, into_blocks};
use crate::codec::smallfloat;
use crate::index::FtsIndex;
use crate::index::error::FtsIndexError;
use crate::lsm::query as lsm_query;
use crate::posting::{Posting, QueryMode};
use crate::scope::IndexScope;
use crate::search::bmw::skip_index::TermBlocks;
use crate::search::query_parser::parse_query;
use crate::search::staged::StagedView;
#[derive(Debug, Clone, Copy)]
pub struct TextQuery<'a> {
pub query: &'a str,
pub fuzzy_enabled: bool,
pub mode: QueryMode,
}
pub(crate) struct ResolvedQuery {
pub(crate) texts: Vec<String>,
pub(crate) blocks: Vec<TermBlocks>,
pub(crate) term_groups: Vec<usize>,
pub(crate) fuzzy: bool,
pub(crate) groups: usize,
pub(crate) require_all: bool,
pub(crate) negative_terms: HashSet<String>,
pub(crate) negated: SurrogateBitmap,
pub(crate) total_docs: u32,
pub(crate) avg_doc_len: f32,
}
impl<B: FtsBackend> FtsIndex<B> {
pub(crate) fn resolve_query(
&self,
database_id: u64,
tid: u64,
index: IndexScope<'_>,
query: TextQuery<'_>,
staged: Option<&StagedView>,
) -> Result<Option<ResolvedQuery>, FtsIndexError<B::Error>> {
let collection = index.collection();
let fuzzy_enabled = query.fuzzy_enabled
|| self
.get_collection_fuzzy(database_id, tid, collection)
.map_err(FtsIndexError::backend)?;
let parsed = parse_query(query.query)?;
let positive_raw = parsed.positive.join(" ");
let base_tokens = self
.analyze_for_collection(database_id, tid, collection, &positive_raw)
.map_err(FtsIndexError::backend)?;
if base_tokens.is_empty() {
return Ok(None);
}
let raw_tokens = if fuzzy_enabled {
self.tokenize_raw_for_collection(database_id, tid, collection, &positive_raw)
.map_err(FtsIndexError::backend)?
} else {
Vec::new()
};
let words = self
.expand_query_groups(database_id, tid, base_tokens)
.map_err(FtsIndexError::backend)?;
let groups = words.len();
let mut texts: Vec<String> = Vec::new();
let mut term_groups: Vec<usize> = Vec::new();
let mut is_word: Vec<bool> = Vec::new();
for (group, word) in words.into_iter().enumerate() {
for (pos, term) in word.into_iter().enumerate() {
texts.push(term);
term_groups.push(group);
is_word.push(pos == 0);
}
}
let mut blocks = self.term_blocks(database_id, tid, index, &texts)?;
let mut fuzzy = false;
let positions = is_word.iter().zip(&term_groups);
for ((text, term_blocks), (word, group)) in
texts.iter_mut().zip(blocks.iter_mut()).zip(positions)
{
if !fuzzy_enabled || term_is_live(text, term_blocks, staged) {
continue;
}
let raw = if *word {
raw_tokens.get(*group).map_or(text.as_str(), String::as_str)
} else {
text.as_str()
};
if let Some((replacement, replacement_blocks)) =
self.fuzzy_replacement(database_id, tid, index, raw, staged)?
{
*text = replacement;
*term_blocks = replacement_blocks;
fuzzy = true;
}
}
let (negative_terms, negated) =
self.negative_terms(database_id, tid, index, &parsed.negative)?;
let (total_docs, avg_doc_len) = self
.index_stats(database_id, tid, index)
.map_err(FtsIndexError::backend)?;
Ok(Some(ResolvedQuery {
texts,
blocks,
term_groups,
fuzzy,
groups,
require_all: query.mode == QueryMode::And && groups > 1,
negative_terms,
negated,
total_docs,
avg_doc_len,
}))
}
fn fuzzy_replacement(
&self,
database_id: u64,
tid: u64,
index: IndexScope<'_>,
raw: &str,
staged: Option<&StagedView>,
) -> Result<Option<(String, TermBlocks)>, FtsIndexError<B::Error>> {
let vocabulary = staged.map(StagedView::vocabulary).unwrap_or_default();
let candidates = self.fuzzy_candidates(database_id, tid, index, raw, &vocabulary)?;
for candidate in candidates {
let mut blocks =
self.term_blocks(database_id, tid, index, std::slice::from_ref(&candidate))?;
let Some(blocks) = blocks.pop() else {
continue;
};
if term_is_live(&candidate, &blocks, staged) {
return Ok(Some((candidate, blocks)));
}
}
Ok(None)
}
fn negative_terms(
&self,
database_id: u64,
tid: u64,
index: IndexScope<'_>,
raw_negative: &[String],
) -> Result<(HashSet<String>, SurrogateBitmap), FtsIndexError<B::Error>> {
let mut negated = SurrogateBitmap::new();
if raw_negative.is_empty() {
return Ok((HashSet::new(), negated));
}
let base = self
.analyze_for_collection(
database_id,
tid,
index.collection(),
&raw_negative.join(" "),
)
.map_err(FtsIndexError::backend)?;
if base.is_empty() {
return Ok((HashSet::new(), negated));
}
let terms = self
.expand_query_with_synonyms(database_id, tid, base)
.map_err(FtsIndexError::backend)?;
for blocks in self.term_blocks(database_id, tid, index, &terms)? {
for doc_id in blocks.doc_ids() {
negated.insert(doc_id);
}
}
Ok((terms.into_iter().collect(), negated))
}
pub(crate) fn term_blocks(
&self,
database_id: u64,
tid: u64,
index: IndexScope<'_>,
texts: &[String],
) -> Result<Vec<TermBlocks>, FtsIndexError<B::Error>> {
let mut blocks = lsm_query::collect_merged_term_blocks(
&self.backend,
database_id,
tid,
index,
self.memtable(),
texts,
&self.governor,
)?;
for (text, term) in texts.iter().zip(blocks.iter_mut()) {
if term.df > 0 {
continue;
}
let postings = self
.backend
.read_postings(database_id, tid, index, text)
.map_err(FtsIndexError::backend)?;
if !postings.is_empty() {
let compact = self.compact_postings(database_id, tid, index, &postings)?;
*term = TermBlocks::from_blocks(into_blocks(compact));
}
}
Ok(blocks)
}
fn compact_postings(
&self,
database_id: u64,
tid: u64,
index: IndexScope<'_>,
postings: &[Posting],
) -> Result<Vec<CompactPosting>, FtsIndexError<B::Error>> {
let doc_ids: Vec<Surrogate> = postings.iter().map(|p| p.doc_id).collect();
let lengths = self
.backend
.read_doc_lengths(database_id, tid, index, &doc_ids)
.map_err(FtsIndexError::backend)?;
let mut compact = Vec::with_capacity(postings.len());
for (posting, length) in postings.iter().zip(lengths) {
let doc_len = match length {
Some(len) => len,
None => self
.read_fieldnorm(database_id, tid, index, posting.doc_id)
.map_err(FtsIndexError::backend)?
.unwrap_or(posting.term_freq),
};
compact.push(CompactPosting {
doc_id: posting.doc_id,
term_freq: posting.term_freq,
fieldnorm: smallfloat::encode(doc_len),
positions: posting.positions.clone(),
});
}
Ok(compact)
}
}
fn term_is_live(term: &str, blocks: &TermBlocks, staged: Option<&StagedView>) -> bool {
match staged {
None => blocks.df > 0,
Some(view) => {
(!view.hides_all() && blocks.doc_ids().any(|doc| !view.hides(doc)))
|| view.holds_term(term)
}
}
}