use std::cmp::Reverse;
use std::collections::{BTreeSet, BinaryHeap};
use std::sync::Arc;
use lance_core::Result;
use lance_core::utils::tokio::spawn_cpu;
use super::super::documents::AddressKeyedDocuments;
use super::super::query::{FtsSearchParams, Operator, Tokens};
use super::super::scorer::CombinedFieldsBM25Scorer;
use super::cursor::{CombinedTermPostings, LoadedSource, build_term_postings};
use super::{CombinedFieldColumn, unique_terms};
use crate::metrics::MetricsCollector;
use crate::prefilter::PreFilter;
use crate::vector::graph::OrderedFloat;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct RankedDoc {
score: OrderedFloat,
row_id: Reverse<u64>,
}
impl RankedDoc {
fn new(row_id: u64, score: f32) -> Self {
Self {
score: OrderedFloat(score),
row_id: Reverse(row_id),
}
}
}
pub async fn combined_fields_search(
columns: &[CombinedFieldColumn],
tokens: &Tokens,
params: &FtsSearchParams,
operator: Operator,
scorer: &CombinedFieldsBM25Scorer,
prefilter: Arc<dyn PreFilter>,
metrics: &dyn MetricsCollector,
) -> Result<(Vec<u64>, Vec<f32>)> {
let terms = unique_terms(tokens);
let limit = params.limit.unwrap_or(usize::MAX);
if terms.is_empty() || limit == 0 {
return Ok((Vec::new(), Vec::new()));
}
let mask = prefilter.mask();
let require_all_terms = operator == Operator::And;
let mut loaded: Vec<Vec<LoadedSource>> = (0..terms.len()).map(|_| Vec::new()).collect();
let mut length_sources: Vec<(f32, AddressKeyedDocuments)> = Vec::new();
for column in columns {
let weight = column.weight;
for index in &column.indices {
for partition in &index.partitions {
let docs = partition.docs.address_keyed().await?;
let is_legacy = partition.is_legacy();
for (term_index, term) in terms.iter().enumerate() {
let Some(token_id) = partition.tokens.get(term) else {
continue;
};
let posting = partition
.inverted_list
.posting_list(token_id, false, metrics)
.await?;
loaded[term_index].push(LoadedSource {
weight,
docs: docs.clone(),
is_legacy,
posting,
});
}
length_sources.push((weight, docs));
}
}
}
let scorer = Arc::new(scorer.clone());
let top = spawn_cpu(move || {
let dl_prime = |row_id: u64| -> f32 {
length_sources
.iter()
.map(|(weight, docs)| weight * docs.doc_length_at(row_id) as f32)
.sum()
};
let terms: Vec<CombinedTermPostings> = terms
.iter()
.zip(loaded)
.map(|(term, sources)| build_term_postings(term, sources, &mask, scorer.as_ref()))
.collect();
let candidates: BTreeSet<u64> = terms
.iter()
.flat_map(|term| term.postings.iter().map(|(row_id, _)| *row_id))
.collect();
let mut top: BinaryHeap<Reverse<RankedDoc>> = BinaryHeap::new();
for row_id in candidates {
let dl = dl_prime(row_id);
let mut score = 0.0f32;
let mut missing_term = false;
for term in &terms {
let tf = term.tf_prime(row_id);
if tf <= 0.0 {
missing_term = true;
continue;
}
score += term.idf * scorer.doc_weight(tf, dl);
}
if require_all_terms && missing_term {
continue;
}
top.push(Reverse(RankedDoc::new(row_id, score)));
if top.len() > limit {
top.pop();
}
}
Result::Ok(top)
})
.await?;
Ok(top
.into_sorted_vec()
.into_iter()
.map(|Reverse(doc)| (doc.row_id.0, doc.score.0))
.unzip())
}
#[cfg(test)]
mod tests {
use super::super::super::index::InvertedListFormatVersion;
use super::super::super::scorer::idf;
use super::super::super::tokenizer::document_tokenizer::DocType;
use super::super::stats::{CombinedCorpusStats, build_combined_bm25_scorer};
use super::super::testing::{
ElementRows, as_row_documents, combined_columns, combined_top_k, element_document_index,
};
use super::*;
use crate::metrics::NoOpMetricsCollector;
use crate::prefilter::NoFilter;
use rstest::rstest;
#[rstest]
#[case::v1(InvertedListFormatVersion::V1)]
#[case::v2(InvertedListFormatVersion::V2)]
#[tokio::test]
async fn test_combined_fields_scores_legacy_list_elements_by_row(
#[case] format_version: InvertedListFormatVersion,
) {
let vocab = ["alpha", "beta", "gamma", "zzz"];
let mut title: ElementRows = vec![vec![vec!["alpha"]; 10], vec![vec!["beta"]]];
title.extend((2..10).map(|_| vec![vec!["gamma"]]));
let body: ElementRows = (0..10).map(|_| vec![vec!["zzz"]]).collect();
let (title_index, _title_dir) =
element_document_index(format_version, &vocab, &title).await;
let (body_index, _body_dir) = element_document_index(format_version, &vocab, &body).await;
let title_docs = title_index.partitions[0]
.docs
.address_keyed()
.await
.unwrap();
assert_eq!(title_docs.len(), 19, "one document per title list element");
assert_eq!(title_docs.num_distinct_rows(), 10);
let terms = ["alpha".to_owned(), "beta".to_owned()];
assert_eq!(
title_index
.bm25_stats_for_terms(&terms, None)
.await
.unwrap(),
(19, 19, vec![10, 1]),
"document-granularity statistics must keep counting elements",
);
assert_eq!(
title_index
.bm25_row_stats_for_terms(&terms, None, None)
.await
.unwrap(),
(19, 10, vec![1, 1]),
"row-granularity statistics must count distinct row ids",
);
let legacy = combined_top_k(
&combined_columns(vec![title_index, body_index]),
&["alpha", "beta"],
1,
)
.await;
assert_eq!(legacy.len(), 1);
assert_eq!(legacy[0].0, 0, "row 0 matches `alpha` ten times over");
assert!(
(legacy[0].1 - 3.196_305).abs() < 1e-5,
"unexpected score {}",
legacy[0].1
);
let (row_title, _row_title_dir) = element_document_index(
InvertedListFormatVersion::V3,
&vocab,
&as_row_documents(&title),
)
.await;
let (row_body, _row_body_dir) = element_document_index(
InvertedListFormatVersion::V3,
&vocab,
&as_row_documents(&body),
)
.await;
let rebuilt = combined_top_k(
&combined_columns(vec![row_title, row_body]),
&["alpha", "beta"],
1,
)
.await;
assert_eq!(
legacy
.iter()
.map(|(row_id, score)| (*row_id, score.to_bits()))
.collect::<Vec<_>>(),
rebuilt
.iter()
.map(|(row_id, score)| (*row_id, score.to_bits()))
.collect::<Vec<_>>(),
"legacy index must score like the same data reindexed per row",
);
}
#[rstest]
#[case::v1(InvertedListFormatVersion::V1)]
#[case::v2(InvertedListFormatVersion::V2)]
#[tokio::test]
async fn test_single_column_search_keeps_element_granularity(
#[case] format_version: InvertedListFormatVersion,
) {
let vocab = ["alpha", "beta", "gamma"];
let mut title: ElementRows = vec![vec![vec!["alpha"]; 10], vec![vec!["beta"]]];
title.extend((2..10).map(|_| vec![vec!["gamma"]]));
let (index, _dir) = element_document_index(format_version, &vocab, &title).await;
let terms = ["alpha".to_owned(), "beta".to_owned()];
assert_eq!(
index.bm25_stats_for_terms(&terms, None).await.unwrap(),
(19, 19, vec![10, 1]),
"the single-column path's statistics stay at document granularity",
);
let tokens = Arc::new(Tokens::new(terms.to_vec(), DocType::Text));
let params = Arc::new(FtsSearchParams::new().with_limit(Some(4)));
let scorer = crate::scalar::inverted::build_global_bm25_scorer(
std::slice::from_ref(&index),
&tokens,
¶ms,
None,
)
.await
.unwrap();
let (row_ids, scores) = index
.bm25_search(
tokens,
params,
Operator::Or,
Arc::new(NoFilter),
Arc::new(NoOpMetricsCollector),
Some(&scorer),
)
.await
.unwrap();
assert_eq!(row_ids, vec![1, 0, 0, 0]);
assert_eq!(
scores.iter().map(|s| s.to_bits()).collect::<Vec<_>>(),
vec![0x4025_c6f0, 0x3f24_f495, 0x3f24_f495, 0x3f24_f495],
);
assert_eq!(scores[0].to_bits(), idf(1, 19).to_bits());
assert_eq!(scores[1].to_bits(), idf(10, 19).to_bits());
}
#[tokio::test]
async fn test_combined_fields_mixes_legacy_and_modern_columns() {
let vocab = ["alpha", "beta"];
let legacy_rows: ElementRows = vec![
vec![vec!["alpha"]; 6],
vec![vec!["beta"]],
vec![vec!["beta"]],
vec![vec!["beta"]],
];
let modern_rows: ElementRows = (0..4).map(|_| vec![vec!["alpha", "beta"]]).collect();
let (legacy, _legacy_dir) =
element_document_index(InvertedListFormatVersion::V2, &vocab, &legacy_rows).await;
let (modern, _modern_dir) =
element_document_index(InvertedListFormatVersion::V3, &vocab, &modern_rows).await;
let terms = ["alpha".to_owned(), "beta".to_owned()];
assert_eq!(
legacy.bm25_stats_for_terms(&terms, None).await.unwrap(),
(9, 9, vec![6, 3]),
"the fixture must still be element-per-document",
);
assert_eq!(
legacy
.bm25_row_stats_for_terms(&terms, None, None)
.await
.unwrap(),
(9, 4, vec![1, 3]),
);
assert_eq!(
modern
.bm25_row_stats_for_terms(&terms, None, None)
.await
.unwrap(),
modern.bm25_stats_for_terms(&terms, None).await.unwrap(),
"a row-per-document index must be untouched",
);
let columns = combined_columns(vec![legacy, modern.clone()]);
let mixed = combined_top_k(&columns, &["alpha", "beta"], 4).await;
let scorer = build_combined_bm25_scorer(
&columns,
&Tokens::new(vec!["alpha".to_owned(), "beta".to_owned()], DocType::Text),
CombinedCorpusStats::IndexOnly,
None,
)
.await
.unwrap();
assert_eq!(scorer.doc_count(), 4);
let (rebuilt_legacy, _rebuilt_dir) = element_document_index(
InvertedListFormatVersion::V3,
&vocab,
&as_row_documents(&legacy_rows),
)
.await;
let rebuilt = combined_top_k(
&combined_columns(vec![rebuilt_legacy, modern]),
&["alpha", "beta"],
4,
)
.await;
assert_eq!(mixed.len(), 4);
assert_eq!(
mixed
.iter()
.map(|(row_id, score)| (*row_id, score.to_bits()))
.collect::<Vec<_>>(),
rebuilt
.iter()
.map(|(row_id, score)| (*row_id, score.to_bits()))
.collect::<Vec<_>>(),
);
}
}