use nodedb_types::{Surrogate, SurrogateBitmap};
use crate::backend::FtsBackend;
use crate::index::FtsIndex;
use crate::index::error::FtsIndexError;
use crate::scope::IndexScope;
use crate::search::doc_score::term_score;
use crate::search::match_mode::{MatchMode, staged_candidates};
use crate::search::query_terms::{ResolvedQuery, TextQuery};
use crate::search::staged::{StagedView, staged_contributions};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DocScore {
Match(f32),
Miss,
Absent,
}
pub struct DocScorer<'a, B: FtsBackend> {
fts: &'a FtsIndex<B>,
database_id: u64,
tid: u64,
index: IndexScope<'a>,
staged: Option<StagedView>,
resolved: Option<(ResolvedQuery, MatchMode)>,
}
impl<B: FtsBackend> FtsIndex<B> {
pub fn doc_scorer<'a>(
&'a self,
database_id: u64,
tid: u64,
index: impl Into<IndexScope<'a>>,
query: TextQuery<'_>,
eligible: Option<&SurrogateBitmap>,
staged: Option<StagedView>,
) -> Result<DocScorer<'a, B>, FtsIndexError<B::Error>> {
let index = index.into();
let resolved = match self.resolve_query(database_id, tid, index, query, staged.as_ref())? {
Some(resolved) => {
let mut deny = resolved.negated.clone();
if let Some(view) = staged.as_ref() {
deny.union_in_place(view.hidden());
}
let index_visible = staged.as_ref().is_none_or(|view| !view.hides_all());
let candidates =
staged_candidates(&resolved, staged.as_ref(), eligible, &self.bm25_params);
let mode = resolved.match_mode(index_visible, eligible, &deny, &candidates);
Some((resolved, mode))
}
None => None,
};
Ok(DocScorer {
fts: self,
database_id,
tid,
index,
staged,
resolved,
})
}
}
impl<B: FtsBackend> DocScorer<'_, B> {
pub fn score(&self, docs: &[Surrogate]) -> Result<Vec<DocScore>, FtsIndexError<B::Error>> {
let mut scores = vec![DocScore::Absent; docs.len()];
let mut membership: Vec<(usize, Surrogate)> = Vec::new();
for (slot, doc_id) in docs.iter().enumerate() {
if let Some(view) = self.staged.as_ref() {
if let Some(doc) = view.doc(*doc_id) {
scores[slot] = self
.staged_match(doc)
.map_or(DocScore::Miss, DocScore::Match);
continue;
}
if view.hides(*doc_id) {
continue;
}
}
match self.indexed_match(*doc_id) {
Some(score) => scores[slot] = DocScore::Match(score),
None => membership.push((slot, *doc_id)),
}
}
if membership.is_empty() {
return Ok(scores);
}
let ids: Vec<Surrogate> = membership.iter().map(|(_, doc_id)| *doc_id).collect();
let lengths = self
.fts
.backend
.read_doc_lengths(self.database_id, self.tid, self.index, &ids)
.map_err(FtsIndexError::backend)?;
for ((slot, _), length) in membership.into_iter().zip(lengths) {
if length.is_some() {
scores[slot] = DocScore::Miss;
}
}
Ok(scores)
}
fn staged_match(&self, doc: &crate::search::staged::StagedDoc) -> Option<f32> {
let (query, mode) = self.resolved.as_ref()?;
let contributions = staged_contributions(query, doc, &self.fts.bm25_params)?;
mode.score(query, &contributions)
}
fn indexed_match(&self, doc_id: Surrogate) -> Option<f32> {
let (query, mode) = self.resolved.as_ref()?;
if query.negated.contains(doc_id) {
return None;
}
let params = &self.fts.bm25_params;
let contributions: Vec<Option<f32>> = query
.blocks
.iter()
.map(|blocks| {
blocks.lookup(doc_id).map(|(tf, fieldnorm)| {
term_score(
crate::bm25::idf(blocks.df, query.total_docs),
tf,
fieldnorm,
query.avg_doc_len,
params,
)
})
})
.collect();
mode.score(query, &contributions)
}
}
#[cfg(test)]
mod tests {
use nodedb_types::{Surrogate, SurrogateBitmap};
use super::DocScore;
use crate::backend::memory::MemoryBackend;
use crate::index::FtsIndex;
use crate::posting::QueryMode;
use crate::search::bm25_search::FtsSearchParams;
use crate::search::query_terms::TextQuery;
use crate::search::staged::{StagedDoc, StagedView};
use crate::test_support::test_governor;
const DB: u64 = 0;
const T: u64 = 1;
fn query(text: &str) -> TextQuery<'_> {
TextQuery {
query: text,
fuzzy_enabled: false,
mode: QueryMode::And,
}
}
#[test]
fn point_scores_equal_search_scores() {
let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
idx.index_document(DB, T, "docs", Surrogate(1), "rust systems language")
.unwrap();
idx.index_document(DB, T, "docs", Surrogate(2), "rust rust web")
.unwrap();
idx.index_document(DB, T, "docs", Surrogate(3), "python scripting")
.unwrap();
let hits = idx
.search(
DB,
T,
"docs",
FtsSearchParams {
query: "rust",
top_k: 10,
fuzzy_enabled: false,
mode: QueryMode::And,
prefilter: None,
},
)
.unwrap();
let scorer = idx
.doc_scorer(DB, T, "docs", query("rust"), None, None)
.unwrap();
let docs: Vec<Surrogate> = hits.iter().map(|h| h.doc_id).collect();
let scores = scorer.score(&docs).unwrap();
for (hit, score) in hits.iter().zip(scores) {
assert_eq!(score, DocScore::Match(hit.score));
}
assert_eq!(
scorer.score(&[Surrogate(3), Surrogate(99)]).unwrap(),
vec![DocScore::Miss, DocScore::Absent]
);
}
#[test]
fn fallback_follows_the_eligible_rows() {
let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
idx.index_document(DB, T, "docs", Surrogate(1), "alpha bravo")
.unwrap();
idx.index_document(DB, T, "docs", Surrogate(2), "alpha charlie")
.unwrap();
let all = idx
.doc_scorer(DB, T, "docs", query("alpha bravo"), None, None)
.unwrap();
assert_eq!(all.score(&[Surrogate(2)]).unwrap(), vec![DocScore::Miss]);
let mut eligible = SurrogateBitmap::new();
eligible.insert(Surrogate(2));
let scoped = idx
.doc_scorer(DB, T, "docs", query("alpha bravo"), Some(&eligible), None)
.unwrap();
assert!(matches!(
scoped.score(&[Surrogate(2)]).unwrap()[0],
DocScore::Match(_)
));
}
#[test]
fn staged_documents_score_and_hidden_documents_are_absent() {
let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
idx.index_document(DB, T, "docs", Surrogate(1), "rust language")
.unwrap();
let mut hidden = SurrogateBitmap::new();
hidden.insert(Surrogate(1));
let view = StagedView::new(
hidden,
false,
vec![StagedDoc {
doc_id: Surrogate(2),
tokens: vec!["rust".into()],
}],
);
let scorer = idx
.doc_scorer(DB, T, "docs", query("rust"), None, Some(view))
.unwrap();
let scores = scorer.score(&[Surrogate(1), Surrogate(2)]).unwrap();
assert_eq!(scores[0], DocScore::Absent);
assert!(matches!(scores[1], DocScore::Match(_)));
}
}