use std::collections::HashMap;
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema, SchemaRef, SortOptions};
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
use datafusion::physical_plan::ExecutionPlan;
use datafusion::physical_plan::limit::GlobalLimitExec;
use datafusion::physical_plan::sorts::sort::SortExec;
use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
use datafusion::physical_plan::union::UnionExec;
use datafusion::prelude::Expr;
use lance_core::{Error, Result, is_system_column};
use lance_index::scalar::FullTextSearchQuery;
use lance_index::scalar::InvertedIndexParams;
use lance_index::scalar::inverted::query::{FtsQuery as IndexFtsQuery, Operator};
use lance_index::scalar::inverted::{DOC_INDEX_COL, DOC_INDEX_FIELD, DocumentGranularity};
use tracing::instrument;
use super::block_list::compute_source_block_lists;
use super::collector::LsmDataSourceCollector;
use super::data_source::LsmDataSource;
use super::exec::{FirstByPkExec, PkBlockFilterExec};
use super::generation_read::{GenerationRead, filter_above};
use super::projection::{
project_to_canonical, resolve_data_fields, top_level_of, validate_projection_names,
};
use super::sstable_cache::{DatasetCache, SsTableWarmer, open_sstable};
use crate::dataset::mem_wal::memtable::scanner::MemTableScanner;
use crate::dataset::mem_wal::write::{BatchStore, IndexStore};
use crate::index::scalar::inverted::{
indexed_fts_document_granularities, indexed_fts_index_params, resolve_fts_field,
};
use crate::session::Session;
use lance_io::object_store::ObjectStoreParams;
pub const SCORE_COLUMN: &str = "_score";
const DEFAULT_OVERFETCH_FACTOR: f64 = 1.0;
fn requested_query_document_granularity(
query: &IndexFtsQuery,
) -> Result<Option<DocumentGranularity>> {
fn merge(
current: &mut Option<DocumentGranularity>,
requested: Option<DocumentGranularity>,
) -> Result<()> {
let Some(requested) = requested else {
return Ok(());
};
if let Some(current) = current
&& *current != requested
{
return Err(Error::invalid_input(
"FTS queries cannot mix Row and ListElement document granularities".to_string(),
));
}
*current = Some(requested);
Ok(())
}
fn visit(query: &IndexFtsQuery, current: &mut Option<DocumentGranularity>) -> Result<()> {
match query {
IndexFtsQuery::Match(query) => merge(current, query.document_granularity),
IndexFtsQuery::Phrase(query) => merge(current, query.document_granularity),
IndexFtsQuery::Boost(query) => {
visit(&query.positive, current)?;
visit(&query.negative, current)
}
IndexFtsQuery::Boolean(query) => {
for child in query
.must
.iter()
.chain(&query.should)
.chain(&query.must_not)
{
visit(child, current)?;
}
Ok(())
}
IndexFtsQuery::MultiMatch(query) => {
for child in &query.match_queries {
merge(current, child.document_granularity)?;
}
Ok(())
}
IndexFtsQuery::CombinedFields(_) => merge(current, Some(DocumentGranularity::Row)),
}
}
let mut requested = None;
visit(query, &mut requested)?;
Ok(requested)
}
fn set_query_document_granularity(
query: &mut IndexFtsQuery,
document_granularity: DocumentGranularity,
) {
match query {
IndexFtsQuery::Match(query) => {
query.document_granularity = Some(document_granularity);
}
IndexFtsQuery::Phrase(query) => {
query.document_granularity = Some(document_granularity);
}
IndexFtsQuery::Boost(query) => {
set_query_document_granularity(&mut query.positive, document_granularity);
set_query_document_granularity(&mut query.negative, document_granularity);
}
IndexFtsQuery::Boolean(query) => {
for child in query
.must
.iter_mut()
.chain(&mut query.should)
.chain(&mut query.must_not)
{
set_query_document_granularity(child, document_granularity);
}
}
IndexFtsQuery::MultiMatch(query) => {
for child in &mut query.match_queries {
child.document_granularity = Some(document_granularity);
}
}
IndexFtsQuery::CombinedFields(_) => {}
}
}
fn query_document_granularity(query: &FullTextSearchQuery) -> Result<DocumentGranularity> {
requested_query_document_granularity(&query.query)?.ok_or_else(|| {
Error::internal("LSM FTS query document granularity was not resolved".to_string())
})
}
fn resolve_document_granularity_from_candidates(
column: &str,
requested: Option<DocumentGranularity>,
mut available: Vec<DocumentGranularity>,
) -> Result<DocumentGranularity> {
available.sort_by_key(|document_granularity| match document_granularity {
DocumentGranularity::Row => 0,
DocumentGranularity::ListElement => 1,
});
available.dedup();
match requested {
Some(requested) if available.is_empty() || available.contains(&requested) => Ok(requested),
Some(requested) => Err(Error::invalid_input(format!(
"FTS query for field '{column}' requested {requested:?} document granularity, but \
the MemWAL sources use a different indexed granularity: {available:?}"
))),
None if available.is_empty() => Ok(DocumentGranularity::Row),
None if available.len() == 1 => Ok(available[0]),
None => Err(Error::invalid_input(format!(
"FTS query for field '{column}' is ambiguous because Row and ListElement indexes \
coexist across MemWAL sources; specify document_granularity"
))),
}
}
fn validate_source_document_granularities(
column: &str,
resolved: DocumentGranularity,
source_granularities: &[Vec<DocumentGranularity>],
) -> Result<()> {
for granularities in source_granularities {
if !granularities.is_empty() && !granularities.contains(&resolved) {
return Err(Error::invalid_input(format!(
"FTS query for field '{column}' resolved to {resolved:?}, but a MemWAL source has \
only incompatible indexed granularities: {granularities:?}"
)));
}
}
Ok(())
}
fn validate_lsm_fts_query(query: &FullTextSearchQuery) -> Result<()> {
fn visit(query: &IndexFtsQuery) -> Result<()> {
match query {
IndexFtsQuery::Match(m) => {
if m.fuzziness != Some(0) && m.operator != Operator::Or {
return Err(Error::not_supported(
"LSM fuzzy full-text search only supports OR match operators".to_string(),
));
}
Ok(())
}
IndexFtsQuery::Phrase(_) => Ok(()),
IndexFtsQuery::Boost(b) => {
visit(&b.positive)?;
visit(&b.negative)
}
IndexFtsQuery::Boolean(b) => {
for child in b.must.iter().chain(&b.should).chain(&b.must_not) {
visit(child)?;
}
Ok(())
}
IndexFtsQuery::MultiMatch(m) => {
for leaf in &m.match_queries {
visit(&IndexFtsQuery::Match(leaf.clone()))?;
}
Ok(())
}
IndexFtsQuery::CombinedFields(_) => Err(Error::not_supported(
"LSM full-text search does not support combined_fields (BM25F)".to_string(),
)),
}
}
visit(&query.query)
}
fn active_source_can_execute_fts(
source: &LsmDataSource,
column: &str,
document_granularity: DocumentGranularity,
) -> bool {
match source {
LsmDataSource::ActiveMemTable {
batch_store,
index_store,
..
} => {
index_store
.get_fts_by_column_and_granularity(column, document_granularity)
.is_some_and(|index| !index.is_empty())
&& batch_store
.max_visible_row(index_store.visible_count())
.is_some()
}
_ => false,
}
}
fn transient_fts_index_store(
batch_store: &Arc<BatchStore>,
source: &IndexStore,
schema: &SchemaRef,
columns: &[String],
document_granularity: DocumentGranularity,
pk_columns: &[String],
index_params: &HashMap<&str, InvertedIndexParams>,
) -> Result<Option<Arc<IndexStore>>> {
let visible_batches = source.visible_count();
if visible_batches == 0 {
return Ok(None);
}
let field_ids = columns
.iter()
.map(|column| {
schema
.index_of(column)
.map(|field_id| (column.as_str(), field_id as i32))
.map_err(|_| {
Error::invalid_input(format!(
"FTS query column '{column}' is not in the MemWAL schema"
))
})
})
.collect::<Result<Vec<_>>>()?;
let mut store = IndexStore::new();
if !pk_columns.is_empty() {
let resolved = pk_columns
.iter()
.map(|name| {
schema
.index_of(name)
.map(|idx| (name.clone(), idx as i32))
.map_err(|_| {
Error::invalid_input(format!(
"primary-key column '{name}' is not in the MemWAL schema"
))
})
})
.collect::<Result<Vec<_>>>()?;
store.enable_pk_index(&resolved);
}
for (column, field_id) in field_ids {
let params = index_params
.get(column)
.cloned()
.unwrap_or_else(|| InvertedIndexParams::default().with_position(true))
.document_granularity(document_granularity);
store.add_fts_with_params(
format!("__transient_fts_{column}"),
field_id,
column.to_string(),
params,
)?;
}
for position in 0..visible_batches {
let Some(stored) = batch_store.get(position) else {
break;
};
store.insert_with_batch_position(
&stored.data,
stored.row_offset,
Some(stored.batch_position),
)?;
}
Ok(Some(Arc::new(store)))
}
enum FtsPlanShape {
Bound(Vec<String>),
PerColumn(Vec<(String, IndexFtsQuery)>),
}
fn fts_plan_shape(query: &IndexFtsQuery) -> Result<FtsPlanShape> {
let columns = collect_query_columns(query);
if let IndexFtsQuery::MultiMatch(multi) = query {
if requested_query_document_granularity(query)?
.is_some_and(|granularity| granularity.is_list_element())
{
return Err(Error::not_supported(
"multi-match full-text search supports row documents only, not list elements"
.to_string(),
));
}
if columns.len() <= 1 {
return Ok(FtsPlanShape::Bound(columns));
}
return multi
.match_queries
.iter()
.map(|leaf| {
let column = leaf.column.clone().ok_or_else(|| {
Error::invalid_input(
"multi-match leaf has no bound column; they are bound at construction"
.to_string(),
)
})?;
Ok((column, IndexFtsQuery::Match(leaf.clone())))
})
.collect::<Result<Vec<_>>>()
.map(FtsPlanShape::PerColumn);
}
if columns.len() <= 1 {
return Ok(FtsPlanShape::Bound(columns));
}
if requested_query_document_granularity(query)?
.is_some_and(|granularity| granularity.is_list_element())
{
return Err(Error::not_supported(
"cross-column full-text search supports row documents only, not list elements"
.to_string(),
));
}
Ok(FtsPlanShape::Bound(columns))
}
fn collect_query_columns(query: &IndexFtsQuery) -> Vec<String> {
fn visit(query: &IndexFtsQuery, out: &mut Vec<String>) {
let mut push = |column: &Option<String>| {
if let Some(column) = column
&& !out.contains(column)
{
out.push(column.clone());
}
};
match query {
IndexFtsQuery::Match(query) => push(&query.column),
IndexFtsQuery::Phrase(query) => push(&query.column),
IndexFtsQuery::CombinedFields(query) => {
for column in query.column_names() {
push(&Some(column.to_string()));
}
}
IndexFtsQuery::MultiMatch(query) => {
for leaf in &query.match_queries {
push(&leaf.column);
}
}
IndexFtsQuery::Boost(query) => {
visit(&query.positive, out);
visit(&query.negative, out);
}
IndexFtsQuery::Boolean(query) => {
for child in query
.must
.iter()
.chain(&query.should)
.chain(&query.must_not)
{
visit(child, out);
}
}
}
}
let mut out = Vec::new();
visit(query, &mut out);
out
}
pub struct LsmFtsSearchPlanner {
collector: LsmDataSourceCollector,
pk_columns: Vec<String>,
base_schema: SchemaRef,
identity_schema: SchemaRef,
session: Option<Arc<Session>>,
store_params: Option<ObjectStoreParams>,
sstable_cache: Option<Arc<dyn DatasetCache>>,
warmer: Option<Arc<dyn SsTableWarmer>>,
overfetch_factor: f64,
filter: Option<Expr>,
}
impl LsmFtsSearchPlanner {
pub fn new(
collector: LsmDataSourceCollector,
pk_columns: Vec<String>,
base_schema: SchemaRef,
) -> Self {
Self {
collector,
pk_columns,
identity_schema: base_schema.clone(),
base_schema,
session: None,
store_params: None,
sstable_cache: None,
warmer: None,
overfetch_factor: DEFAULT_OVERFETCH_FACTOR,
filter: None,
}
}
pub fn with_filter(mut self, filter: Option<Expr>) -> Self {
self.filter = filter;
self
}
pub fn with_overfetch_factor(mut self, factor: f64) -> Self {
self.overfetch_factor = factor;
self
}
pub fn with_identity_schema(mut self, schema: SchemaRef) -> Self {
self.identity_schema = schema;
self
}
pub fn with_session(mut self, session: Arc<Session>) -> Self {
self.session = Some(session);
self
}
pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self {
self.store_params = Some(store_params);
self
}
pub fn with_sstable_cache(mut self, cache: Arc<dyn DatasetCache>) -> Self {
self.sstable_cache = Some(cache);
self
}
pub fn with_warmer(mut self, warmer: Arc<dyn SsTableWarmer>) -> Self {
self.warmer = Some(warmer);
self
}
#[instrument(name = "lsm_fts_search", level = "info", skip_all, fields(limit))]
pub async fn plan_search(
&self,
query: FullTextSearchQuery,
limit: Option<usize>,
projection: Option<&[String]>,
) -> Result<Arc<dyn ExecutionPlan>> {
if query.query.is_missing_column() {
return Err(Error::invalid_input(
"LSM full-text search requires the query to name the columns to search; \
bind them with `FullTextSearchQuery::with_columns` before planning"
.to_string(),
));
}
let per_column = match fts_plan_shape(&query.query)? {
FtsPlanShape::Bound(columns) => {
if columns.is_empty() {
return Err(Error::internal(
"full-text query names no column after the bound check".to_string(),
));
}
return self
.plan_bound_search(&columns, query, limit, projection)
.await;
}
FtsPlanShape::PerColumn(per_column) => per_column,
};
if self.pk_columns.is_empty() {
return Err(Error::not_supported(
"cross-column full-text search requires a primary key: a row matching in \
several columns is scored once per column, and collapsing those hits to \
one row needs a stable identity to collapse by"
.to_string(),
));
}
let candidate_limit = limit.map(|k| k.saturating_mul(per_column.len().max(1)));
let mut per_column_plans = Vec::with_capacity(per_column.len());
for (field, sub_query) in per_column {
let mut bounded = FullTextSearchQuery::new_query(sub_query);
bounded.limit = query.limit;
bounded.wand_factor = query.wand_factor;
per_column_plans.push(
Box::pin(self.plan_bound_search(
std::slice::from_ref(&field),
bounded,
candidate_limit,
projection,
))
.await?,
);
}
if per_column_plans
.iter()
.any(|plan| plan.schema().column_with_name(DOC_INDEX_COL).is_some())
{
return Err(Error::not_supported(
"cross-column full-text search supports row documents only: element hits \
carry a per-element coordinate that a row primary key cannot collapse by, \
so matching elements would be silently dropped"
.to_string(),
));
}
if let Some((first, rest)) = per_column_plans.split_first()
&& let Some(mismatch) = rest.iter().find(|plan| plan.schema() != first.schema())
{
return Err(Error::not_supported(format!(
"cross-column full-text search requires every column to produce the same \
schema; got {:?} and {:?}",
first.schema(),
mismatch.schema()
)));
}
let merged: Arc<dyn ExecutionPlan> = if per_column_plans.len() == 1 {
per_column_plans.into_iter().next().unwrap()
} else {
#[allow(deprecated)]
Arc::new(UnionExec::new(per_column_plans))
};
let sorted = self.sort_by_score(merged, candidate_limit)?;
let collapsed: Arc<dyn ExecutionPlan> =
Arc::new(FirstByPkExec::new(sorted, self.pk_columns.clone()));
Ok(match limit {
Some(k) => Arc::new(GlobalLimitExec::new(collapsed, 0, Some(k))),
None => collapsed,
})
}
#[instrument(
name = "lsm_fts_search_columns",
level = "info",
skip_all,
fields(columns = ?columns, limit)
)]
async fn plan_bound_search(
&self,
columns: &[String],
mut query: FullTextSearchQuery,
limit: Option<usize>,
projection: Option<&[String]>,
) -> Result<Arc<dyn ExecutionPlan>> {
let sources = self.collector.collect()?;
let requested = requested_query_document_granularity(&query.query)?;
let mut document_granularity: Option<DocumentGranularity> = None;
let mut index_params: HashMap<&str, InvertedIndexParams> =
HashMap::with_capacity(columns.len());
for column in columns {
let (granularity, params) = self
.resolve_column_index_contract(&sources, column, requested)
.await?;
match document_granularity {
None => document_granularity = Some(granularity),
Some(previous) if previous != granularity => {
return Err(Error::not_supported(format!(
"cross-column full-text search resolved {previous:?} document \
granularity for an earlier column and {granularity:?} for \
'{column}'; they must agree"
)));
}
Some(_) => {}
}
if let Some(params) = params {
index_params.insert(column.as_str(), params);
}
}
let document_granularity = document_granularity.ok_or_else(|| {
Error::internal("full-text query names no column after the bound check".to_string())
})?;
if columns.len() > 1 && document_granularity.is_list_element() {
return Err(Error::not_supported(
"cross-column full-text search supports row documents only: element hits \
carry a per-element coordinate the clauses cannot be joined on"
.to_string(),
));
}
let schema = lance_core::datatypes::Schema::try_from(self.base_schema.as_ref())?;
for column in columns {
resolve_fts_field(&schema, column, document_granularity)?;
}
set_query_document_granularity(&mut query.query, document_granularity);
if sources.iter().any(|source| {
columns
.iter()
.any(|column| active_source_can_execute_fts(source, column, document_granularity))
}) {
validate_lsm_fts_query(&query)?;
}
let allowed_system_columns: &[&str] = if document_granularity.is_list_element() {
&[SCORE_COLUMN, DOC_INDEX_COL]
} else {
&[SCORE_COLUMN]
};
validate_projection_names(projection, &self.base_schema, allowed_system_columns)?;
let target_schema = self.canonical_fts_schema(projection, document_granularity)?;
let overfetch = super::validate_overfetch_factor(self.overfetch_factor)?;
if sources.is_empty() {
return self.empty_plan(&target_schema);
}
let block_lists = Box::pin(compute_source_block_lists(
&sources,
self.session.as_ref(),
self.store_params.as_ref(),
self.sstable_cache.as_ref(),
))
.await?;
let arm_inputs: Vec<_> = sources
.iter()
.map(|source| {
let is_active = matches!(source, LsmDataSource::ActiveMemTable { .. });
let blocked = block_lists.get(&(source.shard_id(), source.generation()));
let active_needs_uncapped_recency = match source {
LsmDataSource::ActiveMemTable { index_store, .. }
if !self.pk_columns.is_empty() =>
{
!index_store.has_pk_index() || index_store.pk_has_overrides()
}
_ => false,
};
let fetch_limit = if active_needs_uncapped_recency {
None
} else if blocked.is_some() && !self.pk_columns.is_empty() {
limit.map(|limit| ((limit as f64) * overfetch).ceil() as usize)
} else {
limit
};
(source, is_active, blocked, fetch_limit)
})
.collect();
let built =
futures::future::try_join_all(arm_inputs.iter().map(|(source, _, _, fetch_limit)| {
let arm: futures::future::BoxFuture<'_, Result<Arc<dyn ExecutionPlan>>> =
Box::pin(self.build_source_plan(
source,
columns,
&query,
*fetch_limit,
projection,
&index_params,
&target_schema,
));
arm
}))
.await?;
let mut per_source_plans: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(sources.len());
for ((_, _, blocked, _), plan) in arm_inputs.iter().zip(built) {
let blocked = *blocked;
let deduped = if let Some(set) = blocked
&& !self.pk_columns.is_empty()
{
Arc::new(PkBlockFilterExec::new(
plan,
self.pk_columns.clone(),
set.clone(),
limit.unwrap_or(usize::MAX),
)) as Arc<dyn ExecutionPlan>
} else {
plan
};
let normalized = project_to_canonical(deduped, &target_schema)?;
per_source_plans.push(normalized);
}
let merged: Arc<dyn ExecutionPlan> = if per_source_plans.len() == 1 {
per_source_plans.into_iter().next().unwrap()
} else {
#[allow(deprecated)]
Arc::new(UnionExec::new(per_source_plans))
};
self.sort_by_score(merged, limit)
}
async fn resolve_column_index_contract(
&self,
sources: &[LsmDataSource],
column: &str,
requested: Option<DocumentGranularity>,
) -> Result<(DocumentGranularity, Option<InvertedIndexParams>)> {
let mut available = Vec::new();
let mut source_granularities = Vec::with_capacity(sources.len());
let mut index_params: Vec<(DocumentGranularity, InvertedIndexParams)> = Vec::new();
for source in sources {
let granularities = match source {
LsmDataSource::BaseTable { dataset } => {
if index_params.is_empty() {
index_params = indexed_fts_index_params(dataset, column).await?;
}
indexed_fts_document_granularities(dataset, column)
.await?
.into_iter()
.map(|(_, document_granularity)| document_granularity)
.collect::<Vec<_>>()
}
LsmDataSource::SsTable { path, .. } => {
let dataset = open_sstable(
path,
self.session.as_ref(),
self.store_params.as_ref(),
self.sstable_cache.as_ref(),
self.warmer.as_ref(),
)
.await?;
let generation = GenerationRead::new(
dataset.schema(),
&self.identity_schema,
&self.pk_columns,
Vec::new(),
);
match generation.stored_name(column) {
None => Vec::new(),
Some(stored_column) => {
if index_params.is_empty() {
index_params =
indexed_fts_index_params(&dataset, stored_column).await?;
}
indexed_fts_document_granularities(&dataset, stored_column)
.await?
.into_iter()
.map(|(_, document_granularity)| document_granularity)
.collect::<Vec<_>>()
}
}
}
LsmDataSource::ActiveMemTable { index_store, .. } => {
index_store.fts_document_granularities_by_column(column)
}
};
available.extend(granularities.iter().copied());
source_granularities.push(granularities);
}
let document_granularity =
resolve_document_granularity_from_candidates(column, requested, available)?;
validate_source_document_granularities(
column,
document_granularity,
&source_granularities,
)?;
let params = index_params
.into_iter()
.find(|(granularity, _)| *granularity == document_granularity)
.map(|(_, params)| params);
Ok((document_granularity, params))
}
fn sort_by_score(
&self,
merged: Arc<dyn ExecutionPlan>,
limit: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
let score_idx = merged.schema().index_of(SCORE_COLUMN).map_err(|_| {
Error::internal(format!(
"{SCORE_COLUMN} missing from canonical FTS schema after merge"
))
})?;
let sort_expr = vec![PhysicalSortExpr {
expr: Arc::new(Column::new(SCORE_COLUMN, score_idx)),
options: SortOptions {
descending: true,
nulls_first: false,
},
}];
let lex_ordering = LexOrdering::new(sort_expr).ok_or_else(|| {
Error::internal("Failed to build LexOrdering for FTS _score sort".to_string())
})?;
let per_partition_sorted: Arc<dyn ExecutionPlan> = Arc::new(
SortExec::new(lex_ordering.clone(), merged)
.with_preserve_partitioning(true)
.with_fetch(limit),
);
Ok(Arc::new(
SortPreservingMergeExec::new(lex_ordering, per_partition_sorted).with_fetch(limit),
))
}
#[allow(clippy::too_many_arguments)]
async fn build_source_plan(
&self,
source: &LsmDataSource,
columns: &[String],
query: &FullTextSearchQuery,
limit: Option<usize>,
projection: Option<&[String]>,
index_params: &HashMap<&str, InvertedIndexParams>,
target_schema: &SchemaRef,
) -> Result<Arc<dyn ExecutionPlan>> {
let bind_column = match columns {
[column] => Some(column.as_str()),
_ => None,
};
let bind = |query: &FullTextSearchQuery| -> Result<FullTextSearchQuery> {
let bound = match bind_column {
Some(column) => query.clone().with_column(column.to_string())?,
None => query.clone(),
};
Ok(match limit {
Some(limit) => bound.limit(Some(limit as i64)),
None => bound.limit(None),
})
};
match source {
LsmDataSource::BaseTable { dataset } => {
let mut scanner = dataset.scan();
let cols = self.fts_scanner_projection(projection);
scanner.project_with_schema(&dataset.schema().project(&cols)?)?;
if let Some(ref filter) = self.filter {
scanner.filter_expr(filter.clone());
scanner.prefilter(true);
}
scanner.full_text_search(bind(query)?)?;
scanner.create_plan().await
}
LsmDataSource::SsTable { path, .. } => {
let dataset = open_sstable(
path,
self.session.as_ref(),
self.store_params.as_ref(),
self.sstable_cache.as_ref(),
self.warmer.as_ref(),
)
.await?;
let mut scanner = dataset.scan();
let asked_for = self.fts_scanner_projection(projection);
let mut generation = GenerationRead::new(
dataset.schema(),
&self.identity_schema,
&self.pk_columns,
asked_for,
);
let mut stored_columns = Vec::with_capacity(columns.len());
for column in columns {
let Some(stored) = generation.stored_name(column) else {
return self.empty_plan(target_schema);
};
stored_columns.push((column.clone(), stored.to_string()));
}
let (stored_filter, above) = generation.split_filter(self.filter.as_ref());
scanner.project_with_schema(
&dataset.schema().project(&generation.stored_projection())?,
)?;
if let Some(ref stored) = stored_filter {
scanner.filter_expr(stored.clone());
scanner.prefilter(true);
}
let bound_query = match stored_columns.as_slice() {
_ if stored_columns.iter().all(|(asked, stored)| asked == stored) => {
match columns {
[column] => query.clone().with_column(column.clone())?,
_ => query.clone(),
}
}
[(_, stored)] => query.clone().with_column(stored.clone())?,
moved => {
let renamed: Vec<String> = moved
.iter()
.filter(|(asked, stored)| asked != stored)
.map(|(asked, stored)| format!("{asked} (stored as {stored})"))
.collect();
return Err(Error::invalid_input(format!(
"a full-text search over several columns cannot read a sealed \
generation in which one of them was renamed: {}. Compact the table \
so the generation merges into base, or search the columns separately",
renamed.join(", ")
)));
}
};
let bound_query = match limit.filter(|_| above.is_none()) {
Some(limit) => bound_query.limit(Some(limit as i64)),
None => bound_query.limit(None),
};
scanner.full_text_search(bound_query)?;
let reconciled = generation.reconcile(Box::pin(scanner.create_plan()).await?)?;
match &above {
Some(expr) => filter_above(reconciled, expr),
None => Ok(reconciled),
}
}
LsmDataSource::ActiveMemTable {
batch_store,
index_store,
schema,
..
} => {
let document_granularity = query_document_granularity(query)?;
let index_store = if columns.iter().all(|column| {
active_source_can_execute_fts(source, column, document_granularity)
}) {
index_store.clone()
} else {
match transient_fts_index_store(
batch_store,
index_store,
schema,
columns,
document_granularity,
&self.pk_columns,
index_params,
)? {
Some(store) => store,
None => {
return self.empty_plan(
&self.canonical_fts_schema(projection, document_granularity)?,
);
}
}
};
validate_lsm_fts_query(query)?;
let mut scanner =
MemTableScanner::new(batch_store.clone(), index_store, schema.clone());
let cols = self.fts_scanner_projection(projection);
scanner.project(&cols.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
if let Some(ref filter) = self.filter {
scanner.filter_expr(filter.clone());
}
if !self.pk_columns.is_empty() {
scanner.with_pk_columns(self.pk_columns.clone());
}
scanner.full_text_search(bind(query)?)?;
scanner.create_plan().await
}
}
}
fn fts_scanner_projection(&self, user_projection: Option<&[String]>) -> Vec<String> {
let mut cols: Vec<String> = if let Some(p) = user_projection {
p.iter()
.filter(|c| !is_system_column(c) && c.as_str() != SCORE_COLUMN)
.cloned()
.collect()
} else {
self.base_schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect()
};
for pk in &self.pk_columns {
if !cols.contains(pk) {
cols.push(pk.clone());
}
}
cols
}
fn canonical_fts_schema(
&self,
user_projection: Option<&[String]>,
document_granularity: DocumentGranularity,
) -> Result<SchemaRef> {
let mut ordered: Vec<String> = if let Some(p) = user_projection {
p.to_vec()
} else {
self.base_schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect()
};
for pk in &self.pk_columns {
if !ordered.contains(pk) {
ordered.push(pk.clone());
}
}
if document_granularity.is_list_element() && !ordered.iter().any(|c| c == DOC_INDEX_COL) {
ordered.push(DOC_INDEX_COL.to_string());
}
if !ordered.iter().any(|c| c == SCORE_COLUMN) {
ordered.push(SCORE_COLUMN.to_string());
}
let data_names: Vec<String> = ordered
.iter()
.filter(|n| {
n.as_str() != SCORE_COLUMN && n.as_str() != DOC_INDEX_COL && !is_system_column(n)
})
.cloned()
.collect();
let mut by_name: HashMap<String, Arc<Field>> =
resolve_data_fields(&data_names, &self.base_schema)?
.into_iter()
.map(|f| (f.name().clone(), f))
.collect();
let mut fields: Vec<Arc<Field>> = Vec::with_capacity(ordered.len());
for name in &ordered {
if name == SCORE_COLUMN {
fields.push(Arc::new(Field::new(SCORE_COLUMN, DataType::Float32, true)));
} else if name == DOC_INDEX_COL {
fields.push(Arc::new(DOC_INDEX_FIELD.clone()));
} else if is_system_column(name) {
fields.push(Arc::new(Field::new(name.clone(), DataType::UInt64, true)));
} else if let Some(field) = by_name.remove(&top_level_of(name)?) {
fields.push(field);
}
}
Ok(Arc::new(Schema::new(fields)))
}
fn empty_plan(&self, schema: &SchemaRef) -> Result<Arc<dyn ExecutionPlan>> {
use datafusion::physical_plan::empty::EmptyExec;
Ok(Arc::new(EmptyExec::new(schema.clone())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dataset::mem_wal::scanner::collector::{InMemoryMemTableRef, InMemoryMemTables};
use crate::dataset::mem_wal::write::{BatchStore, IndexStore};
use crate::dataset::{Dataset, WriteParams};
use arrow_array::builder::{ListBuilder, StringBuilder};
use arrow_array::cast::AsArray;
use arrow_array::{
Array, BooleanArray, Float32Array, Int32Array, ListArray, RecordBatch, RecordBatchIterator,
StringArray, UInt32Array,
};
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use futures::TryStreamExt;
use lance_index::scalar::inverted::query::MatchQuery;
use std::collections::HashMap;
fn fts_schema() -> Arc<ArrowSchema> {
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("text", DataType::Utf8, true),
]))
}
fn two_column_fts_schema() -> Arc<ArrowSchema> {
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("title", DataType::Utf8, true),
Field::new("body", DataType::Utf8, true),
]))
}
fn make_two_column_batch(schema: &ArrowSchema, rows: &[(i32, &str, &str)]) -> RecordBatch {
RecordBatch::try_new(
Arc::new(schema.clone()),
vec![
Arc::new(Int32Array::from(
rows.iter().map(|(id, _, _)| *id).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|(_, t, _)| *t).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|(_, _, b)| *b).collect::<Vec<_>>(),
)),
],
)
.unwrap()
}
fn fts_tombstone_schema() -> Arc<ArrowSchema> {
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("text", DataType::Utf8, true),
Field::new(crate::dataset::mem_wal::TOMBSTONE, DataType::Boolean, false),
]))
}
fn make_batch(schema: &ArrowSchema, ids: &[i32], texts: &[&str]) -> RecordBatch {
RecordBatch::try_new(
Arc::new(schema.clone()),
vec![
Arc::new(Int32Array::from(ids.to_vec())),
Arc::new(StringArray::from(texts.to_vec())),
],
)
.unwrap()
}
fn make_tombstone_batch(
schema: &ArrowSchema,
rows: &[(i32, Option<&str>, bool)],
) -> RecordBatch {
let ids: Vec<i32> = rows.iter().map(|(id, _, _)| *id).collect();
let texts: Vec<Option<&str>> = rows.iter().map(|(_, text, _)| *text).collect();
let tombstones: Vec<bool> = rows.iter().map(|(_, _, tombstone)| *tombstone).collect();
RecordBatch::try_new(
Arc::new(schema.clone()),
vec![
Arc::new(Int32Array::from(ids)),
Arc::new(StringArray::from(texts)),
Arc::new(BooleanArray::from(tombstones)),
],
)
.unwrap()
}
async fn write_dataset(uri: &str, batches: Vec<RecordBatch>) -> Dataset {
let schema = batches[0].schema();
let has_id = schema.column_with_name("id").is_some();
let reader = RecordBatchIterator::new(batches.clone().into_iter().map(Ok), schema);
let dataset = Dataset::write(reader, uri, Some(WriteParams::default()))
.await
.unwrap();
if has_id {
crate::dataset::mem_wal::scanner::block_list::write_pk_sidecar(uri, &batches, &["id"])
.await
.unwrap();
}
dataset
}
fn nested_fts_schema() -> Arc<ArrowSchema> {
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(id_meta),
Field::new("text", DataType::Utf8, true),
Field::new("meta", DataType::Struct(nested_meta_fields()), true),
]))
}
fn nested_meta_fields() -> arrow_schema::Fields {
arrow_schema::Fields::from(vec![
Field::new("a", DataType::Int64, true),
Field::new("b", DataType::Utf8, true),
])
}
fn meta_children(schema: &ArrowSchema) -> Vec<String> {
let DataType::Struct(fields) = schema
.field_with_name("meta")
.expect("meta survived canonicalization")
.data_type()
.clone()
else {
panic!("meta is not a struct");
};
fields.iter().map(|f| f.name().clone()).collect()
}
#[tokio::test]
async fn nested_projection_survives_fts_canonicalization() {
let schema = nested_fts_schema();
let meta = arrow_array::StructArray::new(
nested_meta_fields(),
vec![
Arc::new(arrow_array::Int64Array::from(vec![10i64, 20])) as Arc<dyn Array>,
Arc::new(StringArray::from(vec!["b_1", "b_2"])) as Arc<dyn Array>,
],
None,
);
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["lance rocks", "unrelated"])),
Arc::new(meta),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: Arc::new(indexes),
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let projection = vec!["meta.a".to_string()];
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
Some(&projection),
)
.await
.unwrap();
assert_eq!(meta_children(&plan.schema()), vec!["a"]);
let ctx = datafusion::prelude::SessionContext::new();
let batches: Vec<RecordBatch> = plan
.execute(0, ctx.task_ctx())
.unwrap()
.try_collect()
.await
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 1, "only id=1 contains 'lance'");
let hit = batches.iter().find(|b| b.num_rows() > 0).unwrap();
assert_eq!(meta_children(hit.schema_ref()), vec!["a"]);
assert_eq!(
hit.column_by_name("meta")
.unwrap()
.as_struct()
.column_by_name("a")
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Int64Array>()
.unwrap()
.value(0),
10
);
}
#[tokio::test]
async fn empty_fts_plan_preserves_nested_projection() {
let schema = nested_fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let projection = vec!["meta.a".to_string()];
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
Some(&projection),
)
.await
.unwrap();
assert_eq!(meta_children(&plan.schema()), vec!["a"]);
}
#[tokio::test]
async fn rejects_missing_projection_column() {
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let projection = vec!["missing".to_string()];
let err = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
Some(&projection),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("missing"),
"unexpected missing-column projection error: {err}"
);
}
#[test]
fn resolves_document_granularity_from_memwal_indexes() {
assert_eq!(
resolve_document_granularity_from_candidates("tags", None, vec![]).unwrap(),
DocumentGranularity::Row
);
assert_eq!(
resolve_document_granularity_from_candidates(
"tags",
None,
vec![DocumentGranularity::ListElement],
)
.unwrap(),
DocumentGranularity::ListElement
);
assert!(
resolve_document_granularity_from_candidates(
"tags",
Some(DocumentGranularity::Row),
vec![DocumentGranularity::ListElement],
)
.unwrap_err()
.to_string()
.contains("different indexed granularity")
);
let both = vec![DocumentGranularity::Row, DocumentGranularity::ListElement];
assert!(
resolve_document_granularity_from_candidates("tags", None, both.clone())
.unwrap_err()
.to_string()
.contains("ambiguous")
);
assert_eq!(
resolve_document_granularity_from_candidates(
"tags",
Some(DocumentGranularity::ListElement),
both,
)
.unwrap(),
DocumentGranularity::ListElement
);
assert!(
validate_source_document_granularities(
"tags",
DocumentGranularity::ListElement,
&[
vec![DocumentGranularity::ListElement],
vec![DocumentGranularity::Row],
],
)
.unwrap_err()
.to_string()
.contains("incompatible indexed granularities")
);
}
#[tokio::test]
async fn memwal_rejects_list_element_without_a_list_path() {
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance".to_string())
.with_document_granularity(DocumentGranularity::ListElement),
));
let error = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(1),
None,
)
.await
.unwrap_err();
assert!(error.to_string().contains("has no List layer"), "{error}");
}
#[tokio::test]
async fn active_element_document_search_returns_physical_ordinals() {
use lance_index::scalar::inverted::InvertedIndexParams;
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
let mut tags = ListBuilder::new(StringBuilder::new());
tags.values().append_value("alpha");
tags.values().append_value("beta gamma");
tags.values().append_null();
tags.values().append_value("beta");
tags.append(true);
tags.append(true);
tags.values().append_value("beta");
tags.append(true);
let tags = tags.finish();
let schema = Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("tags", tags.data_type().clone(), true),
]));
let active_batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(Int32Array::from(vec![1, 2, 3])), Arc::new(tags)],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes
.add_fts_with_params(
"tags_list_element_fts".to_string(),
1,
"tags".to_string(),
InvertedIndexParams::default()
.document_granularity(DocumentGranularity::ListElement),
)
.unwrap();
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: Arc::new(indexes),
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema.clone());
let projection = vec!["id".to_string()];
let plan = planner
.plan_search(
FullTextSearchQuery::new_query(IndexFtsQuery::Match(MatchQuery::new(
"beta".to_string(),
)))
.with_column("tags".to_string())
.unwrap(),
Some(10),
Some(&projection),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut hits = Vec::new();
for batch in batches {
let ids = batch["id"].as_any().downcast_ref::<Int32Array>().unwrap();
let doc_indices = batch[DOC_INDEX_COL]
.as_any()
.downcast_ref::<ListArray>()
.unwrap();
for row in 0..batch.num_rows() {
let coordinate = doc_indices
.value(row)
.as_any()
.downcast_ref::<UInt32Array>()
.unwrap()
.value(0);
hits.push((ids.value(row), coordinate));
}
}
hits.sort_unstable();
assert_eq!(hits, vec![(1, 1), (1, 3), (3, 0)]);
}
#[tokio::test]
async fn element_document_search_unions_base_and_active_sources() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::InvertedIndexParams;
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
let list_type = DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)));
let schema = Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("tags", list_type, true),
]));
let mut base_tags = ListBuilder::new(StringBuilder::new());
base_tags.values().append_value("beta");
base_tags.values().append_value("other");
base_tags.append(true);
let base_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1])),
Arc::new(base_tags.finish()),
],
)
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = Dataset::write(
RecordBatchIterator::new(vec![Ok(base_batch)], schema.clone()),
&base_uri,
None,
)
.await
.unwrap();
base_ds
.create_index(
&["tags"],
IndexType::Inverted,
Some("tags_list_element_fts".to_string()),
&InvertedIndexParams::default()
.document_granularity(DocumentGranularity::ListElement),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let mut active_tags = ListBuilder::new(StringBuilder::new());
active_tags.values().append_value("other");
active_tags.values().append_value("beta");
active_tags.append(true);
let active_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![2])),
Arc::new(active_tags.finish()),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes
.add_fts_with_params(
"tags_list_element_fts".to_string(),
1,
"tags".to_string(),
InvertedIndexParams::default()
.document_granularity(DocumentGranularity::ListElement),
)
.unwrap();
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: Arc::new(indexes),
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let projection = vec!["id".to_string()];
let plan = planner
.plan_search(
FullTextSearchQuery::new_query(IndexFtsQuery::Match(MatchQuery::new(
"beta".to_string(),
)))
.with_column("tags".to_string())
.unwrap(),
Some(10),
Some(&projection),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let batches: Vec<RecordBatch> = plan
.execute(0, ctx.task_ctx())
.unwrap()
.try_collect()
.await
.unwrap();
let mut hits = Vec::new();
for batch in batches {
let ids = batch["id"].as_any().downcast_ref::<Int32Array>().unwrap();
let coordinates = batch[DOC_INDEX_COL]
.as_any()
.downcast_ref::<ListArray>()
.unwrap();
for row in 0..batch.num_rows() {
let coordinate = coordinates
.value(row)
.as_any()
.downcast_ref::<UInt32Array>()
.unwrap()
.value(0);
hits.push((ids.value(row), coordinate));
}
}
hits.sort_unstable();
assert_eq!(hits, vec![(1, 0), (2, 1)]);
}
#[tokio::test]
async fn local_mode_unions_base_and_active_with_consistent_score_schema() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(
&schema,
&[1, 2],
&["lance rocks", "unrelated text"],
)],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(
&schema,
&[3, 4],
&["lance memwal goes fast", "completely unrelated"],
);
batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a base+active union plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert!(
total >= 2,
"expected at least the 2 'lance' rows from base+active, got {total}"
);
let out = batches[0].schema();
let score_field = out
.field_with_name(SCORE_COLUMN)
.expect("_score column missing from output");
assert!(
score_field.is_nullable(),
"_score must be nullable to stay union-compatible across base+active"
);
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert!(ids.contains(&1), "missing base hit id=1; got ids={ids:?}");
assert!(ids.contains(&3), "missing active hit id=3; got ids={ids:?}");
}
#[tokio::test]
async fn prefilter_drops_nonmatching_hits_across_base_and_active() {
use crate::index::DatasetIndexExt;
use datafusion::prelude::{col, lit};
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(&schema, &[1, 2], &["lance one", "lance two"])],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(&schema, &[0, 3], &["lance zero", "lance three"]);
batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("id").gt_eq(lit(2i32))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a filtered base+active plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
ids.sort();
assert_eq!(
ids,
vec![2, 3],
"prefilter must keep only id>=2 across base (id=2) and active (id=3), \
dropping base id=1 and active id=0; got {ids:?}"
);
}
#[tokio::test]
async fn a_cross_column_predicate_over_a_renamed_generation_is_refused() {
use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot;
use crate::index::DatasetIndexExt;
use lance_core::datatypes::LANCE_FIELD_ID_KEY;
use lance_index::IndexType;
use lance_index::scalar::inverted::query::{BooleanQuery, MatchQuery, Occur};
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let stored_schema = two_column_fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let shard_id = uuid::Uuid::new_v4();
let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id);
let mut gen1 = write_dataset(
&gen1_uri,
vec![make_two_column_batch(
&stored_schema,
&[(1, "alpha", "beta")],
)],
)
.await;
for column in ["title", "body"] {
gen1.create_index(
&[column],
IndexType::Inverted,
Some(format!("{column}_fts")),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
}
let stamp = |name: &str, id: i32, nullable: bool| {
Field::new(name, DataType::Utf8, nullable).with_metadata(HashMap::from([(
LANCE_FIELD_ID_KEY.to_string(),
id.to_string(),
)]))
};
let mut pk_meta = HashMap::from([(LANCE_FIELD_ID_KEY.to_string(), "0".to_string())]);
pk_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let table_schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(pk_meta),
stamp("heading", 1, true),
stamp("body", 2, true),
]));
let snapshot = ShardSnapshot::new(shard_id)
.with_current_generation(2)
.with_sstable(1, "gen_1".to_string());
let collector =
LsmDataSourceCollector::without_base_table(base_uri.clone(), vec![snapshot]);
let planner =
LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], table_schema.clone())
.with_identity_schema(Arc::clone(&table_schema));
let query = |first: &str| {
FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(
Occur::Must,
MatchQuery::new("alpha".to_string())
.with_column(Some(first.to_string()))
.into(),
),
(
Occur::Must,
MatchQuery::new("beta".to_string())
.with_column(Some("body".to_string()))
.into(),
),
])))
};
let error = planner
.plan_search(query("heading"), Some(10), None)
.await
.expect_err("a renamed column in a cross-column predicate must be refused");
let message = error.to_string();
assert!(
message.contains("renamed") && message.contains("heading"),
"the refusal should name the column that moved, got: {message}"
);
let unmoved = Arc::new(ArrowSchema::new(vec![
table_schema.field(0).clone(),
stamp("title", 1, true),
stamp("body", 2, true),
]));
let snapshot = ShardSnapshot::new(shard_id)
.with_current_generation(2)
.with_sstable(1, "gen_1".to_string());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![snapshot]);
LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], unmoved.clone())
.with_identity_schema(unmoved)
.plan_search(query("title"), Some(10), None)
.await
.expect("an unmoved cross-column predicate still plans over the generation");
}
#[tokio::test]
async fn prefilter_on_sstable_composes_with_block_list() {
use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot;
use crate::index::DatasetIndexExt;
use datafusion::prelude::{col, lit};
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let shard_id = uuid::Uuid::new_v4();
let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id);
let mut gen1 = write_dataset(
&gen1_uri,
vec![make_batch(
&schema,
&[1, 3, 4],
&["lance lance lance lance", "lance lance lance", "lance"],
)],
)
.await;
gen1.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, shard_id);
let mut gen2 =
write_dataset(&gen2_uri, vec![make_batch(&schema, &[3], &["other text"])]).await;
gen2.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let snapshot = ShardSnapshot::new(shard_id)
.with_current_generation(3)
.with_sstable(1, "gen_1".to_string())
.with_sstable(2, "gen_2".to_string());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![snapshot]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("id").gt_eq(lit(3i32))))
.with_overfetch_factor(2.0);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
None,
)
.await
.expect("planner should produce a filtered SSTable plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert_eq!(
ids,
vec![4],
"SSTable FTS prefilter should return live id=4 after stale id=3 is blocked; got {ids:?}"
);
}
#[tokio::test]
async fn active_tombstone_masks_base_fts_hit() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let base_schema = fts_schema();
let mem_schema = fts_tombstone_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base = write_dataset(
&base_uri,
vec![make_batch(&base_schema, &[1, 2], &["lance lance", "lance"])],
)
.await;
base.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let active_tombstone = make_tombstone_batch(&mem_schema, &[(1, None, true)]);
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut index_store = IndexStore::new();
index_store.enable_pk_index(&[("id".to_string(), 0)]);
index_store.add_fts("text_fts".to_string(), 1, "text".to_string());
let (_, row_offset, batch_position) = batch_store.append(active_tombstone.clone()).unwrap();
index_store
.insert_with_batch_position(&active_tombstone, row_offset, Some(batch_position))
.unwrap();
let index_store = Arc::new(index_store);
let collector = LsmDataSourceCollector::new(Arc::new(base), vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store,
schema: mem_schema,
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], base_schema)
.with_overfetch_factor(2.0);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
None,
)
.await
.unwrap();
let stream = plan
.execute(0, datafusion::prelude::SessionContext::new().task_ctx())
.unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert_eq!(
ids,
vec![2],
"active tombstone for id=1 must block the older base FTS hit; got {ids:?}"
);
}
#[tokio::test]
async fn active_update_and_tombstone_mask_frozen_fts_hits() {
let base_schema = fts_schema();
let mem_schema = fts_tombstone_schema();
let frozen_batch = make_tombstone_batch(
&mem_schema,
&[
(1, Some("lance stale update"), false),
(2, Some("lance live"), false),
(3, Some("lance deleted"), false),
],
);
let frozen_batch_store = Arc::new(BatchStore::with_capacity(16));
let mut frozen_index_store = IndexStore::new();
frozen_index_store.enable_pk_index(&[("id".to_string(), 0)]);
frozen_index_store.add_fts("text_fts".to_string(), 1, "text".to_string());
let (_, frozen_row_offset, frozen_batch_position) =
frozen_batch_store.append(frozen_batch.clone()).unwrap();
frozen_index_store
.insert_with_batch_position(
&frozen_batch,
frozen_row_offset,
Some(frozen_batch_position),
)
.unwrap();
let active_batch = make_tombstone_batch(
&mem_schema,
&[(1, Some("fresh other text"), false), (3, None, true)],
);
let active_batch_store = Arc::new(BatchStore::with_capacity(16));
let mut active_index_store = IndexStore::new();
active_index_store.enable_pk_index(&[("id".to_string(), 0)]);
active_index_store.add_fts("text_fts".to_string(), 1, "text".to_string());
let (_, active_row_offset, active_batch_position) =
active_batch_store.append(active_batch.clone()).unwrap();
active_index_store
.insert_with_batch_position(
&active_batch,
active_row_offset,
Some(active_batch_position),
)
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let shard_id = uuid::Uuid::new_v4();
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
shard_id,
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store: active_batch_store,
index_store: Arc::new(active_index_store),
schema: mem_schema.clone(),
generation: 2,
},
frozen: vec![InMemoryMemTableRef {
batch_store: frozen_batch_store,
index_store: Arc::new(frozen_index_store),
schema: mem_schema,
generation: 1,
}],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], base_schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.unwrap();
let stream = plan
.execute(0, datafusion::prelude::SessionContext::new().task_ctx())
.unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
ids.sort_unstable();
assert_eq!(
ids,
vec![2],
"active update id=1 and tombstone id=3 must block stale frozen FTS hits; got {ids:?}"
);
}
#[rstest::rstest]
#[case::match_query(false)]
#[case::single_column_multi_match(true)]
#[tokio::test]
async fn active_filtered_search_without_pk_applies_small_limit_after_filter(
#[case] multi_match: bool,
) {
use datafusion::prelude::{col, lit};
use lance_index::scalar::inverted::query::MultiMatchQuery;
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(
&schema,
&[1, 2, 3],
&["lance", "lance filler", "lance filler filler"],
);
batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec![], schema)
.with_filter(Some(col("id").gt_eq(lit(1i32))));
let query = if multi_match {
FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new("lance".to_string(), vec!["text".to_string()]).unwrap(),
))
} else {
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap()
};
let plan = planner
.plan_search(query, Some(2), None)
.await
.expect("planner should produce an active-only filtered plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 2],
"no-PK filtered active search must apply the limit after filtering \
and keep the top-scoring matching hits; got ids={ids:?}"
);
}
#[tokio::test]
async fn fuzzy_and_query_is_rejected_when_active_memtable_is_present() {
use lance_index::scalar::inverted::query::{
FtsQuery as IndexFtsQuery, MatchQuery, Operator,
};
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(&schema, &[1], &["lance memwal"]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec![], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance memwal".to_string())
.with_operator(Operator::And)
.with_fuzziness(Some(1)),
));
let err = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
None,
)
.await
.expect_err("fuzzy AND should be rejected consistently");
assert!(
err.to_string().contains("fuzzy full-text search"),
"unexpected error for fuzzy AND query: {err}"
);
}
#[tokio::test]
async fn base_only_boolean_query_uses_dataset_scanner_support() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::query::{
BooleanQuery, FtsQuery as IndexFtsQuery, MatchQuery, Occur,
};
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(
&schema,
&[1, 2],
&["lance rocks", "unrelated text"],
)],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let collector = LsmDataSourceCollector::new(base_ds, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(
vec![(Occur::Must, MatchQuery::new("lance".to_string()).into())],
)));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.expect("base-only boolean query should be delegated to dataset scanner");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
assert_eq!(ids, vec![1]);
}
#[tokio::test]
async fn boolean_query_searches_active_memtable_without_a_maintained_fts_index() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::query::{
BooleanQuery, FtsQuery as IndexFtsQuery, MatchQuery, Occur,
};
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(
&schema,
&[1, 2],
&["lance rocks", "unrelated text"],
)],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let active_batch = make_batch(&schema, &[99], &["active text has no fts index"]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(
vec![(Occur::Must, MatchQuery::new("lance".to_string()).into())],
)));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.expect("an unmaintained column must be indexed for the query, not skipped");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
assert_eq!(ids, vec![1]);
}
#[tokio::test]
async fn boolean_query_reaches_the_active_memtable() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::query::{
BooleanQuery, FtsQuery as IndexFtsQuery, MatchQuery, Occur,
};
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(
&schema,
&[1, 2],
&["lance rocks", "unrelated text"],
)],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(&schema, &[10, 11], &["lance memwal", "lance beta"]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let leaf = |terms: &str| IndexFtsQuery::from(MatchQuery::new(terms.to_string()));
let query =
FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, leaf("lance")),
(Occur::MustNot, leaf("beta")),
])));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.expect("an active memtable with an FTS index must serve a boolean query");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 10],
"id=1 from base and id=10 from the memtable match MUST 'lance'; \
id=2 lacks it and id=11 is excluded by MUST_NOT 'beta'"
);
}
#[tokio::test]
async fn unmaintained_column_is_indexed_for_the_query() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(&schema, &[1, 2], &["lance rocks", "unrelated"])],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let active_batch = make_batch(&schema, &[10, 11], &["lance in memtable", "no match here"]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
assert!(
indexes
.fts_document_granularities_by_column("text")
.is_empty(),
"precondition: the memtable maintains no FTS index on the column"
);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Match(MatchQuery::new(
"lance".to_string(),
)));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 10],
"id=10 lives only in the memtable and matches 'lance'; without a \
transient index the arm contributes nothing and only id=1 comes back"
);
}
#[tokio::test]
async fn transient_index_inherits_persisted_index_params() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::query::PhraseQuery;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(&schema, &[1, 2], &["lance rocks", "unrelated"])],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default().with_position(true),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let active_batch = make_batch(&schema, &[10], &["lance rocks in memtable"]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Phrase(PhraseQuery::new(
"lance rocks".to_string(),
)));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 10],
"id=10 carries the phrase in the memtable; a default-params transient \
index has no positions and drops it"
);
}
#[tokio::test]
async fn transient_index_uses_params_for_selected_granularity() {
use crate::index::DatasetIndexExt;
use lance_index::IndexType;
use lance_index::scalar::inverted::InvertedIndexParams;
use lance_index::scalar::inverted::query::PhraseQuery;
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
let list_type = DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)));
let schema = Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("tags", list_type, true),
]));
let mut base_tags = ListBuilder::new(StringBuilder::new());
base_tags.values().append_value("lance rocks");
base_tags.append(true);
let base_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1])),
Arc::new(base_tags.finish()),
],
)
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = Dataset::write(
RecordBatchIterator::new(vec![Ok(base_batch)], schema.clone()),
&base_uri,
None,
)
.await
.unwrap();
base_ds
.create_index(
&["tags"],
IndexType::Inverted,
Some("tags_row_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
base_ds
.create_index(
&["tags"],
IndexType::Inverted,
Some("tags_element_fts".to_string()),
&InvertedIndexParams::default()
.with_position(true)
.document_granularity(DocumentGranularity::ListElement),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let params = indexed_fts_index_params(&base_ds, "tags").await.unwrap();
let row = params
.iter()
.find(|(g, _)| *g == DocumentGranularity::Row)
.expect("row index");
let element = params
.iter()
.find(|(g, _)| *g == DocumentGranularity::ListElement)
.expect("list-element index");
assert!(!row.1.has_positions(), "row index must lack positions");
assert!(
element.1.has_positions(),
"element index must have positions"
);
let mut active_tags = ListBuilder::new(StringBuilder::new());
active_tags.values().append_value("lance rocks");
active_tags.append(true);
let active_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![10])),
Arc::new(active_tags.finish()),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Phrase(
PhraseQuery::new("lance rocks".to_string())
.with_document_granularity(DocumentGranularity::ListElement),
));
let plan = planner
.plan_search(
query.with_column("tags".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(
ids,
vec![1, 10],
"id=10 is in the memtable; rebuilding against the row index's \
positionless settings drops it"
);
}
#[tokio::test]
async fn empty_memtable_builds_no_transient_index() {
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Match(MatchQuery::new(
"lance".to_string(),
)));
let plan = planner
.plan_search(
query.with_column("text".to_string()).unwrap(),
Some(10),
Some(&["id".to_string()]),
)
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 0);
}
#[rstest::rstest]
#[case::one_leaf(vec!["text"], false)]
#[case::two_leaves_one_column(vec!["text", "text"], false)]
#[case::one_leaf_per_column(vec!["title", "body"], true)]
#[case::mixed(vec!["title", "title", "body"], true)]
fn multi_match_decomposes_only_when_it_spans_columns(
#[case] leaves: Vec<&str>,
#[case] spans_columns: bool,
) {
use lance_index::scalar::inverted::query::MultiMatchQuery;
let multi = MultiMatchQuery::try_new(
"lance".to_string(),
leaves.iter().map(|leaf| leaf.to_string()).collect(),
)
.unwrap();
match fts_plan_shape(&IndexFtsQuery::MultiMatch(multi)).unwrap() {
FtsPlanShape::Bound(columns) => {
assert!(!spans_columns, "expected one arm per leaf for {leaves:?}");
assert_eq!(columns, vec![leaves[0].to_string()]);
}
FtsPlanShape::PerColumn(arms) => {
assert!(spans_columns, "expected the bound path for {leaves:?}");
assert_eq!(
arms.iter()
.map(|(column, _)| column.as_str())
.collect::<Vec<_>>(),
leaves
);
}
}
}
#[tokio::test]
async fn single_column_multi_match_scores_each_row_by_its_best_leaf() {
use lance_index::scalar::inverted::query::MultiMatchQuery;
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_batch(
&schema,
&[1, 2, 3],
&["lance rocks", "lance lance", "nothing"],
);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let ctx = datafusion::prelude::SessionContext::new();
let scores_for = |query: FullTextSearchQuery| {
let planner = &planner;
let ctx = &ctx;
async move {
let plan = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.expect("plans");
let batches: Vec<RecordBatch> = plan
.execute(0, ctx.task_ctx())
.unwrap()
.try_collect()
.await
.unwrap();
let mut scores: Vec<(i32, f32)> = batches
.iter()
.flat_map(|batch| {
let ids = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let scores = batch
.column_by_name(SCORE_COLUMN)
.unwrap()
.as_any()
.downcast_ref::<Float32Array>()
.unwrap();
(0..batch.num_rows())
.map(|i| (ids.value(i), scores.value(i)))
.collect::<Vec<_>>()
})
.collect();
scores.sort_by_key(|(id, _)| *id);
scores
}
};
let multi = MultiMatchQuery::try_new(
"lance".to_string(),
vec!["text".to_string(), "text".to_string()],
)
.unwrap()
.try_with_boosts(vec![1.0, 2.0])
.unwrap();
let fused = scores_for(FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
multi,
)))
.await;
let dominating = scores_for(FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance".to_string())
.with_column(Some("text".to_string()))
.with_boost(2.0),
)))
.await;
assert_eq!(
fused.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
vec![1, 2],
"each matching row once; id=3 matches nothing"
);
assert_eq!(fused.len(), dominating.len());
for ((id, fused_score), (_, dominating_score)) in fused.iter().zip(&dominating) {
assert!(
(fused_score - dominating_score).abs() < 1e-6,
"id={id}: best-leaf score {fused_score} != boost-2 leaf alone {dominating_score}"
);
}
}
#[tokio::test]
async fn nested_multi_match_reaches_the_active_memtable() {
use lance_index::scalar::inverted::query::{BooleanQuery, MultiMatchQuery, Occur};
let schema = two_column_fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("title_fts".to_string(), 1, "title".to_string());
indexes.add_fts("body_fts".to_string(), 2, "body".to_string());
let active_batch = make_two_column_batch(
&schema,
&[
(1, "lance title", "unrelated body"), (2, "unrelated title", "lance body"), (3, "lance title", "lance body"), (4, "spam lance title", "lance body"), (5, "nothing", "nothing"), ],
);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let multi = IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["title".to_string(), "body".to_string()],
)
.unwrap(),
);
let spam = IndexFtsQuery::Match(
MatchQuery::new("spam".to_string()).with_column(Some("title".to_string())),
);
let query =
FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, multi),
(Occur::MustNot, spam),
])));
let plan = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.expect("a boolean over a multi-match must plan");
let ctx = datafusion::prelude::SessionContext::new();
let batches: Vec<RecordBatch> = plan
.execute(0, ctx.task_ctx())
.unwrap()
.try_collect()
.await
.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 2, 3],
"both columns searched through the nested multi-match, id=4 excluded, id=3 once"
);
}
#[tokio::test]
async fn phrase_over_an_unindexed_column_reaches_the_active_memtable() {
use lance_index::scalar::inverted::query::PhraseQuery;
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
let active_batch = make_batch(
&schema,
&[1, 2, 3],
&["alpha prose here", "prose alpha reversed", "nothing"],
);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let ctx = datafusion::prelude::SessionContext::new();
let ids_for = |query: FullTextSearchQuery| {
let planner = &planner;
let ctx = &ctx;
async move {
let plan = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.expect("plans");
let batches: Vec<RecordBatch> = plan
.execute(0, ctx.task_ctx())
.unwrap()
.try_collect()
.await
.unwrap();
let mut ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
ids.sort_unstable();
ids
}
};
let matched = ids_for(FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("alpha".to_string()).with_column(Some("text".to_string())),
)))
.await;
assert_eq!(
matched,
vec![1, 2],
"precondition: the transient index serves a match"
);
let phrase = ids_for(FullTextSearchQuery::new_query(IndexFtsQuery::Phrase(
PhraseQuery::new("alpha prose".to_string()).with_column(Some("text".to_string())),
)))
.await;
assert_eq!(
phrase,
vec![1],
"the phrase matches row 1 only; row 2 carries the terms reversed"
);
}
#[tokio::test]
async fn multi_match_searches_every_column_and_collapses_duplicates() {
use lance_index::scalar::inverted::query::MultiMatchQuery;
let schema = two_column_fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("title_fts".to_string(), 1, "title".to_string());
indexes.add_fts("body_fts".to_string(), 2, "body".to_string());
let active_batch = make_two_column_batch(
&schema,
&[
(1, "lance title", "unrelated body"), (2, "unrelated title", "lance body"), (3, "lance title", "lance body"), (4, "nothing", "nothing"), ],
);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["title".to_string(), "body".to_string()],
)
.unwrap(),
));
let plan = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.expect("a cross-column multi-match must plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(
sorted,
vec![1, 2, 3],
"every column must be searched; id=4 matches neither"
);
assert_eq!(
ids.len(),
3,
"id=3 matches in both columns and must collapse to one row, got {ids:?}"
);
}
#[tokio::test]
async fn cross_column_limit_counts_rows_not_field_hits() {
use lance_index::scalar::inverted::query::MultiMatchQuery;
let schema = two_column_fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("title_fts".to_string(), 1, "title".to_string());
indexes.add_fts("body_fts".to_string(), 2, "body".to_string());
let active_batch = make_two_column_batch(
&schema,
&[
(3, "lance lance lance", "lance lance lance"),
(1, "lance title", "unrelated body"),
(2, "unrelated title", "lance body"),
],
);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["title".to_string(), "body".to_string()],
)
.unwrap(),
));
let plan = planner
.plan_search(query, Some(2), Some(&["id".to_string()]))
.await
.unwrap();
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let ids: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect();
assert_eq!(
ids.len(),
2,
"limit 2 must return two distinct rows, got {ids:?}"
);
let mut distinct = ids.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(distinct.len(), 2, "rows must be distinct, got {ids:?}");
}
#[tokio::test]
async fn unbound_query_columns_are_refused() {
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let err = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string()),
Some(10),
None,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("name the columns to search"),
"unexpected unbound-column error: {err}"
);
}
#[tokio::test]
async fn cross_column_without_a_primary_key_is_refused() {
use lance_index::scalar::inverted::query::MultiMatchQuery;
let schema = two_column_fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec![], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["title".to_string(), "body".to_string()],
)
.unwrap(),
));
let err = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.unwrap_err();
assert!(
err.to_string().contains("requires a primary key"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn cross_column_list_element_granularity_is_refused() {
use lance_index::scalar::inverted::query::{MatchQuery, MultiMatchQuery};
let schema = two_column_fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let mut multi = MultiMatchQuery::try_new(
"lance".to_string(),
vec!["title".to_string(), "body".to_string()],
)
.unwrap();
multi.match_queries[1] = MatchQuery::new("lance".to_string())
.with_column(Some("body".to_string()))
.with_document_granularity(DocumentGranularity::ListElement);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(multi));
let err = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.unwrap_err();
assert!(
err.to_string().contains("row documents only"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn cross_column_implicit_list_element_granularity_is_refused() {
use lance_index::scalar::inverted::InvertedIndexParams;
use lance_index::scalar::inverted::query::MultiMatchQuery;
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_meta);
let list_type = DataType::List(Arc::new(Field::new("item", DataType::Utf8, true)));
let schema = Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("tags", list_type.clone(), true),
Field::new("notes", list_type, true),
]));
let mut tags = ListBuilder::new(StringBuilder::new());
tags.values().append_value("lance");
tags.append(true);
let mut notes = ListBuilder::new(StringBuilder::new());
notes.values().append_value("lance");
notes.append(true);
let active_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1])),
Arc::new(tags.finish()),
Arc::new(notes.finish()),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
for (name, field_id, column) in [
("tags_element_fts", 1, "tags"),
("notes_element_fts", 2, "notes"),
] {
indexes
.add_fts_with_params(
name.to_string(),
field_id,
column.to_string(),
InvertedIndexParams::default()
.document_granularity(DocumentGranularity::ListElement),
)
.unwrap();
}
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::MultiMatch(
MultiMatchQuery::try_new(
"lance".to_string(),
vec!["tags".to_string(), "notes".to_string()],
)
.unwrap(),
));
let err = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.unwrap_err();
assert!(
err.to_string().contains("row documents only"),
"unexpected error: {err}"
);
}
async fn run_cross_column(
rows: &[(i32, &str, &str)],
indexed_columns: &[&str],
query: IndexFtsQuery,
limit: usize,
) -> Vec<(i32, f32)> {
let schema = two_column_fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
for column in indexed_columns {
let field_id = match *column {
"title" => 1,
"body" => 2,
other => panic!("unknown fixture column '{other}'"),
};
indexes.add_fts(format!("{column}_fts"), field_id, column.to_string());
}
let active_batch = make_two_column_batch(&schema, rows);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new_query(query),
Some(limit),
Some(&["id".to_string()]),
)
.await
.expect("a cross-column predicate must plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
batches
.iter()
.flat_map(|batch| {
let ids = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let scores = batch
.column_by_name(SCORE_COLUMN)
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Float32Array>()
.unwrap();
(0..batch.num_rows())
.map(|row| (ids.value(row), scores.value(row)))
.collect::<Vec<_>>()
})
.collect()
}
fn match_leaf(terms: &str, column: &str) -> IndexFtsQuery {
use lance_index::scalar::inverted::query::MatchQuery;
IndexFtsQuery::Match(
MatchQuery::new(terms.to_string()).with_column(Some(column.to_string())),
)
}
#[tokio::test]
async fn cross_column_boolean_must_intersects_the_columns() {
use lance_index::scalar::inverted::query::{BooleanQuery, Occur};
let results = run_cross_column(
&[
(1, "alpha title", "beta body"),
(2, "alpha title", "unrelated body"),
(3, "unrelated title", "beta body"),
(4, "unrelated title", "unrelated body"),
],
&["title", "body"],
IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, match_leaf("alpha", "title")),
(Occur::Must, match_leaf("beta", "body")),
])),
10,
)
.await;
let ids: Vec<i32> = results.iter().map(|(id, _)| *id).collect();
assert_eq!(
ids,
vec![1],
"only the row matching in both columns satisfies a cross-column MUST"
);
}
#[tokio::test]
async fn cross_column_boolean_must_not_excludes_across_columns() {
use lance_index::scalar::inverted::query::{BooleanQuery, Occur};
let results = run_cross_column(
&[
(1, "alpha title", "beta body"),
(2, "alpha title", "unrelated body"),
],
&["title", "body"],
IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, match_leaf("alpha", "title")),
(Occur::MustNot, match_leaf("beta", "body")),
])),
10,
)
.await;
let ids: Vec<i32> = results.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![2], "id=1 carries the excluded term in `body`");
}
#[tokio::test]
async fn cross_column_boost_demotes_rather_than_drops() {
use lance_index::scalar::inverted::query::BoostQuery;
let results = run_cross_column(
&[
(1, "alpha title", "beta body"),
(2, "alpha title", "unrelated body"),
],
&["title", "body"],
IndexFtsQuery::Boost(BoostQuery::new(
match_leaf("alpha", "title"),
match_leaf("beta", "body"),
Some(1.0),
)),
10,
)
.await;
let ids: Vec<i32> = results.iter().map(|(id, _)| *id).collect();
assert_eq!(
ids,
vec![2, 1],
"both rows come back, and the demoted one ranks last"
);
let demoted = results.iter().find(|(id, _)| *id == 1).unwrap().1;
let kept = results.iter().find(|(id, _)| *id == 2).unwrap().1;
assert!(
demoted < kept,
"the negative clause must subtract from the score: {demoted} vs {kept}"
);
}
#[tokio::test]
async fn cross_column_builds_transient_indexes_for_unmaintained_columns() {
use lance_index::scalar::inverted::query::{BooleanQuery, Occur};
let results = run_cross_column(
&[
(1, "alpha title", "beta body"),
(2, "alpha title", "unrelated body"),
],
&["title"],
IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, match_leaf("alpha", "title")),
(Occur::Must, match_leaf("beta", "body")),
])),
10,
)
.await;
let ids: Vec<i32> = results.iter().map(|(id, _)| *id).collect();
assert_eq!(
ids,
vec![1],
"the unmaintained column must still be searched"
);
}
#[tokio::test]
async fn cross_column_unbound_leaf_is_refused() {
use lance_index::scalar::inverted::query::{BooleanQuery, MatchQuery, Occur};
let schema = two_column_fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("title_fts".to_string(), 1, "title".to_string());
indexes.add_fts("body_fts".to_string(), 2, "body".to_string());
let active_batch = make_two_column_batch(&schema, &[(1, "alpha title", "beta body")]);
let (_, row_offset, batch_position) = batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, row_offset, Some(batch_position))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query =
FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(vec![
(Occur::Must, match_leaf("alpha", "title")),
(
Occur::Must,
IndexFtsQuery::Match(MatchQuery::new("beta".to_string())),
),
])));
let err = planner
.plan_search(query, Some(10), Some(&["id".to_string()]))
.await
.unwrap_err();
assert!(
err.to_string().contains("name the columns to search"),
"unexpected error for an unbound leaf: {err}"
);
}
#[tokio::test]
async fn prefilter_on_base_is_not_a_lossy_postfilter() {
use crate::index::DatasetIndexExt;
use datafusion::prelude::{col, lit};
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let schema = fts_schema();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_batch(
&schema,
&[1, 2],
&["lance", "lance filler filler filler filler filler"],
)],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let collector = LsmDataSourceCollector::new(base_ds, vec![]);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("id").gt_eq(lit(2i32))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
None,
)
.await
.expect("planner should produce a filtered base plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert_eq!(
ids,
vec![2],
"prefilter must return the lower-scoring match id=2, not post-filter \
the top-1 (id=1) down to nothing; got {ids:?}"
);
}
#[tokio::test]
async fn prefilter_on_active_is_not_a_lossy_postfilter() {
use datafusion::prelude::{col, lit};
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(id_meta),
Field::new("text", DataType::Utf8, true),
Field::new("status", DataType::Utf8, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec![
"lance",
"lance filler filler filler filler filler",
])),
Arc::new(StringArray::from(vec!["archived", "active"])),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("status").eq(lit("active"))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
None,
)
.await
.expect("planner should produce a filtered active plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert_eq!(
ids,
vec![2],
"active FTS prefilter must return the lower-scoring matching row, \
not post-filter the top-1 down to nothing; got {ids:?}"
);
}
#[tokio::test]
async fn active_limit_applies_after_newest_pk_recency_filter() {
use datafusion::prelude::{col, lit};
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(id_meta),
Field::new("text", DataType::Utf8, true),
Field::new("status", DataType::Utf8, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 1])),
Arc::new(StringArray::from(vec![
"lance",
"lance filler filler filler filler filler",
"other text",
])),
Arc::new(StringArray::from(vec!["active", "active", "active"])),
],
)
.unwrap();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("status").eq(lit("active"))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(1),
None,
)
.await
.expect("planner should produce a filtered active plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert_eq!(
ids,
vec![2],
"active FTS limit must apply after newest-PK filtering; got {ids:?}"
);
}
#[tokio::test]
async fn prefilter_excludes_pk_whose_newest_version_fails() {
use crate::dataset::mem_wal::scanner::collector::{InMemoryMemTableRef, InMemoryMemTables};
use crate::dataset::mem_wal::write::{BatchStore, IndexStore};
use arrow_schema::{DataType, Field};
use datafusion::prelude::{col, lit};
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(id_meta),
Field::new("text", DataType::Utf8, true),
Field::new("status", DataType::Utf8, false),
]));
let make_row = |statuses: &[&str]| -> RecordBatch {
RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![5; statuses.len()])),
Arc::new(StringArray::from(vec!["lance text"; statuses.len()])),
Arc::new(StringArray::from(statuses.to_vec())),
],
)
.unwrap()
};
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let base_batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![999])),
Arc::new(StringArray::from(vec!["unrelated"])),
Arc::new(StringArray::from(vec!["active"])),
],
)
.unwrap();
let base_ds = Arc::new(write_dataset(&base_uri, vec![base_batch]).await);
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_row(&["active", "archived"]);
batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("status").eq(lit("active"))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a filtered active plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total, 0,
"pk=5's current version is 'archived' and must be excluded; the stale \
'active' older hit must not leak (filter evaluated on newest version)"
);
}
#[tokio::test]
async fn prefilter_blocks_base_hit_when_active_newest_fails() {
use crate::dataset::mem_wal::scanner::collector::{InMemoryMemTableRef, InMemoryMemTables};
use crate::dataset::mem_wal::write::{BatchStore, IndexStore};
use crate::index::DatasetIndexExt;
use datafusion::prelude::{col, lit};
use lance_index::IndexType;
use lance_index::scalar::inverted::tokenizer::InvertedIndexParams;
let mut id_meta = HashMap::new();
id_meta.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false).with_metadata(id_meta),
Field::new("text", DataType::Utf8, true),
Field::new("status", DataType::Utf8, false),
]));
let make_rows = |rows: &[(i32, &str, &str)]| -> RecordBatch {
RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(
rows.iter().map(|(id, _, _)| *id).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|(_, text, _)| *text).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter()
.map(|(_, _, status)| *status)
.collect::<Vec<_>>(),
)),
],
)
.unwrap()
};
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let mut base_ds = write_dataset(
&base_uri,
vec![make_rows(&[
(5, "lance base stale", "active"),
(6, "lance base live", "active"),
])],
)
.await;
base_ds
.create_index(
&["text"],
IndexType::Inverted,
Some("text_fts".to_string()),
&InvertedIndexParams::default(),
false,
)
.await
.unwrap();
let base_ds = Arc::new(Dataset::open(&base_uri).await.unwrap());
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let active_batch = make_rows(&[(5, "lance active newest", "archived")]);
batch_store.append(active_batch.clone()).unwrap();
indexes
.insert_with_batch_position(&active_batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let collector = LsmDataSourceCollector::new(base_ds, vec![]).with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema)
.with_filter(Some(col("status").eq(lit("active"))));
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a filtered base+active plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids = Vec::new();
for batch in &batches {
let id_array = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for row in 0..batch.num_rows() {
ids.push(id_array.value(row));
}
}
ids.sort_unstable();
assert_eq!(
ids,
vec![6],
"base pk=5 passes the filter but is superseded by active archived pk=5; got {ids:?}"
);
}
#[tokio::test]
async fn local_mode_active_memtable_only_returns_score_sorted_hits() {
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let batch = make_batch(
&schema,
&[1, 2, 3, 4],
&[
"lance is a columnar data format",
"memwal handles streaming writes",
"lance memwal lance lance",
"completely unrelated",
],
);
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("local mode planner should produce a plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert!(
total >= 2,
"expected at least the 2 'lance' rows, got {total}"
);
let out = batches[0].schema();
assert!(out.field_with_name(SCORE_COLUMN).is_ok());
assert!(out.field_with_name("id").is_ok());
let mut prev_score: Option<f32> = None;
for batch in &batches {
let score = batch
.column_by_name(SCORE_COLUMN)
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Float32Array>()
.unwrap();
for i in 0..batch.num_rows() {
let s = score.value(i);
if let Some(p) = prev_score {
assert!(p >= s, "scores not sorted DESC: {p} then {s}");
}
prev_score = Some(s);
}
}
}
#[tokio::test]
async fn active_match_query_preserves_and_operator() {
use lance_index::scalar::inverted::query::{
FtsQuery as IndexFtsQuery, MatchQuery, Operator,
};
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let batch = make_batch(
&schema,
&[1, 2, 3],
&["lance only", "memwal only", "lance memwal"],
);
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let query = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance memwal".to_string())
.with_operator(Operator::And)
.with_column(Some("text".to_string())),
));
let plan = planner
.plan_search(query, Some(10), None)
.await
.expect("planner should produce an active-only plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
ids.sort_unstable();
assert_eq!(
ids,
vec![3],
"AND query must only return rows containing both terms; got ids={ids:?}"
);
}
#[tokio::test]
async fn local_mode_active_dedups_updated_pk_keeping_newest() {
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let batch_old = make_batch(&schema, &[1, 2], &["lance stale version", "other doc"]);
batch_store.append(batch_old.clone()).unwrap();
indexes
.insert_with_batch_position(&batch_old, 0, Some(0))
.unwrap();
let batch_new = make_batch(&schema, &[1], &["lance fresh version"]);
batch_store.append(batch_new.clone()).unwrap();
indexes
.insert_with_batch_position(&batch_new, 2, Some(1))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce an active-only plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut rows: Vec<(i32, String)> = Vec::new();
for b in &batches {
let ids = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let texts = b
.column_by_name("text")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
for i in 0..b.num_rows() {
rows.push((ids.value(i), texts.value(i).to_string()));
}
}
let id1: Vec<&(i32, String)> = rows.iter().filter(|(id, _)| *id == 1).collect();
assert_eq!(
id1.len(),
1,
"updated PK id=1 must be deduped to one row; got {rows:?}"
);
assert_eq!(
id1[0].1, "lance fresh version",
"dedup must keep the newest (max row-position) version"
);
}
#[tokio::test]
async fn active_stale_update_predicate_crossing_leaks() {
let schema = fts_schema();
let batch_store = Arc::new(BatchStore::with_capacity(16));
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
let b1 = make_batch(&schema, &[1, 2], &["alpha lance", "alpha foo"]);
let (bp1, off1, _) = batch_store.append(b1.clone()).unwrap();
indexes
.insert_with_batch_position(&b1, off1, Some(bp1))
.unwrap();
let b2 = make_batch(&schema, &[1], &["beta lance"]);
let (bp2, off2, _) = batch_store.append(b2.clone()).unwrap();
indexes
.insert_with_batch_position(&b2, off2, Some(bp2))
.unwrap();
let indexes = Arc::new(indexes);
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store,
index_store: indexes,
schema: schema.clone(),
generation: 1,
},
frozen: vec![],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("alpha".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert!(
!ids.contains(&1),
"stale pk=1 (now 'beta lance') leaked on an 'alpha' search; got ids={ids:?}"
);
assert!(
ids.contains(&2),
"live pk=2 ('alpha foo') must still match 'alpha'; got ids={ids:?}"
);
}
#[tokio::test]
async fn cross_gen_stale_update_blocked_by_newer_memtable() {
let schema = fts_schema();
let frozen_store = Arc::new(BatchStore::with_capacity(16));
let mut frozen_idx = IndexStore::new();
frozen_idx.enable_pk_index(&[("id".to_string(), 0)]);
frozen_idx.add_fts("text_fts".to_string(), 1, "text".to_string());
let fb = make_batch(&schema, &[1, 2], &["alpha lance", "alpha foo"]);
let (bp, off, _) = frozen_store.append(fb.clone()).unwrap();
frozen_idx
.insert_with_batch_position(&fb, off, Some(bp))
.unwrap();
let active_store = Arc::new(BatchStore::with_capacity(16));
let mut active_idx = IndexStore::new();
active_idx.enable_pk_index(&[("id".to_string(), 0)]);
active_idx.add_fts("text_fts".to_string(), 1, "text".to_string());
let ab = make_batch(&schema, &[1], &["beta lance"]);
let (bp, off, _) = active_store.append(ab.clone()).unwrap();
active_idx
.insert_with_batch_position(&ab, off, Some(bp))
.unwrap();
let tmp = tempfile::tempdir().unwrap();
let base_uri = format!("{}/base", tmp.path().to_str().unwrap());
let collector = LsmDataSourceCollector::without_base_table(base_uri, vec![])
.with_in_memory_memtables(
uuid::Uuid::new_v4(),
InMemoryMemTables {
active: InMemoryMemTableRef {
batch_store: active_store,
index_store: Arc::new(active_idx),
schema: schema.clone(),
generation: 2,
},
frozen: vec![InMemoryMemTableRef {
batch_store: frozen_store,
index_store: Arc::new(frozen_idx),
schema: schema.clone(),
generation: 1,
}],
},
);
let planner = LsmFtsSearchPlanner::new(collector, vec!["id".to_string()], schema);
let plan = planner
.plan_search(
FullTextSearchQuery::new("alpha".to_string())
.with_column("text".to_string())
.unwrap(),
Some(10),
None,
)
.await
.expect("planner should produce a plan");
let ctx = datafusion::prelude::SessionContext::new();
let stream = plan.execute(0, ctx.task_ctx()).unwrap();
let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
let mut ids: Vec<i32> = Vec::new();
for b in &batches {
let col = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
for i in 0..b.num_rows() {
ids.push(col.value(i));
}
}
assert!(
!ids.contains(&1),
"stale frozen pk=1 ('alpha lance', now 'beta lance' in the active gen) \
leaked on an 'alpha' search; got ids={ids:?}"
);
assert!(
ids.contains(&2),
"live pk=2 ('alpha foo', only in the frozen gen) must still match; got ids={ids:?}"
);
}
}