This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 7baef42f feat(core): support full-text prefilters on non-DE append
tables (#1176)
7baef42f is described below
commit 7baef42fbeb97353036036b1bb71a1f57510e00b
Author: QuakeWang <[email protected]>
AuthorDate: Thu Oct 22 14:51:04 2026 +0800
feat(core): support full-text prefilters on non-DE append tables (#1176)
---
.../table/full_text_search_builder/append_tests.rs | 385 +++++++++++++++++++++
.../src/table/full_text_search_builder/filter.rs | 8 +-
.../table/full_text_search_builder/parity_tests.rs | 3 +
.../src/table/full_text_search_builder/scan.rs | 37 +-
.../paimon/src/table/global_index_search_filter.rs | 2 +-
docs/src/sql.md | 15 +-
6 files changed, 436 insertions(+), 14 deletions(-)
diff --git a/crates/paimon/src/table/full_text_search_builder/append_tests.rs
b/crates/paimon/src/table/full_text_search_builder/append_tests.rs
new file mode 100644
index 00000000..cd810e20
--- /dev/null
+++ b/crates/paimon/src/table/full_text_search_builder/append_tests.rs
@@ -0,0 +1,385 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Non-data-evolution append search uses real Parquet data and global indexes.
+
+use super::*;
+use crate::deletion_vector::DeletionVector;
+use crate::spec::{DeletionVectorMeta, IndexFileMeta};
+use crate::table::CommitMessage;
+use roaring::RoaringBitmap;
+
+fn append_table(partitioned: bool) -> Table {
+ new_table(partitioned).copy_with_options(HashMap::from([
+ ("data-evolution.enabled".into(), "false".into()),
+ ("file.format".into(), "parquet".into()),
+ ("full-text-index.search-mode".into(), "fast".into()),
+ ("scalar-index.search-mode".into(), "fast".into()),
+ ]))
+}
+
+fn keep_filter(table: &Table) -> Predicate {
+ PredicateBuilder::new(table.schema().fields())
+ .equal("label", Datum::String("keep".into()))
+ .unwrap()
+}
+
+#[tokio::test]
+async fn scalar_modes_preserve_missing_and_partial_coverage() {
+ let table = append_table(false);
+ append(&table, 0, 4).await;
+ index(&table, "full-text", "text").await;
+ let filter = keep_filter(&table);
+ for (mode, expected) in [("fast", vec![]), ("full", vec![1]), ("detail",
vec![1])] {
+ let table = table.copy_with_options(HashMap::from([(
+ "scalar-index.search-mode".into(),
+ mode.into(),
+ )]));
+ assert_eq!(
+ ids(query(&table)
+ .with_filter(filter.clone())
+ .execute_scored()
+ .await
+ .unwrap()),
+ expected,
+ "missing scalar coverage, mode={mode}",
+ );
+ }
+
+ index(&table, "btree", "label").await;
+ append(&table, 4, 4).await;
+ index(&table, "full-text", "text").await;
+ append(&table, 8, 4).await;
+ for (mode, expected) in [
+ ("fast", vec![1]),
+ ("full", vec![1, 5, 7]),
+ ("detail", vec![1, 5, 7]),
+ ] {
+ let table = table.copy_with_options(HashMap::from([(
+ "scalar-index.search-mode".into(),
+ mode.into(),
+ )]));
+ assert_eq!(
+ ids(query(&table)
+ .with_filter(filter.clone())
+ .execute_scored()
+ .await
+ .unwrap()),
+ expected,
+ "partial scalar coverage, mode={mode}",
+ );
+ }
+}
+
+#[tokio::test]
+async fn candidate_refinement_keeps_rust_defaults_and_original_scores() {
+ let table = append_table(false);
+ append_labels(
+ &table,
+ 0,
+ &[
+ None,
+ Some("drop"),
+ Some("keep"),
+ Some("drop"),
+ Some("keep"),
+ Some("keeper"),
+ Some("other"),
+ Some("keep"),
+ ],
+ )
+ .await;
+ index(&table, "full-text", "text").await;
+ index(&table, "btree", "label").await;
+ assert!(table
+ .schema()
+ .core_options()
+ .global_index_filter_refine_from_data());
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let filter = Predicate::and(vec![
+ pb.contains("label", Datum::String("eep".into())).unwrap(),
+ pb.greater_or_equal("id", Datum::Int(5)).unwrap(),
+ ]);
+ let all = query(&table)
+ .execute_scored()
+ .await
+ .unwrap()
+ .into_row_ids()
+ .unwrap();
+ let filtered = query(&table)
+ .with_filter(filter.clone())
+ .with_limit(2)
+ .execute_scored()
+ .await
+ .unwrap();
+ assert_eq!(ids(filtered.clone()), vec![5, 7]);
+ let filtered = filtered.into_row_ids().unwrap();
+ for (id, score) in filtered.row_ids.iter().zip(&filtered.scores) {
+ let position = all.row_ids.iter().position(|row| row == id).unwrap();
+ assert_eq!(*score, all.scores[position]);
+ }
+
+ let no_refine = table.copy_with_options(HashMap::from([(
+ "global-index.filter.refine-from-data".into(),
+ "false".into(),
+ )]));
+ assert!(query(&no_refine)
+ .with_filter(filter)
+ .execute_scored()
+ .await
+ .unwrap()
+ .is_empty());
+ assert!(query(&table)
+ .with_filter(pb.equal("label",
Datum::String("missing".into())).unwrap())
+ .execute_scored()
+ .await
+ .unwrap()
+ .is_empty());
+}
+
+#[tokio::test]
+async fn partition_scalar_and_include_filters_precede_top_k() {
+ for kind in ["btree", "bitmap"] {
+ let table = append_table(true);
+ append(&table, 0, 8).await;
+ index(&table, "full-text", "text").await;
+ index(&table, kind, "label").await;
+ append(&table, 8, 4).await;
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let filter = Predicate::and(vec![
+ pb.greater_or_equal("pt", Datum::Int(1)).unwrap(),
+ keep_filter(&table),
+ ]);
+ let mut builder = query(&table);
+ builder.with_filter(filter).with_limit(2);
+ let result = builder.execute_scored().await.unwrap();
+ assert_eq!(user_ids(&table, result.clone()).await, vec![5, 7]);
+ // Partition commit order assigns global IDs independently of user IDs.
+ let last = *result.row_ids().unwrap().row_ids.iter().max().unwrap();
+ builder.with_include_row_ids([last, 9].into_iter().collect());
+ assert_eq!(
+ user_ids(&table, builder.execute_scored().await.unwrap()).await,
+ vec![7],
+ );
+ builder.with_include_row_ids(RoaringTreemap::new());
+ assert!(builder.execute_scored().await.unwrap().is_empty());
+ }
+}
+
+async fn delete_row(table: &Table, row_id: i64) {
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ let (split, file) = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files().iter().map(move |file| (split,
file)))
+ .find(|(_, file)| {
+ let first = file.first_row_id.unwrap();
+ first <= row_id && row_id < first + file.row_count
+ })
+ .unwrap();
+ let position = u32::try_from(row_id - file.first_row_id.unwrap()).unwrap();
+ let dv = DeletionVector::from_bitmap(RoaringBitmap::from_iter([position]));
+ let serialized = dv.serialize_to_bytes().unwrap();
+ let length = i32::from_be_bytes(serialized[..4].try_into().unwrap());
+ let mut bytes = vec![1];
+ bytes.extend_from_slice(&serialized);
+ let file_size = bytes.len() as i64;
+ let file_name = format!("index-{}-1", uuid::Uuid::new_v4());
+ table
+ .file_io()
+ .new_output(&format!("{}/index/{file_name}", table.location()))
+ .unwrap()
+ .write(bytes::Bytes::from(bytes))
+ .await
+ .unwrap();
+ let mut message = CommitMessage::new(
+ split.partition().to_serialized_bytes(),
+ split.bucket(),
+ vec![],
+ );
+ message.new_index_files.push(IndexFileMeta {
+ index_type: "DELETION_VECTORS".into(),
+ file_name,
+ file_size,
+ row_count: 1,
+ deletion_vectors_ranges: Some(indexmap::IndexMap::from([(
+ file.file_name.clone(),
+ DeletionVectorMeta {
+ offset: 1,
+ length,
+ cardinality: Some(1),
+ },
+ )])),
+ external_path: None,
+ global_index_meta: None,
+ });
+ TableCommit::new(table.clone(), "delete".into())
+ .commit(vec![message])
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn
deletion_vectors_filter_exact_index_hits_before_top_k_and_pin_snapshot() {
+ let table = append_table(false);
+ append(&table, 0, 4).await;
+ append(&table, 4, 4).await;
+ index(&table, "full-text", "text").await;
+ index(&table, "btree", "label").await;
+ let table = table.copy_with_options(HashMap::from([(
+ "deletion-vectors.enabled".into(),
+ "true".into(),
+ )]));
+ let mut builder = query(&table);
+ builder.with_filter(keep_filter(&table)).with_limit(2);
+ let read = builder.new_read().unwrap();
+ let plan = builder.new_scan().unwrap().scan().await.unwrap();
+ delete_row(&table, 5).await;
+
+ assert_eq!(ids(read.read(plan).await.unwrap()), vec![1, 5]);
+ let result = builder.execute_scored().await.unwrap();
+ assert_eq!(ids(result.clone()), vec![1, 7]);
+ let batches: Vec<RecordBatch> = result
+ .new_read_builder()
+ .with_projection(&["id"])
+ .read()
+ .await
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let values = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .iter()
+ .copied()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(values, vec![1, 7]);
+}
+
+#[tokio::test]
+async fn removed_files_do_not_supply_exact_index_hits() {
+ let table = append_table(false);
+ append(&table, 0, 4).await;
+ append(&table, 4, 4).await;
+ index(&table, "full-text", "text").await;
+ index(&table, "btree", "label").await;
+ let mut builder = query(&table);
+ builder.with_filter(keep_filter(&table)).with_limit(1);
+ let read = builder.new_read().unwrap();
+ let plan = builder.new_scan().unwrap().scan().await.unwrap();
+ let data = table.new_read_builder().new_scan().plan().await.unwrap();
+ let split = data
+ .splits()
+ .iter()
+ .find(|split| {
+ split
+ .data_files()
+ .iter()
+ .any(|file| file.first_row_id == Some(0))
+ })
+ .unwrap();
+ let mut message = CommitMessage::new(
+ split.partition().to_serialized_bytes(),
+ split.bucket(),
+ vec![],
+ );
+ message.deleted_files = split
+ .data_files()
+ .iter()
+ .filter(|file| file.first_row_id == Some(0))
+ .cloned()
+ .collect();
+ TableCommit::new(table.clone(), "remove".into())
+ .commit(vec![message])
+ .await
+ .unwrap();
+ assert_eq!(ids(read.read(plan).await.unwrap()), vec![1]);
+ assert_eq!(ids(builder.execute_scored().await.unwrap()), vec![5]);
+}
+
+#[tokio::test]
+async fn empty_filtered_plans_stay_empty_after_index_build() {
+ let table = append_table(false);
+ let mut builder = query(&table);
+ builder.with_filter(keep_filter(&table));
+ let read = builder.new_read().unwrap();
+ let plan = builder.new_scan().unwrap().scan().await.unwrap();
+ assert_eq!(plan.snapshot_id(), None);
+ append(&table, 0, 4).await;
+ index(&table, "full-text", "text").await;
+ index(&table, "btree", "label").await;
+ assert!(read.read(plan).await.unwrap().is_empty());
+ assert_eq!(ids(builder.execute_scored().await.unwrap()), vec![1]);
+}
+
+#[tokio::test]
+async fn unsupported_append_filter_combinations_fail_before_empty_snapshot() {
+ let table = append_table(false);
+ for (option, value, expected) in [
+ (
+ "row-tracking.enabled",
+ "false",
+ "row-tracked Parquet append",
+ ),
+ ("file.format", "orc", "row-tracked Parquet append"),
+ ("file.format", "avro", "row-tracked Parquet append"),
+ ("full-text-index.search-mode", "full", "FAST"),
+ ("full-text-index.search-mode", "detail", "FAST"),
+ ("query-auth.enabled", "true", "query"),
+ ] {
+ let table = table.copy_with_options(HashMap::from([(option.into(),
value.into())]));
+ let error = query(&table)
+ .with_filter(keep_filter(&table))
+ .execute_scored()
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains(expected),
+ "{option}={value}: {error}"
+ );
+ }
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("text", DataType::VarChar(VarCharType::string_type()))
+ .column("label", DataType::VarChar(VarCharType::string_type()))
+ .primary_key(["id"])
+ .build()
+ .unwrap();
+ let pk = Table::new(
+ table.file_io().clone(),
+ table.identifier().clone(),
+ table.location().into(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ assert!(query(&pk)
+ .with_filter(keep_filter(&pk))
+ .execute_scored()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("row-tracked Parquet append"));
+}
diff --git a/crates/paimon/src/table/full_text_search_builder/filter.rs
b/crates/paimon/src/table/full_text_search_builder/filter.rs
index eef7a1a9..bfca94b4 100644
--- a/crates/paimon/src/table/full_text_search_builder/filter.rs
+++ b/crates/paimon/src/table/full_text_search_builder/filter.rs
@@ -195,7 +195,13 @@ pub(super) async fn matching_indexed_rows(
Some((answer, exact)) => {
let candidates = row_ids(&answer)? & &decided;
if exact {
- matched |= candidates;
+ matched |= if core.data_evolution_enabled() {
+ candidates
+ } else {
+ // An exact index match does not establish snapshot
visibility.
+ matching_rows(evaluation, &Predicate::AlwaysTrue,
ranges(&candidates)?)
+ .await?
+ };
} else if core.global_index_filter_refine_from_data() {
matched |= matching_rows(evaluation, filter,
ranges(&candidates)?).await?;
}
diff --git a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
index 03fc09a1..90b365d6 100644
--- a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
+++ b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
@@ -25,6 +25,9 @@ use arrow_array::{ArrayRef, Int32Array};
use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
use std::sync::Arc;
+#[path = "append_tests.rs"]
+mod append_tests;
+
fn new_schema(partitioned: bool) -> Schema {
let mut schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
diff --git a/crates/paimon/src/table/full_text_search_builder/scan.rs
b/crates/paimon/src/table/full_text_search_builder/scan.rs
index d26a1a5a..1971bc33 100644
--- a/crates/paimon/src/table/full_text_search_builder/scan.rs
+++ b/crates/paimon/src/table/full_text_search_builder/scan.rs
@@ -32,7 +32,8 @@ pub(super) struct PlanContext {
impl PlanContext {
pub(super) fn new(builder: &FullTextSearchBuilder<'_>) ->
crate::Result<Self> {
-
CoreOptions::new(builder.table.schema().options()).ensure_read_authorized()?;
+ let core = builder.table.schema().core_options();
+ core.ensure_read_authorized()?;
let column = builder
.text_column
.as_deref()
@@ -56,9 +57,31 @@ impl PlanContext {
message: format!("Full-text column '{column}' must be a
character string"),
});
}
+ let has_row_filter = builder.filter.as_ref().is_some_and(|filter| {
+ let (_, data) = split_partition_and_data_predicates(
+ filter.clone(),
+ builder.table.schema().fields(),
+ builder.table.schema().partition_keys(),
+ );
+ !data.is_empty()
+ });
+ if has_row_filter && !core.data_evolution_enabled() {
+ if !builder.table.schema().primary_keys().is_empty()
+ || !core.row_tracking_enabled()
+ || core.file_format() != "parquet"
+ {
+ return Err(crate::Error::Unsupported {
+ message: "Non-data-evolution full-text row filters require
a row-tracked Parquet append table".into(),
+ });
+ }
+ if core.full_text_index_search_mode()? !=
GlobalIndexSearchMode::Fast {
+ return Err(crate::Error::Unsupported {
+ message: "Non-data-evolution full-text row filters support
only the FAST full-text index search mode".into(),
+ });
+ }
+ }
let primary_key_field_id =
-
resolves_to_pk_full_text_path(&builder.table.schema().core_options(), column)
- .then_some(field.id());
+ resolves_to_pk_full_text_path(&core, column).then_some(field.id());
if primary_key_field_id.is_some() && builder.include_row_ids.is_some()
{
return Err(crate::Error::Unsupported {
message:
@@ -174,13 +197,7 @@ impl FullTextScan {
pub async fn scan(&self) -> crate::Result<FullTextScanPlan> {
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
- let (partition_filter, row_filter) =
- bind_filters(&self.table, self.context.filter.as_ref())?;
- if row_filter.is_some() &&
!self.table.schema().core_options().data_evolution_enabled() {
- return Err(crate::Error::Unsupported {
- message: "Full-text row filters require a data-evolution
table".into(),
- });
- }
+ let (partition_filter, _) = bind_filters(&self.table,
self.context.filter.as_ref())?;
let Some(snapshot) =
crate::table::time_travel::resolve_snapshot(&self.table).await? else {
return FullTextScanPlan::new(None, vec![]);
};
diff --git a/crates/paimon/src/table/global_index_search_filter.rs
b/crates/paimon/src/table/global_index_search_filter.rs
index 423bd867..28ac53ef 100644
--- a/crates/paimon/src/table/global_index_search_filter.rs
+++ b/crates/paimon/src/table/global_index_search_filter.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-//! Exact row-ID filtering shared by data-evolution index searches.
+//! Exact row-ID filtering shared by global index searches.
use crate::spec::{Predicate, ROW_ID_FIELD_NAME};
use crate::table::{RowRange, Table};
diff --git a/docs/src/sql.md b/docs/src/sql.md
index b00cdfaf..0e2d1e2a 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2238,9 +2238,20 @@ unfiltered corpus for BM25 scores; Boolean and boosted
queries collect leaf
candidates before applying the final limit. Index shard searches use
`global-index.thread-num` workers.
+Non-Data-Evolution tables also support row filters when they are append tables
+without primary keys, use Parquet, enable row tracking, and use full-text
`fast`
+mode. Scalar `fast`, `full`, and `detail` retain their coverage rules, with
data
+reads restricted to the full-text index coverage. Candidate refinement keeps
+the Rust default of `global-index.filter.refine-from-data=true`. Matching rows
+are checked against the pinned snapshot's live data and deletion vectors before
+full-text Top-K; filtering preserves the original index corpus and BM25 scores.
+Full-text `full`/`detail` row filtering on these tables remains unsupported.
+
The existing `with_text_column()` / `with_query_text()` API continues to
-accept plain text for the SQL table function. Full-text row filters require
-Data Evolution; primary-key materialized reads use `execute_read()`.
+accept plain text for the SQL table function. These pre-filters are configured
+through the Rust core builder's `with_filter()`; the SQL table function does
not
+push an outer `WHERE` into full-text search. Primary-key materialized reads use
+`execute_read()`.
## Time Travel