use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::vec;
use super::dataset_index::nested_fts_batch;
use crate::Dataset;
use crate::dataset::write::{WriteMode, WriteParams};
use crate::index::DatasetIndexExt;
use crate::utils::test::copy_test_data_to_tmp;
use arrow::array::{AsArray, GenericListBuilder, GenericStringBuilder};
use arrow::datatypes::UInt64Type;
use arrow_array::RecordBatch;
use arrow_array::record_batch;
use arrow_array::{Array, GenericStringArray, ListArray, StructArray};
use arrow_array::{
ArrayRef, Int32Array, RecordBatchIterator, StringArray,
types::{Float32Type, Int32Type, Int64Type},
};
use arrow_buffer::{OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType, Field as ArrowField, Fields as ArrowFields, Schema as ArrowSchema};
use lance_core::ROW_ID;
use lance_core::utils::tempfile::TempStrDir;
use lance_index::optimize::OptimizeOptions;
use lance_index::scalar::FullTextSearchQuery;
use lance_index::scalar::inverted::{
Language,
query::{MatchQuery, Operator},
tokenizer::InvertedIndexParams,
};
use lance_index::{IndexType, scalar::ScalarIndexParams};
use lance_select::{RowAddrMask, RowAddrTreeMap};
use lance_index::scalar::inverted::builder::BLOCK_SIZE;
use lance_index::scalar::inverted::oracle::{brute_force_bm25f, brute_force_ids};
use lance_index::scalar::inverted::query::{CombinedFieldsQuery, FtsQuery, MultiMatchQuery};
use rstest::rstest;
fn combined_fields_test_params() -> InvertedIndexParams {
InvertedIndexParams::new("simple".to_string(), Language::English)
.lower_case(true)
.stem(false)
.remove_stop_words(false)
.ascii_folding(false)
.max_token_length(None)
}
fn combined_fields_batch(ids: Vec<i32>, titles: Vec<&str>, bodies: Vec<&str>) -> RecordBatch {
record_batch!(
("id", Int32, ids),
("title", Utf8, titles),
("body", Utf8, bodies)
)
.unwrap()
}
async fn write_fts_dataset(
uri: &str,
batch: RecordBatch,
write_params: Option<WriteParams>,
) -> Dataset {
let schema = batch.schema();
Dataset::write(
RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema),
uri,
write_params,
)
.await
.unwrap()
}
async fn append_fts_dataset(
uri: &str,
batch: RecordBatch,
max_rows_per_file: Option<usize>,
) -> Dataset {
let mut params = WriteParams {
mode: WriteMode::Append,
..Default::default()
};
if let Some(max_rows_per_file) = max_rows_per_file {
params.max_rows_per_file = max_rows_per_file;
}
write_fts_dataset(uri, batch, Some(params)).await
}
async fn create_inverted_indices(
dataset: &mut Dataset,
columns: &[&str],
params: &InvertedIndexParams,
) {
for &column in columns {
dataset
.create_index(&[column], IndexType::Inverted, None, params, true)
.await
.unwrap();
}
}
async fn indexed_two_column_dataset(
titles: &[&str],
bodies: &[&str],
max_rows_per_file: Option<usize>,
) -> (TempStrDir, Dataset) {
let ids = (0..titles.len() as i32).collect();
let batch = combined_fields_batch(ids, titles.to_vec(), bodies.to_vec());
let write_params = max_rows_per_file.map(|max_rows_per_file| WriteParams {
max_rows_per_file,
..Default::default()
});
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, write_params).await;
let params = combined_fields_test_params();
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
(test_uri, dataset)
}
fn combined_query(terms: &str, operator: Operator) -> FtsQuery {
combined_query_with_boosts(terms, operator, None)
}
fn combined_query_with_boosts(
terms: &str,
operator: Operator,
boosts: Option<Vec<f32>>,
) -> FtsQuery {
combined_query_over(&["title", "body"], terms, operator, boosts)
}
fn combined_query_over(
columns: &[&str],
terms: &str,
operator: Operator,
boosts: Option<Vec<f32>>,
) -> FtsQuery {
let query = CombinedFieldsQuery::try_new(
terms.to_string(),
columns.iter().map(|c| c.to_string()).collect(),
)
.unwrap();
let query = match boosts {
Some(boosts) => query.try_with_boosts(boosts).unwrap(),
None => query,
};
FtsQuery::CombinedFields(query.with_operator(operator))
}
async fn fts_result_ids(dataset: &Dataset, query: FtsQuery) -> Vec<i32> {
fts_result_id_scores(dataset, query, None)
.await
.into_iter()
.map(|(id, _)| id)
.collect()
}
async fn fts_result_id_scores(
dataset: &Dataset,
query: FtsQuery,
limit: Option<i64>,
) -> Vec<(i32, f32)> {
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(query).limit(limit))
.unwrap()
.try_into_batch()
.await
.unwrap();
let ids = batch["id"].as_primitive::<Int32Type>();
let scores = batch["_score"].as_primitive::<Float32Type>();
(0..batch.num_rows())
.map(|i| (ids.value(i), scores.value(i)))
.collect()
}
fn unique_ids(ids: Vec<i32>) -> HashSet<i32> {
let set: HashSet<i32> = ids.iter().copied().collect();
assert_eq!(set.len(), ids.len(), "duplicate row ids emitted: {ids:?}");
set
}
fn assert_matches_brute_force(actual: &[(i32, f32)], expected: &[Option<f32>], context: &str) {
let expected_ids = brute_force_ids(expected);
let actual_ids: HashSet<i32> = actual.iter().map(|(id, _)| *id).collect();
assert_eq!(
actual.len(),
expected_ids.len(),
"duplicate row emitted ({context}): {actual:?}"
);
assert_eq!(actual_ids, expected_ids, "matched ids differ ({context})");
for (id, score) in actual {
let want = expected[*id as usize].expect("scan returned an unmatched doc");
assert!(
(score - want).abs() < 1e-3,
"score mismatch for id {id} ({context}): scan={score}, brute force={want}"
);
}
}
fn assert_topk_matches_brute_force(
actual: &[(i32, f32)],
expected: &[Option<f32>],
k: usize,
context: &str,
) {
let mut expected_ranked: Vec<f32> = expected.iter().filter_map(|score| *score).collect();
expected_ranked.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
assert!(
!expected_ranked.is_empty(),
"the oracle matched nothing, so there is nothing to compare ({context})"
);
assert_eq!(
actual.len(),
expected_ranked.len().min(k),
"hit count mismatch for k={k} ({context})"
);
let mut actual_scores: Vec<f32> = actual.iter().map(|(_, score)| *score).collect();
actual_scores.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
for (got, want) in actual_scores.iter().zip(&expected_ranked) {
assert!(
(got - want).abs() < 1e-3,
"top-{k} score mismatch ({context}): pruned={actual_scores:?} exact={expected_ranked:?}"
);
}
for (id, score) in actual {
let want = expected[*id as usize].expect("scan returned an unmatched doc");
assert!(
(score - want).abs() < 1e-3,
"score mismatch for id {id} at k={k} ({context}): scan={score}, brute force={want}"
);
}
let returned_ids = unique_ids(actual.iter().map(|(id, _)| *id).collect());
let cutoff = expected_ranked[actual.len() - 1];
for id in &returned_ids {
let want = expected[*id as usize].expect("scan returned an unmatched doc");
assert!(
want >= cutoff - 1e-3,
"id {id} scores {want}, below the top-{k} cutoff {cutoff} ({context})"
);
}
}
#[tokio::test]
async fn test_fts_combined_fields_cross_field_and() {
let params = InvertedIndexParams::default();
let batch = combined_fields_batch(
vec![0, 1, 2],
vec!["john", "john smith", "john"],
vec!["smith", "foo", "alice"],
);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(
&test_uri,
batch,
Some(WriteParams {
max_rows_per_file: 1,
..Default::default()
}),
)
.await;
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
let columns = vec!["title".to_string(), "body".to_string()];
let combined = |op| combined_query("john smith", op);
let multi = |op| {
FtsQuery::MultiMatch(
MultiMatchQuery::try_new("john smith".to_string(), columns.clone())
.unwrap()
.with_operator(op),
)
};
assert_eq!(
unique_ids(fts_result_ids(&dataset, combined(Operator::And)).await),
HashSet::from([0, 1])
);
assert_eq!(
unique_ids(fts_result_ids(&dataset, multi(Operator::And)).await),
HashSet::from([1])
);
assert_eq!(
unique_ids(fts_result_ids(&dataset, combined(Operator::Or)).await),
HashSet::from([0, 1, 2])
);
assert_eq!(
unique_ids(fts_result_ids(&dataset, multi(Operator::Or)).await),
HashSet::from([0, 1, 2])
);
}
#[tokio::test]
async fn test_fts_combined_fields_boost_ranking() {
let params = InvertedIndexParams::new("simple".to_string(), Language::English)
.stem(false)
.remove_stop_words(false);
let batch = combined_fields_batch(
vec![0, 1],
vec!["lance", "other"],
vec!["other other other", "lance"],
);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
let combined =
|boosts: Vec<f32>| combined_query_with_boosts("lance", Operator::Or, Some(boosts));
let scores = |ids: Vec<(i32, f32)>| ids.into_iter().collect::<HashMap<i32, f32>>();
let equal = scores(fts_result_id_scores(&dataset, combined(vec![1.0, 1.0]), None).await);
assert!(
equal[&1] > equal[&0],
"equal weights: {equal:?} should rank id 1 above id 0"
);
let boosted = scores(fts_result_id_scores(&dataset, combined(vec![3.0, 1.0]), None).await);
assert!(
boosted[&0] > boosted[&1],
"title-boosted: {boosted:?} should rank id 0 above id 1"
);
}
#[tokio::test]
async fn test_fts_combined_fields_nulls() {
let params = InvertedIndexParams::default();
let batch = record_batch!(
("id", Int32, [0, 1, 2, 3]),
("title", Utf8, [Some("lance"), None, None, Some("lance")]),
("body", Utf8, [None, Some("lance"), None, Some("lance")]),
("empty", Utf8, [None::<&str>, None, None, None])
)
.unwrap();
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, &["title", "body", "empty"], ¶ms).await;
let query = combined_query("lance", Operator::Or);
assert_eq!(
unique_ids(fts_result_ids(&dataset, query).await),
HashSet::from([0, 1, 3])
);
let with_empty = combined_query_over(&["title", "empty"], "lance", Operator::Or, None);
assert_eq!(
unique_ids(fts_result_ids(&dataset, with_empty).await),
HashSet::from([0, 3])
);
}
#[tokio::test]
async fn test_fts_combined_fields_matches_brute_force_bm25f() {
let titles = vec!["aa bb", "aa", "cc dd", "aa aa bb", "cc"];
let bodies = vec!["cc", "bb cc dd", "aa", "dd", "aa bb"];
let (_test_uri, dataset) = indexed_two_column_dataset(&titles, &bodies, None).await;
let weights = [2.0f32, 1.0f32];
let query = combined_query_with_boosts("aa bb", Operator::Or, Some(weights.to_vec()));
let expected = brute_force_bm25f(
&[(weights[0], titles.clone()), (weights[1], bodies.clone())],
"aa bb",
false,
);
let actual = fts_result_id_scores(&dataset, query, None).await;
assert_matches_brute_force(&actual, &expected, "the fully-indexed two-column scan");
}
#[rstest]
#[case::v1("v3.0.1/fts_v1", 1)]
#[case::v2("v4.0.1/fts_v2", 2)]
#[tokio::test]
async fn test_fts_combined_fields_on_released_format_fixture(
#[case] fixture_path: &str,
#[case] expected_version: i32,
) {
let test_dir = copy_test_data_to_tmp(fixture_path).unwrap();
let dataset = Dataset::open(&test_dir.path_str()).await.unwrap();
let indices = dataset.load_indices().await.unwrap();
assert_eq!(indices.len(), 1);
assert_eq!(indices[0].index_version, expected_version);
const NUM_ROWS: usize = 300;
let texts: Vec<&str> = (0..NUM_ROWS)
.map(|id| {
if id % 3 == 0 {
"lance database compatibility shared"
} else {
"database lance compatibility shared"
}
})
.collect();
let expected = brute_force_bm25f(&[(1.0, texts)], "lance compatibility", false);
let query = combined_query_over(&["text"], "lance compatibility", Operator::Or, None);
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(query))
.unwrap()
.try_into_batch()
.await
.unwrap();
let ids = batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int64Type>();
let scores = batch
.column_by_name("_score")
.unwrap()
.as_primitive::<Float32Type>();
assert_eq!(batch.num_rows(), NUM_ROWS);
for i in 0..batch.num_rows() {
let id = ids.value(i) as usize;
let want = expected[id].expect("every fixture row carries both query terms");
assert!(
(scores.value(i) - want).abs() < 1e-3,
"score mismatch for id {id}: scan={}, brute force={want}",
scores.value(i),
);
}
}
#[rstest]
#[case::single_partition(None)]
#[case::multi_partition(Some(7))]
#[tokio::test]
async fn test_fts_combined_fields_topk_matches_brute_force(
#[case] max_rows_per_file: Option<usize>,
) {
let n = 40usize;
let titles: Vec<String> = (0..n)
.map(|i| {
if i % 9 == 0 {
"zeta gamma".to_string()
} else {
"gamma".to_string()
}
})
.collect();
let bodies: Vec<String> = (0..n)
.map(|i| {
let beta = vec!["beta"; 1 + i % 3].join(" ");
if i == 3 { format!("{beta} zeta") } else { beta }
})
.collect();
let title_refs: Vec<&str> = titles.iter().map(|s| s.as_str()).collect();
let body_refs: Vec<&str> = bodies.iter().map(|s| s.as_str()).collect();
let (_test_uri, dataset) =
indexed_two_column_dataset(&title_refs, &body_refs, max_rows_per_file).await;
let weights = [2.0f32, 1.0f32];
for operator in [Operator::Or, Operator::And] {
let require_all = operator == Operator::And;
let query_str = if require_all {
"zeta beta"
} else {
"zeta beta missingterm"
};
let expected = brute_force_bm25f(
&[
(weights[0], title_refs.clone()),
(weights[1], body_refs.clone()),
],
query_str,
require_all,
);
for k in [1usize, 2, 3, 5, 10, 50] {
let query = combined_query_with_boosts(query_str, operator, Some(weights.to_vec()));
let actual = fts_result_id_scores(&dataset, query, Some(k as i64)).await;
assert_topk_matches_brute_force(&actual, &expected, k, &format!("op={operator:?}"));
}
}
}
#[rstest]
#[case::single_fragment(None)]
#[case::multi_fragment(Some(BLOCK_SIZE))]
#[tokio::test]
async fn test_fts_combined_fields_topk_matches_brute_force_across_blocks(
#[case] max_rows_per_file: Option<usize>,
) {
const CORPUS_BLOCKS: usize = 16;
let num_docs = CORPUS_BLOCKS * BLOCK_SIZE;
let has_zeta = |doc: usize| [3, 703, 1403].contains(&doc);
let has_beta = |doc: usize| doc % 5 >= 2;
let has_alpha = |doc: usize| doc.is_multiple_of(7);
let has_beta_in_title = |doc: usize| doc % 5 == 3;
let count = |carries: &dyn Fn(usize) -> bool| (0..num_docs).filter(|&d| carries(d)).count();
let titles: Vec<String> = (0..num_docs)
.map(|doc| {
let mut title = if has_zeta(doc) {
"zeta gamma".to_string()
} else {
"gamma".to_string()
};
if has_beta_in_title(doc) {
title.push_str(" beta");
}
title
})
.collect();
let bodies: Vec<String> = (0..num_docs)
.map(|doc| {
let mut body = if has_beta(doc) {
vec!["beta"; 1 + doc % 3].join(" ")
} else {
"delta".to_string()
};
if has_alpha(doc) {
body.push_str(" alpha");
}
body
})
.collect();
let beta_docs = count(&has_beta);
let zeta_docs = count(&has_zeta);
let alpha_docs = count(&has_alpha);
assert!(
beta_docs > 4 * BLOCK_SIZE && beta_docs < num_docs,
"beta must span several blocks without covering the corpus, got {beta_docs} of {num_docs}"
);
assert!(
zeta_docs < beta_docs / BLOCK_SIZE,
"zeta ({zeta_docs} docs) must stay under beta's block count ({})",
beta_docs / BLOCK_SIZE
);
assert!(
2 * alpha_docs < beta_docs && 2 * zeta_docs < beta_docs,
"the rare terms must stay well below beta ({beta_docs}): zeta={zeta_docs} alpha={alpha_docs}"
);
let beta_title_docs = count(&has_beta_in_title);
assert!(
beta_title_docs > 0 && count(&|doc| has_beta_in_title(doc) && !has_beta(doc)) == 0,
"beta needs a second source in `title`, drawn from its own documents, got {beta_title_docs}"
);
let title_refs: Vec<&str> = titles.iter().map(|title| title.as_str()).collect();
let body_refs: Vec<&str> = bodies.iter().map(|body| body.as_str()).collect();
let (_test_uri, dataset) =
indexed_two_column_dataset(&title_refs, &body_refs, max_rows_per_file).await;
let weights = vec![2.0f32, 1.0f32];
for (terms, operator, want_matches) in [
(
"zeta beta",
Operator::Or,
count(&|doc| has_zeta(doc) || has_beta(doc)),
),
(
"alpha beta",
Operator::Or,
count(&|doc| has_alpha(doc) || has_beta(doc)),
),
(
"zeta beta",
Operator::And,
count(&|doc| has_zeta(doc) && has_beta(doc)),
),
] {
let expected = brute_force_bm25f(
&[
(weights[0], title_refs.clone()),
(weights[1], body_refs.clone()),
],
terms,
operator == Operator::And,
);
assert_eq!(
brute_force_ids(&expected).len(),
want_matches,
"corpus no longer matches as intended for {terms:?} {operator:?}"
);
assert!(want_matches > 0, "no matches for {terms:?} {operator:?}");
for k in [1usize, 10, 100, num_docs] {
let query = combined_query_with_boosts(terms, operator, Some(weights.clone()));
let actual = fts_result_id_scores(&dataset, query, Some(k as i64)).await;
assert_topk_matches_brute_force(
&actual,
&expected,
k,
&format!("terms={terms:?} op={operator:?}"),
);
}
}
}
#[tokio::test]
async fn test_fts_combined_fields_tokenizer_validation() {
let with_pos = InvertedIndexParams::new("simple".to_string(), Language::English)
.with_position(true)
.stem(false)
.remove_stop_words(false);
let without_pos = InvertedIndexParams::new("simple".to_string(), Language::English)
.with_position(false)
.stem(false)
.remove_stop_words(false);
let whitespace = InvertedIndexParams::new("whitespace".to_string(), Language::English)
.stem(false)
.remove_stop_words(false);
let batch = record_batch!(
("id", Int32, [0, 1]),
("title", Utf8, ["aa", "bb"]),
("body", Utf8, ["bb", "aa"]),
("alt", Utf8, ["aa", "aa"])
)
.unwrap();
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, &["title"], &with_pos).await;
create_inverted_indices(&mut dataset, &["body"], &without_pos).await;
create_inverted_indices(&mut dataset, &["alt"], &whitespace).await;
let accepted = combined_query("aa", Operator::Or);
assert_eq!(
unique_ids(fts_result_ids(&dataset, accepted).await),
HashSet::from([0, 1])
);
let rejected = combined_query_over(&["title", "alt"], "aa", Operator::Or, None);
let result = dataset
.scan()
.full_text_search(FullTextSearchQuery::new_query(rejected))
.unwrap()
.try_into_batch()
.await;
let message = result
.expect_err("expected a tokenizer-mismatch error")
.to_string();
assert!(
message.contains("combined_fields") && message.contains("tokenizer"),
"unexpected error: {message}"
);
}
#[tokio::test]
async fn test_fts_combined_fields_concatenation_identity() {
let params = combined_fields_test_params();
let titles = ["cat", "dog", "bird", "cat dog"];
let bodies = ["cat dog", "dog bird cat", "bird cat", "cat dog bird"];
let (w_title, w_body) = (2usize, 1usize);
let concat: Vec<String> = titles
.iter()
.zip(&bodies)
.map(|(title, body)| {
let mut parts: Vec<&str> = Vec::with_capacity(w_title + w_body);
for _ in 0..w_title {
parts.push(title);
}
for _ in 0..w_body {
parts.push(body);
}
parts.join(" ")
})
.collect();
let concat_refs: Vec<&str> = concat.iter().map(|s| s.as_str()).collect();
let batch = record_batch!(
("id", Int32, (0..titles.len() as i32).collect::<Vec<_>>()),
("title", Utf8, titles.to_vec()),
("body", Utf8, bodies.to_vec()),
("concat", Utf8, concat_refs)
)
.unwrap();
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, &["title", "body", "concat"], ¶ms).await;
let combined = combined_query_with_boosts(
"cat dog",
Operator::Or,
Some(vec![w_title as f32, w_body as f32]),
);
let plain = FtsQuery::Match(
MatchQuery::new("cat dog".to_string()).with_column(Some("concat".to_string())),
);
let combined_scores: HashMap<i32, f32> = fts_result_id_scores(&dataset, combined, None)
.await
.into_iter()
.collect();
let plain_scores: HashMap<i32, f32> = fts_result_id_scores(&dataset, plain, None)
.await
.into_iter()
.collect();
assert_eq!(
combined_scores.keys().copied().collect::<HashSet<_>>(),
plain_scores.keys().copied().collect::<HashSet<_>>(),
);
for (id, combined_score) in &combined_scores {
let plain_score = plain_scores[id];
assert!(
(combined_score - plain_score).abs() < 1e-3,
"id {id}: combined={combined_score}, concatenated single-field={plain_score}"
);
}
}
#[rstest]
#[case::or(Operator::Or)]
#[case::and(Operator::And)]
#[tokio::test]
async fn test_fts_combined_fields_covers_unindexed_fragments(#[case] operator: Operator) {
let titles = vec!["alpha", "alpha alpha", "gamma"];
let bodies = vec!["omega", "omega", "omega omega"];
let (test_uri, mut dataset) =
indexed_two_column_dataset(&titles[..1], &bodies[..1], None).await;
for row in 1..3 {
let batch = combined_fields_batch(vec![row as i32], vec![titles[row]], vec![bodies[row]]);
dataset = append_fts_dataset(&test_uri, batch, Some(1)).await;
}
let match_ids = fts_result_ids(
&dataset,
FtsQuery::Match(
MatchQuery::new("alpha".to_string()).with_column(Some("title".to_string())),
),
)
.await;
assert_eq!(
match_ids.iter().copied().collect::<HashSet<_>>(),
HashSet::from([0, 1])
);
let expected = brute_force_bm25f(
&[(1.0, titles.clone()), (1.0, bodies.clone())],
"alpha omega",
operator == Operator::And,
);
let actual =
fts_result_id_scores(&dataset, combined_query("alpha omega", operator), None).await;
assert_matches_brute_force(
&actual,
&expected,
"rows in unindexed fragments must be reached exactly once through the index/flat union",
);
}
#[rstest]
#[case::fully_indexed(false)]
#[case::mixed_index_and_flat(true)]
#[tokio::test]
async fn test_fts_combined_fields_respects_external_row_mask(#[case] append_unindexed: bool) {
let titles = vec!["alpha", "alpha alpha", "gamma"];
let bodies = vec!["omega", "omega", "omega omega"];
let (_test_uri, mut dataset) = if append_unindexed {
let (test_uri, mut dataset) =
indexed_two_column_dataset(&titles[..1], &bodies[..1], None).await;
for row in 1..3 {
let batch =
combined_fields_batch(vec![row as i32], vec![titles[row]], vec![bodies[row]]);
dataset = append_fts_dataset(&test_uri, batch, Some(1)).await;
}
(test_uri, dataset)
} else {
indexed_two_column_dataset(&titles, &bodies, None).await
};
dataset.checkout_latest().await.unwrap();
let baseline = dataset
.scan()
.project(&["id"])
.unwrap()
.with_row_id()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"alpha omega",
Operator::Or,
)))
.unwrap()
.try_into_batch()
.await
.unwrap();
let baseline_ids = baseline["id"].as_primitive::<Int32Type>();
let baseline_row_ids = baseline[ROW_ID].as_primitive::<UInt64Type>();
let baseline_scores = baseline["_score"].as_primitive::<Float32Type>();
let baseline: Vec<(i32, u64, f32)> = (0..baseline.num_rows())
.map(|i| {
(
baseline_ids.value(i),
baseline_row_ids.value(i),
baseline_scores.value(i),
)
})
.collect();
assert_eq!(
baseline.iter().map(|(id, ..)| *id).collect::<HashSet<_>>(),
HashSet::from([0, 1, 2]),
"every row matches this query, so all three must be in the baseline"
);
let masked = |allowed: Vec<u64>| {
let dataset = dataset.clone();
async move {
let mut scan = dataset.scan();
scan.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"alpha omega",
Operator::Or,
)))
.unwrap()
.with_row_id_prefilter(RowAddrMask::from_allowed(RowAddrTreeMap::from_iter(
allowed,
)));
let batch = scan.try_into_batch().await.unwrap();
let ids = batch["id"].as_primitive::<Int32Type>();
let scores = batch["_score"].as_primitive::<Float32Type>();
(0..batch.num_rows())
.map(|i| (ids.value(i), scores.value(i)))
.collect::<Vec<_>>()
}
};
assert!(
masked(vec![]).await.is_empty(),
"an empty allow list must drop every combined_fields match"
);
for &(id, row_id, score) in &baseline {
let actual = masked(vec![row_id]).await;
assert_eq!(
actual.len(),
1,
"allowing only _rowid {row_id} must return exactly id {id}, got {actual:?}"
);
assert_eq!(actual[0].0, id);
assert!(
(actual[0].1 - score).abs() < 1e-3,
"masking must not move id {id}'s score: {} vs the unmasked {score}",
actual[0].1
);
}
}
#[rstest]
#[case::or_row_addresses(false, Operator::Or)]
#[case::or_stable_row_ids(true, Operator::Or)]
#[case::and_row_addresses(false, Operator::And)]
#[case::and_stable_row_ids(true, Operator::And)]
#[tokio::test]
async fn test_fts_combined_fields_per_column_index_skew(
#[case] stable_row_ids: bool,
#[case] operator: Operator,
) {
let params = combined_fields_test_params();
let titles = vec!["aa bb", "cc", "aa aa", "dd"];
let bodies = vec!["cc dd", "aa bb", "dd", "aa bb bb"];
let write_params = |mode: WriteMode| WriteParams {
mode,
max_rows_per_file: 2,
enable_stable_row_ids: stable_row_ids,
..Default::default()
};
let first = combined_fields_batch(
vec![0, 1],
vec![titles[0], titles[1]],
vec![bodies[0], bodies[1]],
);
let test_uri = TempStrDir::default();
let mut dataset =
write_fts_dataset(&test_uri, first, Some(write_params(WriteMode::Create))).await;
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
let second = combined_fields_batch(
vec![2, 3],
vec![titles[2], titles[3]],
vec![bodies[2], bodies[3]],
);
let mut dataset =
write_fts_dataset(&test_uri, second, Some(write_params(WriteMode::Append))).await;
create_inverted_indices(&mut dataset, &["title"], ¶ms).await;
assert_eq!(
dataset.manifest.uses_stable_row_ids(),
stable_row_ids,
"row-id scheme precondition"
);
let weights = [3.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[(weights[0], titles.clone()), (weights[1], bodies.clone())],
"aa bb",
operator == Operator::And,
);
let expected_ids = brute_force_ids(&expected);
let actual = fts_result_id_scores(
&dataset,
combined_query_with_boosts("aa bb", operator, Some(weights.to_vec())),
None,
)
.await;
let actual_ids: HashSet<i32> = actual.iter().map(|(id, _)| *id).collect();
assert_eq!(
actual.len(),
expected_ids.len(),
"row emitted twice by both sides of the union: {actual:?}"
);
assert!(
actual_ids.contains(&3),
"row matching only through the column with stale index coverage was dropped: {actual_ids:?}"
);
assert_eq!(actual_ids, expected_ids);
let scores: HashMap<i32, f32> = actual.into_iter().collect();
for id in [2, 3] {
match expected[id as usize] {
Some(want) => {
let got = scores[&id];
assert!(
(got - want).abs() < 1e-3,
"id {id} scored with partial cross-field data: scan={got}, brute force={want}"
);
}
None => assert!(
!scores.contains_key(&id),
"id {id} does not carry every term but {operator:?} returned it"
),
}
}
}
#[tokio::test]
async fn test_fts_combined_fields_partially_indexed_columns() {
let params = combined_fields_test_params();
let titles = vec!["aa bb", "cc", "aa aa", "dd"];
let bodies = vec!["cc dd", "aa bb", "dd", "aa bb bb"];
let batch = combined_fields_batch(vec![0, 1, 2, 3], titles.clone(), bodies.clone());
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(
&test_uri,
batch,
Some(WriteParams {
max_rows_per_file: 2,
..Default::default()
}),
)
.await;
create_inverted_indices(&mut dataset, &["title"], ¶ms).await;
let weights = [2.0f32, 1.0f32];
let mut plan_scan = dataset.scan();
plan_scan
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query_with_boosts(
"aa",
Operator::Or,
Some(weights.to_vec()),
)))
.unwrap();
let plan = plan_scan.explain_plan(true).await.unwrap();
assert!(
plan.contains("FlatCombinedFields"),
"the flat-only branch must carry a flat child:\n{plan}"
);
assert!(
!plan.contains("CombinedFieldsQuery"),
"no fragment is covered for `body`, so nothing can be scored by an index \
child:\n{plan}"
);
let expected = brute_force_bm25f(
&[(weights[0], titles.clone()), (weights[1], bodies.clone())],
"aa",
false,
);
let actual = fts_result_id_scores(
&dataset,
combined_query_with_boosts("aa", Operator::Or, Some(weights.to_vec())),
None,
)
.await;
assert_matches_brute_force(
&actual,
&expected,
"the flat-only plan must score against the blend of its own corpus and \
`title`'s index",
);
let scores: Vec<f32> = actual.iter().map(|(_, score)| *score).collect();
assert!(
scores.windows(2).all(|w| w[0] >= w[1]),
"flat-only plan is not score-ordered: {actual:?}"
);
let best = actual
.iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(id, _)| *id)
.unwrap();
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query_with_boosts(
"aa",
Operator::Or,
Some(weights.to_vec()),
)))
.unwrap()
.limit(Some(1), None)
.unwrap()
.try_into_batch()
.await
.unwrap();
let top = batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int32Type>()
.values()
.to_vec();
assert_eq!(
top,
vec![best],
"limit=1 on the flat-only plan did not return the top-scoring row"
);
}
#[rstest]
#[case::fully_indexed(true)]
#[case::mixed_coverage(false)]
#[tokio::test]
async fn test_fts_combined_fields_tied_scores_are_deterministic(#[case] fully_indexed: bool) {
let rows = 24usize;
let (_test_uri, dataset) = if fully_indexed {
indexed_two_column_dataset(&vec!["aa"; rows], &vec!["aa"; rows], Some(3)).await
} else {
let (test_uri, _indexed) =
indexed_two_column_dataset(&["aa", "aa"], &["aa", "aa"], None).await;
let second = combined_fields_batch(
(2..rows as i32).collect(),
vec!["aa"; rows - 2],
vec!["aa"; rows - 2],
);
let dataset = append_fts_dataset(&test_uri, second, Some(3)).await;
(test_uri, dataset)
};
let mut plan_scan = dataset.scan();
plan_scan
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.limit(Some(5), None)
.unwrap();
let plan = plan_scan.explain_plan(true).await.unwrap();
assert_eq!(
plan.contains("FlatCombinedFields"),
!fully_indexed,
"wrong plan shape for fully_indexed={fully_indexed}:\n{plan}"
);
assert_eq!(
plan.contains("SortExec"),
!fully_indexed,
"fully indexed plan must settle ties in the search, not in a sort:\n{plan}"
);
let expected_top: Vec<i32> = (0..5).collect();
let mut seen = HashSet::new();
for _ in 0..8 {
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.limit(Some(5), None)
.unwrap()
.try_into_batch()
.await
.unwrap();
let ids = batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int32Type>()
.values()
.to_vec();
assert_eq!(
ids, expected_top,
"tied scores must break by ascending row id"
);
seen.insert(ids);
}
assert_eq!(
seen.len(),
1,
"top-k over tied scores is nondeterministic across runs: {seen:?}"
);
}
#[tokio::test]
async fn test_fts_combined_fields_fast_search_without_full_coverage_is_empty() {
let params = combined_fields_test_params();
let batch = combined_fields_batch(vec![0, 1], vec!["aa", "bb"], vec!["bb", "aa"]);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, &["title"], ¶ms).await;
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.fast_search()
.try_into_batch()
.await
.expect("fast_search without full coverage should be empty, not an error");
assert_eq!(batch.num_rows(), 0);
}
#[tokio::test]
async fn test_fts_combined_fields_unindexed_row_wins_topk() {
let titles = ["filler", "filler", "aa aa aa"];
let bodies = ["aa", "filler", "aa aa aa"];
let (test_uri, _indexed) = indexed_two_column_dataset(&titles[..2], &bodies[..2], None).await;
let second = combined_fields_batch(vec![2], vec![titles[2]], vec![bodies[2]]);
let dataset = append_fts_dataset(&test_uri, second, None).await;
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.limit(Some(1), None)
.unwrap()
.try_into_batch()
.await
.unwrap();
let ids = batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int32Type>()
.values()
.to_vec();
assert_eq!(ids, vec![2], "top-1 was taken from the indexed side only");
}
#[tokio::test]
async fn test_fts_combined_fields_fast_search_skips_uncovered_fragments() {
let params = combined_fields_test_params();
let (test_uri, _indexed) = indexed_two_column_dataset(&["aa"], &["aa"], None).await;
let second = combined_fields_batch(vec![1], vec!["aa"], vec!["aa"]);
let mut dataset = append_fts_dataset(&test_uri, second, None).await;
create_inverted_indices(&mut dataset, &["title"], ¶ms).await;
let batch = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.fast_search()
.try_into_batch()
.await
.unwrap();
let ids = batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int32Type>()
.values()
.to_vec();
assert_eq!(
ids,
vec![0],
"fast_search returned a row whose cross-field data is only partly indexed"
);
}
#[tokio::test]
async fn test_fts_combined_fields_empty_fragment_list_is_empty() {
let (_test_uri, dataset) = indexed_two_column_dataset(&["aa", "bb"], &["bb", "aa"], None).await;
let mut scan = dataset.scan();
scan.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.with_fragments(vec![]);
let plan = scan.explain_plan(false).await.unwrap();
assert!(plan.contains("EmptyExec"), "unexpected plan: {plan}");
assert_eq!(scan.try_into_batch().await.unwrap().num_rows(), 0);
}
#[tokio::test]
async fn test_fts_combined_fields_requires_an_index() {
let batch = combined_fields_batch(vec![0, 1], vec!["aa", "bb"], vec!["bb", "aa"]);
let test_uri = TempStrDir::default();
let dataset = write_fts_dataset(&test_uri, batch, None).await;
let result = dataset
.scan()
.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(combined_query(
"aa",
Operator::Or,
)))
.unwrap()
.try_into_batch()
.await;
let message = result.expect_err("expected an error").to_string();
assert!(
message.contains("combined_fields") && message.contains("inverted index"),
"unexpected error: {message}"
);
}
fn nullable_combined_fields_batch(
ids: Vec<i32>,
titles: Vec<Option<&str>>,
bodies: Vec<Option<&str>>,
) -> RecordBatch {
record_batch!(
("id", Int32, ids),
("title", Utf8, titles),
("body", Utf8, bodies)
)
.unwrap()
}
#[tokio::test]
async fn test_fts_combined_fields_flat_nulls_and_empty_strings() {
let params = combined_fields_test_params();
let titles = vec![
Some("zz zz"),
Some("zz"),
None, Some("aa aa"), None, Some(""), Some("aa"),
None, ];
let bodies = vec![
Some("zz"),
Some("zz zz"),
Some("aa"),
Some(""),
None,
Some(""),
Some("aa zz"),
Some("zz"),
];
let first =
nullable_combined_fields_batch(vec![0, 1], titles[..2].to_vec(), bodies[..2].to_vec());
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, first, None).await;
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
let second = nullable_combined_fields_batch(
(2..titles.len() as i32).collect(),
titles[2..].to_vec(),
bodies[2..].to_vec(),
);
let dataset = append_fts_dataset(&test_uri, second, Some(2)).await;
fn as_text<'a>(values: &[Option<&'a str>]) -> Vec<&'a str> {
values.iter().map(|v| v.unwrap_or("")).collect()
}
let weights = [2.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[
(weights[0], as_text(&titles)),
(weights[1], as_text(&bodies)),
],
"aa",
false,
);
assert_eq!(
brute_force_ids(&expected),
HashSet::from([2, 3, 6]),
"reference corpus changed; the null rows must stay unmatched"
);
let actual = fts_result_id_scores(
&dataset,
combined_query_with_boosts("aa", Operator::Or, Some(weights.to_vec())),
None,
)
.await;
assert_matches_brute_force(
&actual,
&expected,
"a row empty in every target column must not be scored, and docCount' must \
count only rows with tokens in that column",
);
}
fn list_column_batch<
ListOffset: arrow::array::OffsetSizeTrait,
StringOffset: arrow::array::OffsetSizeTrait,
>(
ids: &[i32],
tags: &[Option<Vec<&str>>],
bodies: &[&str],
) -> RecordBatch {
let mut builder =
GenericListBuilder::<ListOffset, _>::new(GenericStringBuilder::<StringOffset>::new());
for row in tags {
match row {
Some(elements) => {
for element in elements {
builder.values().append_value(element);
}
builder.append(true);
}
None => builder.append(false),
}
}
let tags_col = Arc::new(builder.finish()) as ArrayRef;
let bodies_col =
Arc::new(GenericStringArray::<StringOffset>::from(bodies.to_vec())) as ArrayRef;
RecordBatch::try_new(
Arc::new(ArrowSchema::new(vec![
ArrowField::new("id", DataType::Int32, false),
ArrowField::new("tags", tags_col.data_type().clone(), true),
ArrowField::new("body", bodies_col.data_type().clone(), false),
])),
vec![
Arc::new(Int32Array::from(ids.to_vec())) as ArrayRef,
tags_col,
bodies_col,
],
)
.unwrap()
}
#[tokio::test]
async fn test_fts_combined_fields_flat_list_column() {
assert_combined_fields_flat_list_column::<i32, i32>().await;
}
#[tokio::test]
async fn test_fts_combined_fields_flat_large_list_column() {
assert_combined_fields_flat_list_column::<i64, i64>().await;
}
#[tokio::test]
async fn test_fts_combined_fields_flat_list_matches_index_under_raw_tokenizer() {
let params = InvertedIndexParams::new("raw".to_string(), Language::English)
.lower_case(false)
.stem(false)
.remove_stop_words(false);
let tags: Vec<Option<Vec<&str>>> = vec![
Some(vec!["alpha beta"]),
Some(vec!["alpha beta", "gamma"]),
Some(vec!["gamma", "delta"]),
];
let bodies = vec!["alpha beta", "gamma", "alpha beta"];
let build = |indexed_columns: &'static [&'static str]| {
let params = params.clone();
let tags = tags.clone();
let bodies = bodies.clone();
async move {
let batch = list_column_batch::<i32, i32>(
&(0..tags.len() as i32).collect::<Vec<_>>(),
&tags,
&bodies,
);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch, None).await;
create_inverted_indices(&mut dataset, indexed_columns, ¶ms).await;
let scored = fts_result_id_scores(
&dataset,
combined_query_over(&["tags", "body"], "alpha beta", Operator::Or, None),
None,
)
.await;
drop(test_uri);
scored
}
};
let indexed = build(&["tags", "body"]).await;
let flat = build(&["body"]).await;
assert_eq!(
indexed.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
vec![0, 2],
"row 1 only matches when its tag elements are counted separately"
);
assert_eq!(
indexed.len(),
flat.len(),
"flat={flat:?} indexed={indexed:?}"
);
for ((indexed_id, indexed_score), (flat_id, flat_score)) in indexed.iter().zip(&flat) {
assert_eq!(indexed_id, flat_id, "flat={flat:?} indexed={indexed:?}");
assert!(
(indexed_score - flat_score).abs() < 1e-5,
"id {indexed_id}: indexed={indexed_score}, flat={flat_score}; the flat scan must \
join a row's list elements the way the index builder does"
);
}
}
async fn assert_combined_fields_flat_list_column<
ListOffset: arrow::array::OffsetSizeTrait,
StringOffset: arrow::array::OffsetSizeTrait,
>() {
let params = combined_fields_test_params();
let tags: Vec<Option<Vec<&str>>> = vec![
Some(vec!["zz"]),
Some(vec!["zz", "zz"]),
Some(vec!["aa", "bb cc"]),
Some(vec!["aa", "aa"]),
None, Some(vec![]), Some(vec!["zz"]),
];
let bodies = vec!["zz", "zz zz", "zz", "zz zz", "aa", "aa aa", "zz"];
let first = list_column_batch::<ListOffset, StringOffset>(&[0, 1], &tags[..2], &bodies[..2]);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, first, None).await;
create_inverted_indices(&mut dataset, &["tags", "body"], ¶ms).await;
let second = list_column_batch::<ListOffset, StringOffset>(
&(2..tags.len() as i32).collect::<Vec<_>>(),
&tags[2..],
&bodies[2..],
);
let dataset = append_fts_dataset(&test_uri, second, Some(2)).await;
let flattened: Vec<String> = tags
.iter()
.map(|row| row.as_ref().map(|e| e.join(" ")).unwrap_or_default())
.collect();
let weights = [2.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[
(weights[0], flattened.iter().map(|s| s.as_str()).collect()),
(weights[1], bodies.clone()),
],
"aa",
false,
);
assert_eq!(brute_force_ids(&expected), HashSet::from([2, 3, 4, 5]));
let actual = fts_result_id_scores(
&dataset,
combined_query_over(
&["tags", "body"],
"aa",
Operator::Or,
Some(weights.to_vec()),
),
None,
)
.await;
assert_matches_brute_force(
&actual,
&expected,
"a row's list elements must sum into one dl_f",
);
}
#[tokio::test]
async fn test_fts_combined_fields_column_under_a_list() {
let params = combined_fields_test_params();
let docs: Vec<Vec<&str>> = vec![
vec!["zz"],
vec!["zz zz"],
vec!["aa", "bb aa"],
vec!["aa aa"],
vec![],
];
let titles = vec!["zz", "zz", "zz", "aa", "aa aa"];
let nested_batch = |ids: &[i32], docs: &[Vec<&str>], titles: &[&str]| {
let content_field = Arc::new(ArrowField::new("content", DataType::Utf8, true));
let struct_fields = ArrowFields::from(vec![content_field]);
let contents = docs.iter().flatten().copied().collect::<Vec<_>>();
let struct_values = StructArray::new(
struct_fields.clone(),
vec![Arc::new(StringArray::from(contents)) as ArrayRef],
None,
);
let item = Arc::new(ArrowField::new(
"item",
DataType::Struct(struct_fields),
true,
));
let mut offsets = Vec::with_capacity(docs.len() + 1);
offsets.push(0i32);
for row in docs {
offsets.push(offsets.last().unwrap() + row.len() as i32);
}
let docs_col = Arc::new(ListArray::new(
item,
OffsetBuffer::new(ScalarBuffer::from(offsets)),
Arc::new(struct_values),
None,
)) as ArrayRef;
RecordBatch::try_new(
Arc::new(ArrowSchema::new(vec![
ArrowField::new("id", DataType::Int32, false),
ArrowField::new("docs", docs_col.data_type().clone(), true),
ArrowField::new("title", DataType::Utf8, false),
])),
vec![
Arc::new(Int32Array::from(ids.to_vec())) as ArrayRef,
docs_col,
Arc::new(StringArray::from(titles.to_vec())) as ArrayRef,
],
)
.unwrap()
};
let first = nested_batch(&[0, 1], &docs[..2], &titles[..2]);
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, first, None).await;
create_inverted_indices(&mut dataset, &["docs.content", "title"], ¶ms).await;
let weights = [2.0f32, 1.0f32];
let joined: Vec<String> = docs.iter().map(|row| row.join(" ")).collect();
let assert_scored_like_reference = |dataset: Dataset, terms: &'static str, rows: usize| {
let joined = joined.clone();
let titles = titles.clone();
async move {
let expected = brute_force_bm25f(
&[
(
weights[0],
joined[..rows].iter().map(|s| s.as_str()).collect(),
),
(weights[1], titles[..rows].to_vec()),
],
terms,
false,
);
assert!(
!brute_force_ids(&expected).is_empty(),
"the reference must match something"
);
let actual = fts_result_id_scores(
&dataset,
combined_query_over(
&["docs.content", "title"],
terms,
Operator::Or,
Some(weights.to_vec()),
),
None,
)
.await;
assert_matches_brute_force(
&actual,
&expected,
"a target column reached through a list must be walked down to its leaf",
);
}
};
assert_scored_like_reference(dataset.clone(), "zz", 2).await;
let second = nested_batch(
&(2..docs.len() as i32).collect::<Vec<_>>(),
&docs[2..],
&titles[2..],
);
let dataset = append_fts_dataset(&test_uri, second, Some(2)).await;
assert_scored_like_reference(dataset, "aa", docs.len()).await;
}
fn three_column_batch(
ids: Vec<i32>,
titles: Vec<&str>,
bodies: Vec<&str>,
tags: Vec<&str>,
) -> RecordBatch {
record_batch!(
("id", Int32, ids),
("title", Utf8, titles),
("body", Utf8, bodies),
("tags", Utf8, tags)
)
.unwrap()
}
#[rstest]
#[case::fully_indexed(false)]
#[case::flat_side(true)]
#[tokio::test]
async fn test_fts_combined_fields_three_columns_two_terms(#[case] append_unindexed: bool) {
let params = combined_fields_test_params();
let titles = vec!["zz", "zz zz", "aa", "bb bb", "zz", "aa aa"];
let bodies = vec!["zz zz", "zz", "bb", "zz", "aa bb", "zz zz"];
let tags = vec!["zz", "zz zz", "zz", "aa", "bb", "aa bb"];
let columns = ["title", "body", "tags"];
let weights = [3.0f32, 2.0f32, 1.0f32];
let test_uri = TempStrDir::default();
let write_params = |mode: WriteMode| WriteParams {
mode,
max_rows_per_file: 2,
..Default::default()
};
let indexed_rows = if append_unindexed { 2 } else { titles.len() };
let first = three_column_batch(
(0..indexed_rows as i32).collect(),
titles[..indexed_rows].to_vec(),
bodies[..indexed_rows].to_vec(),
tags[..indexed_rows].to_vec(),
);
let mut dataset =
write_fts_dataset(&test_uri, first, Some(write_params(WriteMode::Create))).await;
create_inverted_indices(&mut dataset, &columns, ¶ms).await;
let dataset = if append_unindexed {
let second = three_column_batch(
(indexed_rows as i32..titles.len() as i32).collect(),
titles[indexed_rows..].to_vec(),
bodies[indexed_rows..].to_vec(),
tags[indexed_rows..].to_vec(),
);
write_fts_dataset(&test_uri, second, Some(write_params(WriteMode::Append))).await
} else {
dataset
};
for operator in [Operator::Or, Operator::And] {
let expected = brute_force_bm25f(
&[
(weights[0], titles.clone()),
(weights[1], bodies.clone()),
(weights[2], tags.clone()),
],
"aa bb",
operator == Operator::And,
);
assert_eq!(
brute_force_ids(&expected),
HashSet::from([2, 3, 4, 5]),
"reference corpus changed; rows 0-1 must stay unmatched"
);
let actual = fts_result_id_scores(
&dataset,
combined_query_over(&columns, "aa bb", operator, Some(weights.to_vec())),
None,
)
.await;
assert_matches_brute_force(
&actual,
&expected,
&format!("op={operator:?}: the three-column, two-term stride must not transpose"),
);
}
}
#[rstest]
#[case::scalar_index_prefilter("id >= 4", true)]
#[case::refine_expression("category = 'keep'", false)]
#[tokio::test]
async fn test_fts_combined_fields_with_filter(
#[case] filter: &str,
#[case] index_filter_column: bool,
) {
let params = combined_fields_test_params();
let titles = vec!["zz zz", "zz", "aa", "zz", "aa aa", "zz"];
let bodies = vec!["zz", "zz zz", "zz", "aa aa", "zz", "aa"];
let categories = ["keep", "keep", "drop", "drop", "keep", "keep"];
let batch = |ids: Vec<i32>, rows: std::ops::Range<usize>| {
record_batch!(
("id", Int32, ids),
("category", Utf8, categories[rows.clone()].to_vec()),
("title", Utf8, titles[rows.clone()].to_vec()),
("body", Utf8, bodies[rows].to_vec())
)
.unwrap()
};
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, batch(vec![0, 1], 0..2), None).await;
create_inverted_indices(&mut dataset, &["title", "body"], ¶ms).await;
if index_filter_column {
dataset
.create_index(
&["id"],
IndexType::BTree,
None,
&ScalarIndexParams::default(),
true,
)
.await
.unwrap();
}
let dataset = append_fts_dataset(&test_uri, batch((2..6).collect(), 2..6), Some(2)).await;
let weights = [2.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[(weights[0], titles.clone()), (weights[1], bodies.clone())],
"aa",
false,
);
assert_eq!(
expected
.iter()
.enumerate()
.filter_map(|(row, score)| score.map(|_| row))
.collect::<Vec<_>>(),
vec![2, 3, 4, 5],
"reference corpus changed; the filtered-out rows 2-3 must match too, or \
counting them would not change the kept rows' scores"
);
let expected_scores: HashMap<i32, f32> = [4usize, 5]
.into_iter()
.map(|row| (row as i32, expected[row].unwrap()))
.collect();
let mut scan = dataset.scan();
scan.project(&["id"])
.unwrap()
.prefilter(true)
.full_text_search(FullTextSearchQuery::new_query(combined_query_with_boosts(
"aa",
Operator::Or,
Some(weights.to_vec()),
)))
.unwrap()
.filter(filter)
.unwrap();
let plan = scan.explain_plan(true).await.unwrap();
assert!(
plan.contains("FlatCombinedFields"),
"filtered plan lost its flat child:\n{plan}"
);
assert!(
plan.lines()
.any(|line| line.contains("projection=[title, body]")
&& line.contains("full_filter=--, refine_filter=--")),
"the flat scan must read its fragments unfiltered:\n{plan}"
);
if index_filter_column {
assert!(
plan.contains("ScalarIndexQuery") && !plan.contains("category"),
"expected the filter to resolve entirely through the scalar index:\n{plan}"
);
} else {
assert!(
plan.contains("refine_filter=category"),
"expected the filter to become a refine expression:\n{plan}"
);
assert!(
!plan.contains("FilterExec"),
"the prefilter already applies the refine expression, so no second \
filter:\n{plan}"
);
}
let scan_batch = scan.try_into_batch().await.unwrap();
let ids = scan_batch
.column_by_name("id")
.unwrap()
.as_primitive::<Int32Type>();
let scores = scan_batch
.column_by_name("_score")
.unwrap()
.as_primitive::<Float32Type>();
let actual: Vec<(i32, f32)> = (0..scan_batch.num_rows())
.map(|i| (ids.value(i), scores.value(i)))
.collect();
assert_eq!(
actual.len(),
expected_scores.len(),
"filter did not restrict the result set:\n{plan}\n{actual:?}"
);
for (id, score) in actual {
let want = *expected_scores
.get(&id)
.unwrap_or_else(|| panic!("filtered-out row {id} was returned:\n{plan}"));
assert!(
(score - want).abs() < 1e-3,
"id {id}: scan={score}, brute force={want}"
);
}
}
#[tokio::test]
async fn test_fts_combined_fields_deletions_then_optimize() {
let titles = vec!["aa", "aa aa", "zz", "aa aa aa", "aa", "zz zz"];
let bodies = vec!["zz", "zz", "aa aa", "zz", "aa aa", "aa"];
let (test_uri, _indexed) = indexed_two_column_dataset(&titles[..3], &bodies[..3], None).await;
let second = combined_fields_batch(vec![3, 4, 5], titles[3..].to_vec(), bodies[3..].to_vec());
let mut dataset = append_fts_dataset(&test_uri, second, None).await;
dataset.delete("id in (1, 4)").await.unwrap();
let stats_rows = [0usize, 1, 2, 3, 5];
let pick = |values: &[&'static str]| {
stats_rows
.iter()
.map(|&row| values[row])
.collect::<Vec<_>>()
};
let weights = [2.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[(weights[0], pick(&titles)), (weights[1], pick(&bodies))],
"aa",
false,
);
let expected_scores: HashMap<i32, f32> = stats_rows
.iter()
.enumerate()
.filter_map(|(slot, &row)| expected[slot].map(|score| (row as i32, score)))
.collect();
let query = || combined_query_with_boosts("aa", Operator::Or, Some(weights.to_vec()));
let live_ids = HashSet::from([0, 2, 3, 5]);
let mut scan = dataset.scan();
scan.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(query()))
.unwrap();
let mixed_plan = scan.explain_plan(true).await.unwrap();
assert!(
mixed_plan.contains("FlatCombinedFields"),
"the appended fragment is unindexed, so the plan must have a flat child:\n{mixed_plan}"
);
let mixed = fts_result_id_scores(&dataset, query(), None).await;
assert_eq!(
mixed.iter().map(|(id, _)| *id).collect::<HashSet<_>>(),
live_ids,
"deleted rows leaked into the mixed index/flat plan: {mixed:?}"
);
assert_eq!(mixed.len(), live_ids.len(), "duplicate row: {mixed:?}");
let mixed_scores: HashMap<i32, f32> = mixed.into_iter().collect();
for id in [3, 5] {
let want = expected_scores[&id];
let got = mixed_scores[&id];
assert!(
(got - want).abs() < 1e-3,
"id {id} after delete: scan={got}, brute force={want}"
);
}
dataset
.optimize_indices(&OptimizeOptions::default())
.await
.unwrap();
let mut scan = dataset.scan();
scan.project(&["id"])
.unwrap()
.full_text_search(FullTextSearchQuery::new_query(query()))
.unwrap();
let plan = scan.explain_plan(true).await.unwrap();
assert!(
plan.contains("CombinedFieldsQuery"),
"expected an indexed combined-fields scan:\n{plan}"
);
assert!(
!plan.contains("FlatCombinedFields"),
"every fragment is indexed but the plan still has a flat child:\n{plan}"
);
let optimized = fts_result_id_scores(&dataset, query(), None).await;
assert_eq!(
optimized.iter().map(|(id, _)| *id).collect::<HashSet<_>>(),
live_ids,
"deleted rows came back after optimize: {optimized:?}"
);
for (id, score) in optimized {
let want = expected_scores[&id];
assert!(
(score - want).abs() < 1e-3,
"id {id} after optimize: scan={score}, brute force={want}"
);
}
}
#[tokio::test]
async fn test_fts_combined_fields_nested_columns() {
let params = combined_fields_test_params();
let a_values = [
Some("zz zz"),
Some("zz"),
Some("aa"),
Some("zz"),
Some("aa aa"),
];
let b_values = [
Some("zz"),
Some("zz zz"),
Some("zz"),
Some("aa aa"),
Some("aa"),
];
let first = nested_fts_batch(vec![0, 1], a_values[..2].to_vec(), b_values[..2].to_vec());
let test_uri = TempStrDir::default();
let mut dataset = write_fts_dataset(&test_uri, first, None).await;
create_inverted_indices(&mut dataset, &["s.a", "s.b"], ¶ms).await;
let second = nested_fts_batch(
vec![2, 3, 4],
a_values[2..].to_vec(),
b_values[2..].to_vec(),
);
let schema = second.schema();
dataset
.append(
RecordBatchIterator::new(vec![second].into_iter().map(Ok), schema),
None,
)
.await
.unwrap();
let weights = [2.0f32, 1.0f32];
let expected = brute_force_bm25f(
&[
(
weights[0],
a_values.iter().map(|v| v.unwrap_or("")).collect(),
),
(
weights[1],
b_values.iter().map(|v| v.unwrap_or("")).collect(),
),
],
"aa",
false,
);
let expected_ids: HashSet<u64> = (0..expected.len())
.filter(|&i| expected[i].is_some())
.map(|i| i as u64)
.collect();
assert_eq!(expected_ids, HashSet::from([2, 3, 4]));
let mut scan = dataset.scan();
scan.full_text_search(FullTextSearchQuery::new_query(combined_query_over(
&["s.a", "s.b"],
"aa",
Operator::Or,
Some(weights.to_vec()),
)))
.unwrap();
let plan = scan.explain_plan(true).await.unwrap();
assert!(
plan.contains("FlatCombinedFields"),
"the appended fragment is unindexed, so the nested columns must be aliased \
for a flat child:\n{plan}"
);
let batch = scan.try_into_batch().await.unwrap();
let ids = batch["id"].as_primitive::<UInt64Type>();
let scores = batch["_score"].as_primitive::<Float32Type>();
let actual: Vec<(u64, f32)> = (0..batch.num_rows())
.map(|i| (ids.value(i), scores.value(i)))
.collect();
assert_eq!(
actual.len(),
expected_ids.len(),
"duplicate row: {actual:?}"
);
assert_eq!(
actual.iter().map(|(id, _)| *id).collect::<HashSet<_>>(),
expected_ids
);
for (id, score) in actual {
let want = expected[id as usize].expect("scan returned an unmatched doc");
assert!(
(score - want).abs() < 1e-3,
"id {id}: scan={score}, brute force={want}"
);
}
}