use std::collections::HashMap;
use std::sync::Arc;
use lance_core::Result;
use lance_select::RowAddrMask;
use super::super::query::Tokens;
use super::super::scorer::CombinedFieldsBM25Scorer;
use super::{CombinedFieldColumn, unique_terms};
use crate::metrics::MetricsCollector;
#[derive(Debug, Default, Clone)]
pub struct FlatFieldStats {
pub doc_counts: Vec<usize>,
pub total_tokens: Vec<u64>,
pub doc_freqs: Vec<Vec<usize>>,
}
impl FlatFieldStats {
pub(in crate::scalar::inverted) fn zeros(num_columns: usize, num_terms: usize) -> Self {
Self {
doc_counts: vec![0; num_columns],
total_tokens: vec![0; num_columns],
doc_freqs: vec![vec![0; num_terms]; num_columns],
}
}
pub(in crate::scalar::inverted) fn fold_row(
&mut self,
row_id: u64,
stats_masks: &[Arc<RowAddrMask>],
doc_lengths: &[u64],
term_counts: &[u64],
) {
let num_terms = self.doc_freqs.first().map_or(0, |freqs| freqs.len());
for (column, doc_count) in self.doc_counts.iter_mut().enumerate() {
if doc_lengths[column] == 0 || !stats_masks[column].selected(row_id) {
continue;
}
*doc_count += 1;
self.total_tokens[column] += doc_lengths[column];
let base = column * num_terms;
for (term, doc_freq) in self.doc_freqs[column].iter_mut().enumerate() {
if term_counts[base + term] > 0 {
*doc_freq += 1;
}
}
}
}
pub(in crate::scalar::inverted) fn merge(&mut self, other: Self) {
for (total, part) in self.doc_counts.iter_mut().zip(other.doc_counts) {
*total += part;
}
for (total, part) in self.total_tokens.iter_mut().zip(other.total_tokens) {
*total += part;
}
for (total, part) in self.doc_freqs.iter_mut().zip(other.doc_freqs) {
for (total, part) in total.iter_mut().zip(part) {
*total += part;
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum CombinedCorpusStats<'a> {
IndexOnly,
Blended(&'a FlatFieldStats),
FlatOnly(&'a FlatFieldStats),
}
impl<'a> CombinedCorpusStats<'a> {
pub fn for_flat_scan(flat: &'a FlatFieldStats, covers_whole_corpus: bool) -> Self {
if covers_whole_corpus {
Self::FlatOnly(flat)
} else {
Self::Blended(flat)
}
}
fn flat(&self) -> Option<&'a FlatFieldStats> {
match self {
Self::IndexOnly => None,
Self::Blended(flat) | Self::FlatOnly(flat) => Some(flat),
}
}
fn folds_index_stats(&self) -> bool {
!matches!(self, Self::FlatOnly(_))
}
}
pub async fn build_combined_bm25_scorer(
columns: &[CombinedFieldColumn],
tokens: &Tokens,
corpus: CombinedCorpusStats<'_>,
metrics: Option<&dyn MetricsCollector>,
) -> Result<CombinedFieldsBM25Scorer> {
let terms = unique_terms(tokens);
if let Some(flat) = corpus.flat() {
debug_assert_eq!(flat.doc_counts.len(), columns.len());
debug_assert!(flat.doc_freqs.iter().all(|df| df.len() == terms.len()));
}
let mut doc_count = 0usize;
let mut sum_total_term_freq = 0f64;
let mut doc_freq: HashMap<String, usize> = terms.iter().map(|t| (t.clone(), 0)).collect();
for (column_slot, column) in columns.iter().enumerate() {
let mut column_num_docs = 0usize;
let mut column_total_tokens = 0u64;
let mut column_doc_freq = vec![0usize; terms.len()];
if corpus.folds_index_stats() {
for index in &column.indices {
let (total_tokens, num_docs, token_docs) = index
.bm25_row_stats_for_terms(&terms, column.stale_rows.as_deref(), metrics)
.await?;
column_total_tokens += total_tokens;
column_num_docs += num_docs;
for (slot, df) in token_docs.into_iter().enumerate() {
column_doc_freq[slot] += df;
}
}
}
if let Some(flat) = corpus.flat() {
column_num_docs += flat.doc_counts[column_slot];
column_total_tokens += flat.total_tokens[column_slot];
for (slot, df) in flat.doc_freqs[column_slot].iter().enumerate() {
column_doc_freq[slot] += df;
}
}
doc_count = doc_count.max(column_num_docs);
sum_total_term_freq += column.weight as f64 * column_total_tokens as f64;
for (term, df) in terms.iter().zip(column_doc_freq) {
let entry = doc_freq
.get_mut(term)
.expect("doc_freq initialized for every term");
*entry = (*entry).max(df);
}
}
let avg_doc_length = if doc_count > 0 {
(sum_total_term_freq / doc_count as f64) as f32
} else {
0.0
};
Ok(CombinedFieldsBM25Scorer::new(
doc_count,
avg_doc_length,
doc_freq,
))
}
#[cfg(test)]
mod tests {
use super::super::super::index::InvertedListFormatVersion;
use super::super::super::tokenizer::InvertedIndexParams;
use super::super::super::tokenizer::document_tokenizer::DocType;
use super::super::flat::flat_combined_fields_search_stream;
use super::super::testing::{
ElementRows, combined_columns, element_document_index, flat_input, flat_scores,
};
use super::*;
use crate::metrics::LocalMetricsCollector;
use crate::scalar::inverted::query::Operator;
use lance_select::RowAddrTreeMap;
use rstest::rstest;
#[test]
fn test_flat_field_stats_fold_is_split_invariant() {
let rows: [(u64, [u64; 2], [u64; 4]); 3] = [
(7, [4, 0], [1, 0, 0, 0]),
(8, [3, 5], [1, 1, 2, 0]),
(9, [2, 6], [0, 1, 1, 1]),
];
let masks = vec![
Arc::new(RowAddrMask::all_rows()),
Arc::new(RowAddrMask::all_rows().also_block(RowAddrTreeMap::from_iter([9u64]))),
];
let fold = |rows: &[(u64, [u64; 2], [u64; 4])]| {
let mut stats = FlatFieldStats::zeros(2, 2);
for (row_id, lengths, counts) in rows {
stats.fold_row(*row_id, &masks, lengths, counts);
}
stats
};
let whole = fold(&rows);
assert_eq!(whole.doc_counts, vec![3, 1]);
assert_eq!(whole.total_tokens, vec![9, 5]);
assert_eq!(whole.doc_freqs, vec![vec![2, 2], vec![1, 0]]);
for split in 0..=rows.len() {
let mut merged = fold(&rows[..split]);
merged.merge(fold(&rows[split..]));
assert_eq!(merged.doc_counts, whole.doc_counts, "split at {split}");
assert_eq!(merged.total_tokens, whole.total_tokens, "split at {split}");
assert_eq!(merged.doc_freqs, whole.doc_freqs, "split at {split}");
}
}
#[rstest]
#[case::modern(InvertedListFormatVersion::V3)]
#[case::legacy_elements(InvertedListFormatVersion::V2)]
#[tokio::test]
async fn test_combined_scorer_build_reports_index_cache_lookups(
#[case] format_version: InvertedListFormatVersion,
) {
let vocab = ["alpha", "beta"];
let rows: ElementRows = vec![vec![vec!["alpha"], vec!["beta"]], vec![vec!["alpha"]]];
let (first, _first_dir) = element_document_index(format_version, &vocab, &rows).await;
let (second, _second_dir) = element_document_index(format_version, &vocab, &rows).await;
let columns = combined_columns(vec![first, second]);
let tokens = Tokens::new(vec!["alpha".to_owned(), "beta".to_owned()], DocType::Text);
let expected_lookups = 2 * columns.len();
let indexed = LocalMetricsCollector::default();
build_combined_bm25_scorer(
&columns,
&tokens,
CombinedCorpusStats::IndexOnly,
Some(&indexed),
)
.await
.unwrap();
assert_eq!(
indexed.index_cache_hits() + indexed.index_cache_misses(),
expected_lookups,
"indexed scorer build must report every cache lookup it makes",
);
let flat = LocalMetricsCollector::default();
let stream = flat_combined_fields_search_stream(
flat_input(&[], &[Vec::new(), Vec::new()], 1),
&columns,
vec![1, 2],
&[
Arc::new(RowAddrMask::all_rows()),
Arc::new(RowAddrMask::all_rows()),
],
None,
false,
&tokens,
InvertedIndexParams::default().build().unwrap(),
Operator::Or,
16,
None,
Some(&flat),
)
.await
.unwrap()
.0;
assert!(flat_scores(stream).await.1.is_empty());
assert_eq!(
flat.index_cache_hits() + flat.index_cache_misses(),
expected_lookups,
"flat scorer build must report every cache lookup it makes",
);
}
#[rstest]
#[case::v1(InvertedListFormatVersion::V1)]
#[case::v2(InvertedListFormatVersion::V2)]
#[case::v3(InvertedListFormatVersion::V3)]
#[tokio::test]
async fn test_row_stats_match_document_stats_without_list_multiplicity(
#[case] format_version: InvertedListFormatVersion,
) {
let vocab = ["alpha", "beta"];
let rows: ElementRows = vec![
vec![vec!["alpha", "beta", "alpha"]],
vec![vec!["beta"]],
vec![Vec::new()],
vec![vec!["alpha"]],
];
let (index, _dir) = element_document_index(format_version, &vocab, &rows).await;
let docs = index.partitions[0].docs.address_keyed().await.unwrap();
assert_eq!(docs.len(), 3, "the empty list must not become a document");
assert_eq!(docs.num_distinct_rows(), docs.len());
let terms = ["alpha".to_owned(), "beta".to_owned(), "absent".to_owned()];
let documents = index.bm25_stats_for_terms(&terms, None).await.unwrap();
assert_eq!(documents, (5, 3, vec![2, 2, 0]));
assert_eq!(
index
.bm25_row_stats_for_terms(&terms, None, None)
.await
.unwrap(),
documents
);
}
#[rstest]
#[case::v1(InvertedListFormatVersion::V1)]
#[case::v2(InvertedListFormatVersion::V2)]
#[tokio::test]
async fn test_row_stats_skip_rows_a_legacy_list_index_never_indexed(
#[case] format_version: InvertedListFormatVersion,
) {
let vocab = ["alpha", "beta"];
let rows: ElementRows = vec![
vec![vec!["alpha"], Vec::new(), vec!["alpha"], vec!["beta"]],
Vec::new(),
vec![vec!["beta"]],
vec![Vec::new(), Vec::new()],
];
let (index, _dir) = element_document_index(format_version, &vocab, &rows).await;
let docs = index.partitions[0].docs.address_keyed().await.unwrap();
assert_eq!(docs.len(), 4, "only rows 0 and 2 own documents");
assert_eq!(docs.num_distinct_rows(), 2);
for unindexed in [1u64, 3] {
assert_eq!(docs.doc_length_at(unindexed), 0);
}
let terms = ["alpha".to_owned(), "beta".to_owned()];
assert_eq!(
index.bm25_stats_for_terms(&terms, None).await.unwrap(),
(4, 4, vec![2, 2]),
);
assert_eq!(
index
.bm25_row_stats_for_terms(&terms, None, None)
.await
.unwrap(),
(4, 2, vec![1, 2]),
);
}
}