This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new af4b262962 [Python] Delegate local full-text search to Rust core
(#10494)
af4b262962 is described below
commit af4b26296257cc89e64fb6883217e2a522113436
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 23:11:05 2026 +0800
[Python] Delegate local full-text search to Rust core (#10494)
---
paimon-python/README.md | 35 ++
paimon-python/pypaimon/common/like_optimization.py | 37 ++
.../globalindex/btree/btree_index_reader.py | 61 +--
.../globalindex/sorted_file_global_index_reader.py | 21 +-
.../globalindex/sorted_file_meta_selector.py | 11 +-
.../pypaimon/table/source/full_text_read.py | 76 +++-
.../pypaimon/table/source/full_text_scan.py | 31 +-
.../table/source/full_text_search_builder.py | 9 +
.../table/source/full_text_search_split.py | 3 +-
.../table/source/global_index_row_filter.py | 25 ++
.../table/source/native_full_text_search.py | 61 +++
.../pypaimon/table/source/vector_search_read.py | 4 +-
.../pypaimon/tests/btree_index_reader_test.py | 69 +++
.../pypaimon/tests/full_text_scalar_filter_test.py | 53 ++-
.../pypaimon/tests/native_full_text_search_test.py | 466 +++++++++++++++++++++
.../pypaimon/tests/vector_filter_exactness_test.py | 45 +-
16 files changed, 909 insertions(+), 98 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index 3702f659ee..b0f5a2eb3e 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -789,6 +789,41 @@ falls back to the Python search reader. Distributed Ray
searches keep their
existing scan/read path.
+# Native local full-text search
+
+For REST Data Evolution tables with row tracking and Parquet files,
+`read.native.enabled=true` delegates local full-text search to Rust core.
+The Python builder and scored result API stay the same:
+
+```python
+import json
+
+native_table = table.copy({'read.native.enabled': 'true'})
+predicate =
native_table.new_read_builder().new_predicate_builder().equal('category',
'news')
+result = (native_table.new_full_text_search_builder()
+ .with_query('content', json.dumps({'match': {'query': 'paimon'}}))
+ .with_filter(predicate)
+ .with_limit(10)
+ .execute_local())
+```
+
+Rust selects one snapshot, prunes partitions, evaluates scalar filters before
+ranking, and searches indexed and uncovered rows. Full-text and scalar
+`fast`, `full`, and `detail` search modes follow Java coverage semantics.
+Scalar candidate refinement obeys `global-index.filter.refine-from-data`.
+Search predicates `contains`, `endsWith`, and residual `LIKE` require
+refinement for both BTree and bitmap indexes, as in Java. Simple `LIKE`
+patterns are first rewritten to equality, prefix, suffix or substring
+predicates; equality and prefix retain the underlying index's exactness.
+Filtering keeps the complete shard or raw corpus for BM25 scoring.
+
+A full-text index definition must exist even in `full` or `detail` mode.
+Tables outside the supported route, or Rust versions without the full-text
+builder, use the Python implementation. Once native execution starts,
+errors propagate without retrying the search through Python. PK and distributed
+searches retain their existing Python scan/read path.
+
+
# Vector index range reads
Native vector indexes (`ivf-flat`, `ivf-pq`, `ivf-sq`, `ivf-rq`, and `diskann`)
diff --git a/paimon-python/pypaimon/common/like_optimization.py
b/paimon-python/pypaimon/common/like_optimization.py
new file mode 100644
index 0000000000..d90fb1641c
--- /dev/null
+++ b/paimon-python/pypaimon/common/like_optimization.py
@@ -0,0 +1,37 @@
+# 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.
+
+"""Simple LIKE rewrites shared by scalar readers and search filtering."""
+
+
+def try_optimize_like(literal):
+ """Follow Java LikeOptimization, leaving escaped/underscore patterns
intact."""
+ if literal is None:
+ return None
+ pattern = str(literal)
+ if "_" in pattern or "\\" in pattern:
+ return None
+ if "%" not in pattern and pattern:
+ return "equal", pattern
+ if (pattern.startswith("%") and pattern.endswith("%")
+ and pattern.count("%") == 2 and pattern[1:-1]):
+ return "contains", pattern[1:-1]
+ if pattern.startswith("%") and pattern.count("%") == 1 and pattern[1:]:
+ return "ends_with", pattern[1:]
+ if pattern.endswith("%") and pattern.count("%") == 1 and pattern[:-1]:
+ return "starts_with", pattern[:-1]
+ return None
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
index f45e8e9e9d..8e9f21e6e3 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
@@ -36,6 +36,7 @@ from pypaimon.utils.roaring_bitmap import RoaringBitmap64
from pypaimon.globalindex.btree.btree_file_footer import BTreeFileFooter
from pypaimon.globalindex.btree.sst_file_reader import SstFileReader
from pypaimon.globalindex.memory_slice_input import MemorySliceInput
+from pypaimon.globalindex.sorted_file_meta_selector import
SortedFileMetaSelector
def _deserialize_row_ids(data: bytes) -> List[int]:
@@ -163,43 +164,39 @@ class BTreeIndexReader:
return RoaringBitmap64()
return self._range_query(self.min_key, self.max_key, True, True)
- def _range_query(
- self,
- from_key: object,
- to_key: object,
- from_inclusive: bool,
- to_inclusive: bool
- ) -> RoaringBitmap64:
- result = RoaringBitmap64()
-
+ def _entries_from(self, serialized_key: bytes):
file_iter = self.reader.create_iterator()
- file_iter.seek_to(self.key_serializer.serialize(from_key))
-
+ file_iter.seek_to(serialized_key)
while True:
data_iter = file_iter.read_batch()
if data_iter is None:
- break
-
+ return
while data_iter.has_next():
entry = data_iter.__next__()
if entry is None:
break
+ yield entry
- key_bytes = entry.key
- value_bytes = entry.value
-
- key = self.key_serializer.deserialize(key_bytes)
+ def _range_query(
+ self,
+ from_key: object,
+ to_key: object,
+ from_inclusive: bool,
+ to_inclusive: bool
+ ) -> RoaringBitmap64:
+ result = RoaringBitmap64()
+ for entry in
self._entries_from(self.key_serializer.serialize(from_key)):
+ key = self.key_serializer.deserialize(entry.key)
- if not from_inclusive and self.comparator(key, from_key) == 0:
- continue
+ if not from_inclusive and self.comparator(key, from_key) == 0:
+ continue
- difference = self.comparator(key, to_key)
- if difference > 0 or (not to_inclusive and difference == 0):
- return result
+ difference = self.comparator(key, to_key)
+ if difference > 0 or (not to_inclusive and difference == 0):
+ return result
- row_ids = _deserialize_row_ids(value_bytes)
- for row_id in row_ids:
- result.add(row_id)
+ for row_id in _deserialize_row_ids(entry.value):
+ result.add(row_id)
return result
@@ -248,7 +245,19 @@ class BTreeIndexReader:
return GlobalIndexResult.create(result)
def visit_starts_with(self, literal: object) ->
Optional[GlobalIndexResult]:
- return GlobalIndexResult.create(self._all_non_null_rows(),
is_exact=False)
+ result = RoaringBitmap64()
+ if self.min_key is None:
+ return GlobalIndexResult.create(result)
+ prefix = self.key_serializer.serialize(literal)
+ upper_bound = SortedFileMetaSelector._prefix_upper_bound(prefix)
+ # STRING keys follow Java's unsigned UTF-8 order. The exclusive byte
+ # boundary need not be a valid Unicode string, so do not deserialize
it.
+ for entry in self._entries_from(prefix):
+ if upper_bound is not None and entry.key >= upper_bound:
+ break
+ for row_id in _deserialize_row_ids(entry.value):
+ result.add(row_id)
+ return GlobalIndexResult.create(result)
def visit_ends_with(self, literal: object) -> Optional[GlobalIndexResult]:
return GlobalIndexResult.create(self._all_non_null_rows(),
is_exact=False)
diff --git
a/paimon-python/pypaimon/globalindex/sorted_file_global_index_reader.py
b/paimon-python/pypaimon/globalindex/sorted_file_global_index_reader.py
index 273a701c74..77a9594cb9 100644
--- a/paimon-python/pypaimon/globalindex/sorted_file_global_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/sorted_file_global_index_reader.py
@@ -21,6 +21,7 @@ import threading
from concurrent.futures import Executor, Future
from typing import Callable, Dict, List, Optional
+from pypaimon.common.like_optimization import try_optimize_like
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.global_index_reader import FieldRef,
GlobalIndexReader, _completed_future
from pypaimon.globalindex.global_index_result import GlobalIndexResult
@@ -225,7 +226,7 @@ class SortedFileGlobalIndexReader(GlobalIndexReader):
if not self._is_string_type(field_ref) or literal is None:
return _completed_future(None)
- optimized = self._try_optimize_like(literal)
+ optimized = try_optimize_like(literal)
if optimized is not None:
method, optimized_literal = optimized
if method == "equal":
@@ -282,24 +283,6 @@ class SortedFileGlobalIndexReader(GlobalIndexReader):
or data_type == "VARCHAR"
or data_type.startswith("VARCHAR("))
- @staticmethod
- def _try_optimize_like(literal):
- pattern = str(literal)
- if "_" in pattern:
- return None
- if "%" not in pattern and pattern:
- return "equal", pattern
- if (pattern.startswith("%")
- and pattern.endswith("%")
- and pattern.count("%") == 2
- and pattern[1:-1]):
- return "contains", pattern[1:-1]
- if pattern.startswith("%") and pattern.count("%") == 1 and pattern[1:]:
- return "ends_with", pattern[1:]
- if pattern.endswith("%") and pattern.count("%") == 1 and pattern[:-1]:
- return "starts_with", pattern[:-1]
- return None
-
def close(self) -> None:
with self._cache_lock:
for reader in self._reader_cache.values():
diff --git a/paimon-python/pypaimon/globalindex/sorted_file_meta_selector.py
b/paimon-python/pypaimon/globalindex/sorted_file_meta_selector.py
index f486d5900f..4fdc776bbd 100644
--- a/paimon-python/pypaimon/globalindex/sorted_file_meta_selector.py
+++ b/paimon-python/pypaimon/globalindex/sorted_file_meta_selector.py
@@ -53,17 +53,14 @@ class SortedFileMetaSelector:
if len(prefix) == 0:
return self._filter(lambda meta: not meta.only_nulls())
- prefix_key = self._key_serializer.deserialize(prefix)
upper_bound_bytes = self._prefix_upper_bound(prefix)
- upper_bound = (None if upper_bound_bytes is None
- else
self._key_serializer.deserialize(upper_bound_bytes))
-
+ # Prefix selection applies to STRING keys, whose serialized order is
+ # Java's unsigned UTF-8 order. A byte upper bound may not decode to
text.
return self._filter(
lambda meta: (
not meta.only_nulls()
- and self._compare_last_key(meta, prefix_key) >= 0
- and (upper_bound is None
- or self._compare_first_key(meta, upper_bound) < 0)))
+ and meta.last_key >= prefix
+ and (upper_bound_bytes is None or meta.first_key <
upper_bound_bytes)))
def select_ends_with(self, literal) -> List[GlobalIndexIOMeta]:
return self._filter(lambda meta: literal is not None and not
meta.only_nulls())
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py
b/paimon-python/pypaimon/table/source/full_text_read.py
index c4236b6646..022e448c71 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -79,6 +79,7 @@ class DataEvolutionFullTextRead(FullTextRead):
def read_plan(self, plan: FullTextScanPlan) -> GlobalIndexResult:
reader = copy(self)
reader._table =
global_index_live_row_filter.table_at_snapshot(self._table, plan.snapshot())
+ reader._snapshot = plan.snapshot()
return reader.read(plan.splits())
def read(self, splits: List[FullTextSearchSplit]) -> GlobalIndexResult:
@@ -91,27 +92,72 @@ class DataEvolutionFullTextRead(FullTextRead):
splits_by_column.setdefault(split.column_name, []).append(split)
live_rows = global_index_live_row_filter.live_rows(
self._table, self._partition_filter)
- if self._filter is not None:
- from pypaimon.table.source.global_index_row_filter import
matching_rows
-
- candidates = RoaringBitmap64()
- for split in index_splits:
- candidates.add_range(split.row_range_start,
split.row_range_end)
- for row_range in _raw_row_ranges(raw_splits):
- candidates.add_range(row_range.from_, row_range.to)
- # Scalar indexes may prune this read, but incomplete scalar
coverage
- # must not exclude rows covered by the full-text search plan.
- table = self._table.copy({"scalar-index.search-mode": "full"})
- matched = matching_rows(table, self._filter, candidates,
self._partition_filter)
- live_rows = matched if live_rows is None else
RoaringBitmap64.and_(live_rows, matched)
- indexed_result = self._eval_column_query(splits_by_column, live_rows)
+ matched = self._matched_indexed_rows(index_splits, live_rows)
+ indexed_result = self._eval_column_query(splits_by_column, matched if
self._filter is not None else live_rows)
raw_ranges = _raw_row_ranges(raw_splits)
if self._filter is None:
raw_result = self._read_raw_search(raw_ranges,
_index_type(index_splits))
else:
- raw_result = self._read_raw_search(raw_ranges,
_index_type(index_splits), live_rows)
+ candidates = GlobalIndexResult.from_ranges(raw_ranges).results()
+ if live_rows is not None:
+ candidates = RoaringBitmap64.and_(candidates, live_rows)
+ matched = self._matching_rows(candidates)
+ raw_result = self._read_raw_search(raw_ranges,
_index_type(index_splits), matched)
return indexed_result.or_(raw_result).top_k(self._limit)
+ def _matching_rows(self, candidates):
+ from pypaimon.table.source.global_index_row_filter import matching_rows
+
+ table = self._table.copy({'scalar-index.search-mode': 'full'})
+ return matching_rows(table, self._filter, candidates,
self._partition_filter)
+
+ def _matched_indexed_rows(self, splits, live_rows):
+ if self._filter is None:
+ return live_rows
+ from pypaimon.common.options.core_options import GlobalIndexSearchMode
+ from pypaimon.globalindex.data_evolution_global_index_coverage import
DataEvolutionGlobalIndexCoverage
+ from pypaimon.globalindex.data_evolution_global_index_scanner import
DataEvolutionGlobalIndexScanner
+ from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+ from pypaimon.table.source.global_index_row_filter import is_exact
+
+ covered = RoaringBitmap64()
+ files = {}
+ for split in splits:
+ covered.add_range(split.row_range_start, split.row_range_end)
+ files.update((file.file_name, file) for file in
split.scalar_index_files)
+ if live_rows is not None:
+ covered = RoaringBitmap64.and_(covered, live_rows)
+ if covered.is_empty():
+ return covered
+ snapshot = getattr(self, '_snapshot', None)
+ if snapshot is None:
+ snapshot = TimeTravelUtil.resolve_snapshot(self._table)
+ mode = self._table.options.scalar_index_search_mode()
+ scalar_files = list(files.values())
+ coverage = DataEvolutionGlobalIndexCoverage(self._table, snapshot,
self._partition_filter, scalar_files)
+ raw_ranges = coverage.unindexed_ranges(self._table.fields,
self._filter, mode)
+ unindexed =
RoaringBitmap64.and_(GlobalIndexResult.from_ranges(raw_ranges).results(),
covered)
+ decided = RoaringBitmap64.remove_all(covered, unindexed)
+ matched = RoaringBitmap64()
+ if not decided.is_empty():
+ scanner = DataEvolutionGlobalIndexScanner.create(self._table,
index_files=scalar_files,
+
snapshot=snapshot, partition_filter=self._partition_filter)
+ evaluation = None
+ if scanner is not None:
+ try:
+ evaluation = scanner.scan_with_coverage(self._filter)
+ finally:
+ scanner.close()
+ if evaluation is not None:
+ candidates = RoaringBitmap64.and_(evaluation.result.results(),
decided)
+ if is_exact(self._filter, evaluation.result):
+ matched = candidates
+ elif
self._table.options.global_index_filter_refine_from_data():
+ matched = self._matching_rows(candidates)
+ elif mode != GlobalIndexSearchMode.FAST:
+ unindexed = RoaringBitmap64.or_(unindexed, decided)
+ return RoaringBitmap64.or_(matched, self._matching_rows(unindexed))
+
def _eval_column_query(
self,
splits_by_column: Dict[str, List[IndexFullTextSearchSplit]],
diff --git a/paimon-python/pypaimon/table/source/full_text_scan.py
b/paimon-python/pypaimon/table/source/full_text_scan.py
index ae5d089d0b..57b7d3ea30 100644
--- a/paimon-python/pypaimon/table/source/full_text_scan.py
+++ b/paimon-python/pypaimon/table/source/full_text_scan.py
@@ -62,10 +62,12 @@ class DataEvolutionFullTextScan(FullTextScan):
self,
table: 'FileStoreTable',
text_columns,
- partition_filter=None):
+ partition_filter=None,
+ filter_=None):
self._table = table
self._text_columns = list(text_columns)
self._partition_filter = partition_filter
+ self._filter = filter_
def scan(self) -> FullTextScanPlan:
from pypaimon.index.index_file_handler import IndexFileHandler
@@ -96,8 +98,26 @@ class DataEvolutionFullTextScan(FullTextScan):
and _supports_full_text_search(entry.index_file.index_type)
)
- entries = index_file_handler.scan(snapshot, index_file_filter)
- all_index_files = [entry.index_file for entry in entries]
+ from pypaimon.read.push_down_utils import _get_all_fields
+ filter_names = _get_all_fields(self._filter) if self._filter is not
None else set()
+ filter_ids = {field.id for field in self._table.fields if field.name
in filter_names}
+
+ def selected(entry):
+ if index_file_filter(entry):
+ return True
+ if partition_filter is not None and not
partition_filter.test(entry.partition):
+ return False
+ file = entry.index_file
+ meta = file.global_index_meta
+ return (meta is not None and file.index_type in ('btree', 'bitmap')
+ and bool(filter_ids.intersection([meta.index_field_id] +
list(meta.extra_field_ids or []))))
+
+ entries = index_file_handler.scan(snapshot, selected)
+ all_index_files = [entry.index_file for entry in entries if
index_file_filter(entry)]
+ scalar_files = [entry.index_file for entry in entries if
entry.index_file.index_type in ('btree', 'bitmap')]
+ # Java requires an existing full-text definition before admitting raw
ranges.
+ if not all_index_files:
+ return FullTextScanPlan([], snapshot)
# Group full-text index files by column and (rowRangeStart,
rowRangeEnd).
by_column_and_range = defaultdict(lambda: defaultdict(list))
@@ -113,7 +133,10 @@ class DataEvolutionFullTextScan(FullTextScan):
for range_key, files in by_range.items():
splits.append(
IndexFullTextSearchSplit(
- column_name, range_key.from_, range_key.to, files))
+ column_name, range_key.from_, range_key.to, files,
+ [file for file in scalar_files
+ if file.global_index_meta.row_range_start <=
range_key.to
+ and file.global_index_meta.row_range_end >=
range_key.from_]))
raw_row_ranges = DataEvolutionGlobalIndexCoverage(
self._table,
diff --git a/paimon-python/pypaimon/table/source/full_text_search_builder.py
b/paimon-python/pypaimon/table/source/full_text_search_builder.py
index 72b34a92dd..3d2d4dd802 100644
--- a/paimon-python/pypaimon/table/source/full_text_search_builder.py
+++ b/paimon-python/pypaimon/table/source/full_text_search_builder.py
@@ -84,6 +84,14 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
self._limit = limit
return self
+ def execute_local(self) -> GlobalIndexResult:
+ from pypaimon.table.source.native_full_text_search import
try_native_full_text_search
+
+ result = try_native_full_text_search(self)
+ if result is not None:
+ return result
+ return super().execute_local()
+
def with_query(self, field_name: str, query: str) ->
'FullTextSearchBuilder':
self._field_name = field_name
self._query = query
@@ -149,6 +157,7 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
self._table,
self._text_columns(),
partition_filter=self._partition_filter,
+ filter_=self._filter,
)
def new_full_text_read(self) -> FullTextRead:
diff --git a/paimon-python/pypaimon/table/source/full_text_search_split.py
b/paimon-python/pypaimon/table/source/full_text_search_split.py
index bb2cdeda9e..e06d485a85 100644
--- a/paimon-python/pypaimon/table/source/full_text_search_split.py
+++ b/paimon-python/pypaimon/table/source/full_text_search_split.py
@@ -17,7 +17,7 @@
"""Splits of full-text search."""
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import List
from pypaimon.index.index_file_meta import IndexFileMeta
@@ -36,6 +36,7 @@ class IndexFullTextSearchSplit(FullTextSearchSplit):
row_range_start: int
row_range_end: int
full_text_index_files: List[IndexFileMeta]
+ scalar_index_files: List[IndexFileMeta] = field(default_factory=list)
@dataclass
diff --git a/paimon-python/pypaimon/table/source/global_index_row_filter.py
b/paimon-python/pypaimon/table/source/global_index_row_filter.py
index 0dc087f56a..2e9820c3e4 100644
--- a/paimon-python/pypaimon/table/source/global_index_row_filter.py
+++ b/paimon-python/pypaimon/table/source/global_index_row_filter.py
@@ -17,12 +17,37 @@
"""Exact row filtering for data-evolution search candidates."""
+from pypaimon.common.like_optimization import try_optimize_like
from pypaimon.read.table_read import _ClosableArrowBatchReader
from pypaimon.table.special_fields import SpecialFields
from pypaimon.table.source.global_index_live_row_filter import
table_at_snapshot
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+def is_exact(predicate, result):
+ """Apply Java FilteredRowIdReader's predicate contract before search Top-K.
+
+ Reader exactness alone is insufficient: contains, endsWith and residual
+ LIKE must be refined even when a bitmap dictionary yields exact rows.
+ Simple LIKE rewrites to equality/prefix retain their usual exactness.
+ """
+ if result is None or not result.is_exact():
+ return False
+ pending = [predicate] if predicate is not None else []
+ while pending:
+ leaf = pending.pop()
+ if leaf.method in ('and', 'or'):
+ pending.extend(leaf.literals)
+ continue
+ method = leaf.method
+ if method == 'like':
+ optimized = try_optimize_like(leaf.literals[0])
+ method = optimized[0] if optimized is not None else 'like'
+ if method in ('contains', 'endsWith', 'ends_with', 'like'):
+ return False
+ return True
+
+
def matching_rows(table, predicate, candidates, partition_filter=None,
snapshot=None):
"""Read only filter dependencies and row IDs, closing both reader and
source."""
matched = RoaringBitmap64()
diff --git a/paimon-python/pypaimon/table/source/native_full_text_search.py
b/paimon-python/pypaimon/table/source/native_full_text_search.py
new file mode 100644
index 0000000000..97a1484352
--- /dev/null
+++ b/paimon-python/pypaimon/table/source/native_full_text_search.py
@@ -0,0 +1,61 @@
+# 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.
+
+"""Local REST full-text search delegated to Rust core."""
+
+from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
+from pypaimon.globalindex.full_text.native_full_text_global_index_reader
import NativeFullTextIndexOptions
+from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
+from pypaimon.read.native_plan import _native_table, _predicate_to_native,
native_method_available
+from pypaimon.table.file_store_table import FileStoreTable
+from pypaimon.write.native_commit import _rest_catalog_supported
+
+
+def try_native_full_text_search(builder):
+ """Qualify the route before execution; Rust owns scan, filtering and
ranking."""
+ table = builder._table
+ reject_search_under_query_auth(table)
+ if (type(table) is not FileStoreTable
+ or not table.options.native_read_enabled()
+ or not table.options.data_evolution_enabled()
+ or not table.options.row_tracking_enabled()
+ or table.is_primary_key_table
+ or table.options.file_format() != 'parquet'
+ or not native_method_available('Table',
'new_full_text_search_builder')
+ or not _rest_catalog_supported(table)):
+ return None
+ if builder._limit is None or builder._limit <= 0:
+ raise ValueError('Limit must be positive, set via with_limit()')
+ # The full-text adapter accepts typed lists and booleans. Reuse its mapping
+ # before the generic table bridge stringifies options for Rust.
+ options = {'full-text.' + key: value for key, value in
NativeFullTextIndexOptions.from_options(
+ table.options.options.to_map()).to_native_options().items()}
+ native = _native_table(table.copy(options)).new_full_text_search_builder()
+ if builder._field_name is not None and builder._query is not None:
+ native.with_query(builder._field_name, builder._query)
+ native.with_limit(builder._limit)
+ try:
+ if builder._partition_filter is not None:
+
native.with_partition_filter(_predicate_to_native(builder._partition_filter))
+ if builder._filter is not None:
+ native.with_filter(_predicate_to_native(builder._filter))
+ except ValueError:
+ # Qualify literal support before searching. Some Python literals cannot
+ # be represented by the strict typed predicate FFI.
+ return None
+ # Once execution starts, propagate errors instead of running a second
search.
+ return DictBasedScoredIndexResult(native.execute_local().row_ids())
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 839993fbed..20d661f709 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -224,7 +224,9 @@ class AbstractVectorSearchReadImpl:
scanner.close()
def _refine_scalar_result(self, splits, result, snapshot=None):
- if result is not None and result.is_exact():
+ from pypaimon.table.source.global_index_row_filter import is_exact
+
+ if is_exact(self._filter, result):
return result.results()
if not self._table.options.global_index_filter_refine_from_data():
logging.getLogger(__name__).warning(
diff --git a/paimon-python/pypaimon/tests/btree_index_reader_test.py
b/paimon-python/pypaimon/tests/btree_index_reader_test.py
index 45184640d1..c1e396467e 100644
--- a/paimon-python/pypaimon/tests/btree_index_reader_test.py
+++ b/paimon-python/pypaimon/tests/btree_index_reader_test.py
@@ -29,6 +29,8 @@ from pypaimon.globalindex.btree.btree_index_reader import
BTreeIndexReader
from pypaimon.globalindex.btree.btree_index_writer import BTreeIndexWriter
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.globalindex.sorted_file_meta_selector import
SortedFileMetaSelector
+from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
from pypaimon.schema.data_types import AtomicType
@@ -111,6 +113,73 @@ class BTreeIndexReaderTest(unittest.TestCase):
reader.close()
self.assertTrue(file_io.input_stream.closed)
+ def test_string_prefix_intervals_are_exact(self):
+ values = [None, '', '\x00', 'ke', 'keep', 'keep', 'keeper', 'kf',
'missing-after',
+ '\x7f', '\x7fa', '\u0080', '\u00ff', '\u00ffa', '\u0100',
+ '\u07ff', '\u07ffa', '\u0800', '\uffff', '\uffffa',
'\U00010000',
+ '\U0010ffff', '\U0010ffffa']
+ serializer = create_serializer(AtomicType('STRING'))
+ with tempfile.TemporaryDirectory() as directory:
+ file_io = LocalFileIO()
+ writer = BTreeIndexWriter(file_io, directory, serializer,
block_size=32)
+ for row_id, value in sorted(enumerate(values), key=lambda item:
(item[1] is not None, item[1])):
+ writer.write(value, row_id)
+ entry = writer.finish()[0]
+ with open(os.path.join(directory, entry.file_name), 'rb') as
stream:
+ data = stream.read()
+ io_meta = GlobalIndexIOMeta(entry.file_name, len(data), entry.meta)
+ for input_type in (_TrackingInput, _PreadInput):
+ reader = BTreeIndexReader(serializer, self._file_io(data,
input_type), '/unused', io_meta)
+ try:
+ for prefix in ('', '\x00', 'ke', 'keep', 'missing', 'absent',
'zz',
+ '\x7f', '\u00ff', '\u07ff', '\uffff',
'\U0010ffff'):
+ with self.subTest(input_type=input_type, prefix=prefix):
+ result = reader.visit_starts_with(prefix)
+ self.assertTrue(result.is_exact())
+ self.assertEqual(
+ [row_id for row_id, value in enumerate(values)
+ if value is not None and
value.startswith(prefix)],
+ result.results().to_list())
+ finally:
+ reader.close()
+
+ def test_prefix_query_on_null_only_file_is_exact_and_empty(self):
+ serializer = create_serializer(AtomicType('STRING'))
+ with tempfile.TemporaryDirectory() as directory:
+ file_io = LocalFileIO()
+ writer = BTreeIndexWriter(file_io, directory, serializer,
block_size=32)
+ writer.write(None, 0)
+ writer.write(None, 1)
+ entry = writer.finish()[0]
+ file_size = os.path.getsize(os.path.join(directory,
entry.file_name))
+ io_meta = GlobalIndexIOMeta(entry.file_name, file_size, entry.meta)
+ reader = BTreeIndexReader(serializer, file_io, directory, io_meta)
+ try:
+ for prefix in ('', 'ke'):
+ with self.subTest(prefix=prefix):
+ result = reader.visit_starts_with(prefix)
+ self.assertTrue(result.is_exact())
+ self.assertTrue(result.results().is_empty())
+ finally:
+ reader.close()
+
+ def test_string_prefix_file_selection_uses_utf8_byte_bounds(self):
+ values = ['', '\x00', 'ke', 'kf', '\x7f', '\x7fa', '\u0080', '\u00ff',
'\u00ffa', '\u0100',
+ '\u07ff', '\u07ffa', '\u0800', '\uffff', '\uffffa',
'\U00010000', '\U0010ffff', None]
+ serializer = create_serializer(AtomicType('STRING'))
+ files = []
+ for row_id, value in enumerate(values):
+ key = None if value is None else serializer.serialize(value)
+ meta = SortedIndexFileMeta(key, key, value is None)
+ files.append((GlobalIndexIOMeta(str(row_id), 0, meta.serialize()),
meta))
+ selector = SortedFileMetaSelector(files, serializer)
+ for prefix in ('', '\x00', 'ke', 'keep', '\x7f', '\u00ff', '\u07ff',
'\uffff', '\U0010ffff'):
+ with self.subTest(prefix=prefix):
+ self.assertEqual(
+ [str(row_id) for row_id, value in enumerate(values)
+ if value is not None and value.startswith(prefix)],
+ [file.file_name for file in
selector.select_starts_with(prefix)])
+
def test_unsupported_versions_rejected_before_sst_read(self):
for input_type in (_TrackingInput, _PreadInput):
for version in (0, 2, 3, 0x80000000, 0xffffffff):
diff --git a/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
b/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
index 523f573dea..5de9a03795 100644
--- a/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
+++ b/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
@@ -41,10 +41,12 @@ def docs(tmp_path):
return table
-def append_rows(docs):
- docs.add(pa.table({"id": [3, 4], "text": ["paimon", "paimon another long
document"],
- "label": ["other", "target"], "pt": ["b"] * 2,
- "embedding": [[3., 1.], [4., 1.]]},
schema=docs.scan().to_arrow().schema))
+def append_rows(docs, row_ids=(3, 4)):
+ rows = [{"id": 3, "text": "paimon", "label": "other", "pt": "b",
"embedding": [3., 1.]},
+ {"id": 4, "text": "paimon another long document", "label":
"target", "pt": "b", "embedding": [4., 1.]}]
+ batch = pa.Table.from_pylist(
+ [row for row in rows if row["id"] in row_ids],
schema=docs.scan().to_arrow().schema)
+ docs.add(batch)
def builder(docs, predicate=None, limit=10):
@@ -62,27 +64,34 @@ def scores(result):
@pytest.mark.parametrize("kind", [None, "btree", "bitmap"])
@pytest.mark.parametrize("raw", [False, True])
-def test_full_text_data_filters_precede_top_k_and_preserve_scores(docs, kind,
raw):
[email protected]("refine", [False, True])
+def test_full_text_data_filters_precede_top_k_and_preserve_scores(docs, kind,
raw, refine):
+ docs.raw_table =
docs.raw_table.copy({"global-index.filter.refine-from-data":
str(refine).lower()})
if kind:
docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index("label", kind)
if raw:
append_rows(docs)
expected = scores(builder(docs).execute_local())
actual = scores(builder(docs, "label LIKE '%target%'",
limit=1).execute_local())
- candidates = [2, 4] if raw else [2]
- best = max(candidates, key=lambda row_id: (expected[row_id], -row_id))
- assert actual == {best: expected[best]}
+ # Java requires LIKE/contains refinement for either scalar index family.
+ # Raw rows are evaluated from the data independently of this option.
+ candidates = [] if kind is not None and not refine else [2]
+ if raw:
+ candidates.append(4)
+ best = max(candidates, key=lambda row_id: (expected[row_id], -row_id)) if
candidates else None
+ assert actual == ({best: expected[best]} if best is not None else {})
public = docs.search("paimon", column="text", pre_filter="label LIKE
'%target%'").select(["id"]).limit(1)
- assert public.to_list() == [{"id": best}]
+ assert public.to_list() == ([{"id": best}] if best is not None else [])
-def test_partial_scalar_coverage_does_not_drop_full_text_matches(docs):
[email protected]("mode, expected", [("fast", [2]), ("full", [2, 4]),
("detail", [2, 4])])
+def test_partial_scalar_coverage_follows_search_mode(docs, mode, expected):
docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index("label", "btree")
append_rows(docs)
docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index("text", "full-text")
- docs.raw_table = docs.raw_table.copy({"scalar-index.search-mode": "fast"})
- assert docs.search("paimon", column="text", pre_filter="label =
'target'").select(["id"]).limit(10).to_list() == [
- {"id": 2}, {"id": 4}]
+ docs.raw_table = docs.raw_table.copy({"scalar-index.search-mode": mode})
+ actual = docs.search("paimon", column="text", pre_filter="label =
'target'").select(["id"]).limit(10).to_list()
+ assert sorted(row["id"] for row in actual) == expected
@pytest.mark.parametrize("predicate, expected", [
@@ -91,7 +100,9 @@ def
test_partial_scalar_coverage_does_not_drop_full_text_matches(docs):
("id = 2", [2]),
])
def test_partition_and_data_predicates_keep_boolean_semantics(docs, predicate,
expected):
- append_rows(docs)
+ append_rows(docs, row_ids=[3])
+ docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index("text", "full-text")
+ append_rows(docs, row_ids=[4])
actual = docs.search("paimon", column="text",
pre_filter=predicate).select(["id"]).limit(10).to_list()
assert sorted(row["id"] for row in actual) == expected
@@ -102,11 +113,21 @@ def test_partition_only_filter_keeps_existing_path(docs):
assert len(docs.search("paimon", column="text", pre_filter="pt =
'a'").limit(10).to_list()) == 3
[email protected]("mode, expected", [("fast", []), ("full", [{"id":
4}])])
-def test_partition_without_full_text_index_respects_search_mode(docs, mode,
expected):
[email protected]("mode", ["fast", "full", "detail"])
+def test_partition_without_full_text_definition_is_empty(docs, mode):
append_rows(docs)
docs.raw_table = docs.raw_table.copy({"full-text-index.search-mode": mode})
search = docs.search("paimon", column="text", pre_filter="pt = 'b' AND
label = 'target'")
+ assert search.select(["id"]).limit(1).to_list() == []
+
+
[email protected]("mode, expected", [("fast", []), ("full", [{"id":
4}]), ("detail", [{"id": 4}])])
+def test_partition_with_full_text_definition_reads_unindexed_tail(docs, mode,
expected):
+ append_rows(docs, row_ids=[3])
+ docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index("text", "full-text")
+ append_rows(docs, row_ids=[4])
+ docs.raw_table = docs.raw_table.copy({"full-text-index.search-mode": mode})
+ search = docs.search("paimon", column="text", pre_filter="pt = 'b' AND
label = 'target'")
assert search.select(["id"]).limit(1).to_list() == expected
diff --git a/paimon-python/pypaimon/tests/native_full_text_search_test.py
b/paimon-python/pypaimon/tests/native_full_text_search_test.py
new file mode 100644
index 0000000000..2f578ce47f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_full_text_search_test.py
@@ -0,0 +1,466 @@
+# 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.
+
+"""REST full-text queries retain Java coverage, filtering and snapshot
semantics."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.api.api_response import GetTableSnapshotResponse, ErrorResponse
+from pypaimon.read.native_plan import _native_table, native_method_available,
_predicate_to_native
+from pypaimon.snapshot.table_snapshot import TableSnapshot
+from pypaimon.table.source.full_text_search_builder import
FullTextSearchBuilderImpl
+from pypaimon.tests import native_sorted_index_build_test as indexes
+from pypaimon.tests.vector_search_filter_test import match_query, boost_query
+
+rest_catalog = indexes.rest_catalog
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+ not native_method_available('Table', 'new_full_text_search_builder'),
reason='Rust full-text search API required')]
+SCHEMA = pa.schema([('id', pa.int32()), ('content', pa.string()), ('label',
pa.string()), ('pt', pa.int32())])
+
+
+def _rows(start, count):
+ return [{'id': i, 'content': None if i == 3 else ('paimon lake' if i % 2
else 'paimon paimon paimon'),
+ 'label': 'keep' if i % 2 else 'drop', 'pt': i // 4} for i in
range(start, start + count)]
+
+
+def _table(catalog, options=None, *, indexed=True, partial=False):
+ settings = {'read.native.enabled': 'true', 'full-text-index.search-mode':
'full',
+ 'scalar-index.search-mode': 'full',
'global-index.row-count-per-shard': '4'}
+ settings.update(options or {})
+ table = indexes._create(catalog, schema=SCHEMA, partitioned=True,
options=settings)
+ indexes._append(table, _rows(0, 8), schema=SCHEMA)
+ if indexed:
+ table.create_global_index('content', index_type='full-text')
+ if partial:
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ return table
+
+
+def _builder(table, query=None, limit=64, predicate=None, partition=None,
column='content'):
+ builder = (table.new_full_text_search_builder().with_query(column, query
or match_query('paimon'))
+ .with_limit(limit))
+ if predicate is not None:
+ builder.with_filter(predicate)
+ if partition is not None:
+ builder.with_partition_filter(partition)
+ return builder
+
+
+def _scores(result):
+ if result.is_empty():
+ return {}
+ score = result.score_getter()
+ return {row_id: score(row_id) for row_id in result.results()}
+
+
+def _compare(table, **kwargs):
+ classic = table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'})
+ expected = _scores(_builder(classic, **kwargs).execute_local())
+ with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+ side_effect=AssertionError('Python full-text scan')):
+ actual = _scores(_builder(table, **kwargs).execute_local())
+ assert set(actual) == set(expected)
+ assert actual == pytest.approx(expected, rel=1e-6, abs=1e-6)
+ return actual
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
[email protected]('query', [
+ match_query('paimon'), match_query('paimon lake', 'And'),
+ boost_query(match_query('paimon'), match_query('lake'), 0.1)])
[email protected]('limit', [1, 64])
+def test_native_full_text_coverage_and_structured_queries(rest_catalog, mode,
query, limit):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
partial=True)
+ scores = _compare(table, query=query, limit=limit)
+ if limit == 64 and query == match_query('paimon'):
+ assert set(scores) == ({0, 1, 2, 4, 5, 6, 7} if mode == 'fast' else
{0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11})
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('text_mode', ['fast', 'full', 'detail'])
[email protected]('scalar_mode', ['fast', 'full', 'detail'])
[email protected]('scalar_index', [None, 'btree', 'bitmap'])
+def test_scalar_filter_modes_before_full_text_top_k(rest_catalog, text_mode,
scalar_mode, scalar_index):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': text_mode,
'scalar-index.search-mode': scalar_mode})
+ if scalar_index is not None:
+ table.create_global_index('label', index_type=scalar_index)
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ predicate =
table.new_read_builder().new_predicate_builder().equal('label', 'keep')
+ scores = _compare(table, predicate=predicate)
+ indexed = set() if scalar_mode == 'fast' and scalar_index is None else {1,
5, 7}
+ assert set(scores) == indexed.union(set() if text_mode == 'fast' else {9,
11})
+ top = _compare(table, predicate=predicate, limit=1)
+ assert len(top) == min(len(scores), 1)
+ assert all(row_id % 2 for row_id in top)
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('refine', [False, True])
[email protected]('scalar_mode', ['fast', 'full', 'detail'])
[email protected]('index_type', ['btree', 'bitmap'])
[email protected]('method, literal', [('contains', 'eep'), ('endswith',
'eep'), ('like', 'k%p')])
+def test_candidate_filter_refinement_retains_full_corpus_scores(
+ rest_catalog, refine, scalar_mode, index_type, method, literal):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'global-index.filter.refine-from-data':
str(refine).lower(),
+ 'scalar-index.search-mode': scalar_mode})
+ table.create_global_index('label', index_type=index_type)
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ predicate = getattr(table.new_read_builder().new_predicate_builder(),
method)('label', literal)
+ scores = _compare(table, predicate=predicate)
+ assert set(scores) == ({1, 5, 7, 9, 11} if refine else {9, 11})
+ full = _compare(table)
+ assert scores == pytest.approx({row_id: full[row_id] for row_id in
scores}, abs=1e-6)
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('refine', [False, True])
[email protected]('compound', ['and', 'or'])
[email protected]('method, literal', [('contains', 'eep'), ('endswith',
'eep'), ('like', '%eep%')])
+def test_bitmap_candidate_leaves_require_refinement_in_compounds(rest_catalog,
refine, compound, method, literal):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'global-index.filter.refine-from-data':
str(refine).lower(),
+ 'scalar-index.search-mode': 'fast'})
+ table.create_global_index('label', index_type='bitmap')
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ pb = table.new_read_builder().new_predicate_builder()
+ candidate = getattr(pb, method)('label', literal)
+ predicate = (pb.and_predicates([pb.is_not_null('label'), candidate]) if
compound == 'and'
+ else pb.or_predicates([pb.equal('label', 'drop'), candidate]))
+ rows = set(_compare(table, predicate=predicate))
+ indexed = ({1, 5, 7} if compound == 'and' else {0, 1, 2, 4, 5, 6, 7}) if
refine else set()
+ assert rows == indexed.union({9, 11} if compound == 'and' else {8, 9, 10,
11})
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('pattern', ['keep', 'ke%'])
[email protected]('index_type', ['btree', 'bitmap'])
+def test_optimized_like_filters_stay_exact_without_refinement(rest_catalog,
pattern, index_type):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'global-index.filter.refine-from-data': 'false',
+ 'scalar-index.search-mode': 'fast'})
+ table.create_global_index('label', index_type=index_type)
+ predicate = table.new_read_builder().new_predicate_builder().like('label',
pattern)
+ assert set(_compare(table, predicate=predicate)) == {1, 5, 7}
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('refine', [False, True])
[email protected]('partial', [False, True])
[email protected]('index_type', ['btree', 'bitmap'])
[email protected]('method, literal', [
+ ('startswith', 'ke'), ('like', 'ke%'), ('startswith', 'missing'), ('like',
'missing%')])
+def test_prefix_filters_are_exact_before_refinement(rest_catalog, refine,
partial, index_type, method, literal):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'global-index.filter.refine-from-data':
str(refine).lower()})
+ table.create_global_index('label', index_type=index_type)
+ if partial:
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ predicate = getattr(table.new_read_builder().new_predicate_builder(),
method)('label', literal)
+ expected = ({1, 5, 7, 9, 11} if partial else {1, 5, 7}) if
literal.startswith('ke') else set()
+ assert set(_compare(table, predicate=predicate)) == expected
+ assert indexes._ids(table) == list(range(12 if partial else 8))
+
+
[email protected]('prefix', ['\x7f', '\u00ff', '\uffff', '\U0010ffff'])
[email protected]('method', ['startswith', 'like'])
+def
test_btree_unicode_prefix_bounds_keep_indexed_and_raw_matches(rest_catalog,
prefix, method):
+ catalog, _ = rest_catalog
+ table = indexes._create(catalog, schema=SCHEMA, options={
+ 'read.native.enabled': 'true', 'global-index.filter.refine-from-data':
'false'})
+ indexes._append(table, [
+ {'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
+ for i, label in enumerate([prefix, prefix + 'a', None, 'keep'])],
schema=SCHEMA)
+ table.create_global_index('content', index_type='full-text')
+ table.create_global_index('label', index_type='btree')
+ indexes._append(table, [
+ {'id': 4, 'content': 'paimon', 'label': prefix + 'raw', 'pt': 0},
+ {'id': 5, 'content': 'paimon', 'label': 'drop', 'pt': 0}],
schema=SCHEMA)
+ literal = prefix + '%' if method == 'like' else prefix
+ predicate = getattr(table.new_read_builder().new_predicate_builder(),
method)('label', literal)
+ assert set(_compare(table, predicate=predicate)) == {0, 1, 4}
+ assert indexes._ids(table) == list(range(6))
+
+
[email protected]('refine', [False, True])
[email protected]('partial', [False, True])
[email protected]('index_type', ['btree', 'bitmap'])
[email protected]('method, literal', [
+ ('like', ''), ('like', '%'), ('like', '%%'), ('contains', ''),
('endswith', ''), ('startswith', '')])
+def test_empty_string_filters_preserve_java_refinement(rest_catalog, refine,
partial, index_type, method, literal):
+ catalog, _ = rest_catalog
+ table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options={
+ 'read.native.enabled': 'true', 'full-text-index.search-mode': 'full',
+ 'scalar-index.search-mode': 'full',
'global-index.filter.refine-from-data': str(refine).lower()})
+ labels = ['', None, 'keep', 'drop', '', 'keep', 'drop', None]
+ indexes._append(table, [
+ {'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
+ for i, label in enumerate(labels)], schema=SCHEMA)
+ table.create_global_index('content', index_type='full-text')
+ table.create_global_index('label', index_type=index_type)
+ if partial:
+ labels += ['', None, 'keep', 'drop']
+ indexes._append(table, [
+ {'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
+ for i, label in enumerate(labels[8:], 8)], schema=SCHEMA)
+ predicate = getattr(table.new_read_builder().new_predicate_builder(),
method)('label', literal)
+ indexed = {0, 4} if method == 'like' and literal == '' else {0, 2, 3, 4,
5, 6}
+ raw = ({8} if method == 'like' and literal == '' else {8, 10, 11}) if
partial else set()
+ expected = (indexed if refine or method == 'startswith' else
set()).union(raw)
+ assert set(_compare(table, predicate=predicate)) == expected
+ assert indexes._ids(table) == list(range(len(labels)))
+
+
[email protected]('refine', [False, True])
+def test_escaped_bitmap_like_is_refined_without_prefix_rewrite(rest_catalog,
refine):
+ catalog, _ = rest_catalog
+ table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options={
+ 'read.native.enabled': 'true', 'scalar-index.search-mode': 'fast',
+ 'global-index.filter.refine-from-data': str(refine).lower()})
+ indexes._append(table, [
+ {'id': 0, 'content': 'paimon', 'label': 'ke%', 'pt': 0},
+ {'id': 1, 'content': 'paimon', 'label': r'ke\suffix', 'pt': 0},
+ {'id': 2, 'content': 'paimon', 'label': 'keep', 'pt': 0}],
schema=SCHEMA)
+ table.create_global_index('content', index_type='full-text')
+ table.create_global_index('label', index_type='bitmap')
+ predicate = table.new_read_builder().new_predicate_builder().like('label',
r'ke\%')
+ assert set(_compare(table, predicate=predicate)) == ({0} if refine else
set())
+ assert indexes._ids(table) == list(range(3))
+
+
[email protected]('selection', ['partition', 'mixed', 'repeat'])
[email protected]('mode', ['full', 'detail'])
+def test_partition_filter_applies_to_index_and_raw_reads(rest_catalog,
selection, mode):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
partial=True)
+ pb = table.new_read_builder().new_predicate_builder()
+ partition = pb.greater_or_equal('pt', 1)
+ if selection == 'partition':
+ scores = _compare(table, partition=partition)
+ assert set(scores) == set(range(4, 12))
+ elif selection == 'mixed':
+ scores = _compare(table, predicate=pb.and_predicates([partition,
pb.equal('label', 'keep')]))
+ assert set(scores) == {5, 7, 9, 11}
+ else:
+ builder = _builder(table,
partition=partition).with_partition_filter(pb.less_than('pt', 2))
+ assert set(_scores(builder.execute_local())) == set(range(4, 8))
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
+def test_no_full_text_definition_is_empty_in_every_mode(rest_catalog, mode):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
indexed=False)
+ assert _compare(table) == {}
+ assert indexes._ids(table) == list(range(8))
+
+
+def test_scan_read_plan_pins_snapshot_and_rejects_other_filters(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _table(catalog)
+ native = (_native_table(table).new_full_text_search_builder()
+ .with_query('content', match_query('paimon')).with_limit(64))
+ plan = native.new_full_text_scan().scan()
+ before = table.snapshot_manager().get_latest_snapshot().id
+ assert plan.snapshot_id() == before
+ reader = native.new_full_text_read()
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ assert set(reader.read_plan(plan).row_ids()) == {0, 1, 2, 4, 5, 6, 7}
+ assert set(native.execute_local().row_ids()) == {0, 1, 2, 4, 5, 6, 7, 8,
9, 10, 11}
+ predicate = table.new_read_builder().new_predicate_builder().equal('id', 0)
+ changed = (_native_table(table).new_full_text_search_builder()
+ .with_query('content', match_query('paimon')).with_limit(64))
+ changed.with_filter(_predicate_to_native(predicate))
+ with pytest.raises(Exception, match='different table, column or filter'):
+ changed.new_full_text_read().read_plan(plan)
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('response', ['first', 'empty', 'error'])
+def test_rest_snapshot_response_is_authoritative(rest_catalog, response):
+ catalog, server = rest_catalog
+ table = _table(catalog)
+ first = table.snapshot_manager().get_latest_snapshot()
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ if response == 'error':
+ reply = ErrorResponse('TABLE', 'indexes', 'snapshot unavailable', 503)
+ code = 503
+ else:
+ reply = (GetTableSnapshotResponse(TableSnapshot(first, 1, 0, 7,
first.time_millis))
+ if response == 'first' else GetTableSnapshotResponse())
+ code = 200
+ with patch.object(server, '_table_snapshot_handle',
return_value=server._mock_response(reply, code)):
+ if response == 'error':
+ with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+ side_effect=AssertionError('Unexpected Python
retry')):
+ with pytest.raises(Exception, match='snapshot unavailable'):
+ _builder(table).execute_local()
+ else:
+ assert set(_compare(table)) == ({0, 1, 2, 4, 5, 6, 7} if response
== 'first' else set())
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('native', [False, True])
+def test_missing_index_errors_propagate_without_search_retry(rest_catalog,
native):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'read.native.enabled': str(native).lower()})
+ file = indexes._files(table)[0]
+ table.file_io.delete(indexes._path(table, file))
+ if native:
+ with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+ side_effect=AssertionError('Unexpected Python
retry')):
+ with pytest.raises(Exception, match='NotFound|not found|No such
file|does not exist'):
+ _builder(table).execute_local()
+ else:
+ with pytest.raises(Exception):
+ _builder(table).execute_local()
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('native', [False, True])
[email protected]('case', ['limit', 'query', 'missing-column',
'partition', 'dsl'])
+def test_invalid_search_inputs_fail(rest_catalog, native, case):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'read.native.enabled': str(native).lower()})
+ if case == 'limit':
+ builder = _builder(table, limit=0)
+ elif case == 'query':
+ builder = table.new_full_text_search_builder().with_limit(1)
+ elif case == 'missing-column':
+ builder = table.new_full_text_search_builder().with_query('missing',
match_query('paimon')).with_limit(1)
+ elif case == 'partition':
+ with pytest.raises(ValueError, match='Partition filter'):
+ _builder(table,
partition=table.new_read_builder().new_predicate_builder().equal('id', 1))
+ assert indexes._ids(table) == list(range(8))
+ return
+ else:
+ builder = _builder(table, query='paimon')
+ with pytest.raises(Exception):
+ builder.execute_local()
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('mode', ['full', 'detail'])
[email protected]('options', [
+ {'full-text.stop-words': ['paimon']},
+ {'full-text.stop-words': ['lake'], 'full-text.lowercase': True}])
+def test_typed_analyzer_options_keep_raw_query_semantics(rest_catalog, mode,
options):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
partial=True).copy(options)
+ scores = _compare(table)
+ expected = ({0, 1, 2, 4, 5, 6, 7} if options['full-text.stop-words'] ==
['paimon']
+ else {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11})
+ assert set(scores) == expected
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
+def
test_composite_scalar_indexes_are_excluded_from_search_pre_filters(rest_catalog,
mode):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'scalar-index.search-mode': mode})
+ # Python intentionally supports single-column builds. Seed a composite
+ # definition through Rust to exercise Java-compatible search exclusion.
+ messages = (_native_table(table).new_global_index_build_builder()
+ .with_index_columns(['label',
'id']).with_index_type('btree').build())
+ indexes._commit(table, indexes.from_native_commit_messages(table,
messages))
+ pb = table.new_read_builder().new_predicate_builder()
+ predicate = pb.and_predicates([pb.equal('label', 'keep'),
pb.greater_than('id', 0)])
+ assert set(_compare(table, predicate=predicate)) == (set() if mode ==
'fast' else {1, 5, 7})
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('mode', ['full', 'detail'])
+def
test_schema_only_rename_retains_read_schema_for_unindexed_rows(rest_catalog,
mode):
+ from pypaimon.schema.schema_change import SchemaChange
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
partial=True)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ catalog.alter_table('default.indexes',
[SchemaChange.rename_column('content', 'body')], False)
+ table = catalog.get_table('default.indexes')
+ assert table.table_schema.id > snapshot.schema_id
+ assert set(_compare(table, column='body')) == {0, 1, 2, 4, 5, 6, 7, 8, 9,
10, 11}
+ native = (_native_table(table).new_full_text_search_builder()
+ .with_query('body', match_query('paimon')).with_limit(64))
+ plan = native.new_full_text_scan().scan()
+ assert set(native.new_full_text_read().read_plan(plan).row_ids()) == {0,
1, 2, 4, 5, 6, 7, 8, 9, 10, 11}
+ assert indexes._ids(table) == list(range(12))
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
+def test_deleted_indexed_and_raw_rows_are_excluded_before_top_k(rest_catalog,
mode):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode},
partial=True)
+ table = table.copy({'deletion-vectors.enabled': 'true'})
+ builder = table.new_batch_write_builder()
+ indexes._commit(table, builder.new_update().delete_by_row_id([0, 1, 8, 9]))
+ assert set(_compare(table)) == ({2, 4, 5, 6, 7} if mode == 'fast' else {2,
4, 5, 6, 7, 10, 11})
+ assert len(_compare(table, limit=3)) == 3
+ assert indexes._ids(table) == [2, 3, 4, 5, 6, 7, 10, 11]
+
+
[email protected]('native', [False, True])
[email protected]('limit', [-1, 0])
+def test_invalid_limit_has_same_value_error(rest_catalog, native, limit):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'read.native.enabled': str(native).lower()})
+ with pytest.raises(ValueError, match='Limit must be positive'):
+ _builder(table, limit=limit).execute_local()
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
+def test_updated_text_uses_invalidated_index_coverage(rest_catalog, mode):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'full-text-index.search-mode': mode,
+ 'global-index.column-update-action':
'DROP_PARTITION_INDEX'}, partial=True)
+ builder = table.new_batch_write_builder()
+ changed = pa.table({'_ROW_ID': pa.array([4], type=pa.int64()), 'content':
['rust']})
+ messages =
builder.new_update().with_update_type(['content']).update_by_arrow_with_row_id(changed)
+ indexes._commit(table, messages)
+ scores = _compare(table)
+ assert 4 not in scores
+ if mode != 'fast':
+ assert set(scores) == {0, 1, 2, 5, 6, 7, 8, 9, 10, 11}
+ assert indexes._ids(table) == list(range(12))
+
+
+def
test_unrepresentable_literal_qualifies_before_native_execution(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _table(catalog)
+ predicate = table.new_read_builder().new_predicate_builder().equal('id',
1.0)
+ expected = _scores(_builder(table.copy({'read.native.enabled': 'false'}),
predicate=predicate).execute_local())
+ scan = FullTextSearchBuilderImpl.new_full_text_scan
+ with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
autospec=True, side_effect=scan) as selected:
+ assert _scores(_builder(table, predicate=predicate).execute_local())
== expected
+ selected.assert_called_once()
+ assert set(expected) == {1}
+ assert indexes._ids(table) == list(range(8))
+
+
[email protected]('native', [False, True])
+def test_query_auth_rejects_search_before_index_access(rest_catalog, native):
+ catalog, _ = rest_catalog
+ table = _table(catalog)
+ authorized = table.copy({'query-auth.enabled': 'true',
'read.native.enabled': str(native).lower()})
+ with pytest.raises(Exception, match='query-auth'):
+ _builder(authorized).execute_local()
+ assert indexes._ids(table) == list(range(8))
diff --git a/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
b/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
index 4a1d77861e..86e1e78c91 100644
--- a/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
+++ b/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
@@ -62,40 +62,67 @@ def query(table, predicate, batch=False):
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("mode", ["full", "fast"])
@pytest.mark.parametrize("refine", [False, True])
-def test_btree_candidates_are_verified_before_top_k(table, caplog, pattern,
batch, mode, refine):
+def test_btree_string_filters_follow_java_exactness_before_top_k(table,
caplog, pattern, batch, mode, refine):
scalar_index(table)
table.raw_table = table.raw_table.copy({
"vector-index.search-mode": mode,
"global-index.filter.refine-from-data": str(refine).lower()})
result = query(table, "name LIKE '%s'" % pattern, batch).to_list()
- expected = [{"id": 1}] if refine else []
+ candidate_only = pattern == "%zeta%"
+ expected = [{"id": 1}] if refine or not candidate_only else []
assert result == ([expected, expected] if batch else expected)
- assert ("global-index.filter.refine-from-data=true" in caplog.text) ==
(not refine)
+ assert ("global-index.filter.refine-from-data=true" in caplog.text) ==
(candidate_only and not refine)
@pytest.mark.parametrize("kind, predicate", [
("btree", "name = 'beta zeta'"), ("btree", "name >= 'beta' AND name <
'gamma'"),
- ("btree", "name LIKE 'beta zeta'"), ("bitmap", "name LIKE '%zeta%'"),
+ ("btree", "name LIKE 'beta zeta'"), ("btree", "name LIKE 'beta%'"),
("bitmap", "name = 'beta zeta'"),
+ ("bitmap", "name LIKE 'beta%'"),
])
-def test_exact_indexes_do_not_read_filter_columns(table, kind, predicate):
[email protected]("refine", [False, True])
+def test_exact_indexes_do_not_read_filter_columns(table, kind, predicate,
refine):
scalar_index(table, kind)
- table.raw_table =
table.raw_table.copy({"global-index.filter.refine-from-data": "true"})
+ table.raw_table =
table.raw_table.copy({"global-index.filter.refine-from-data":
str(refine).lower()})
with patch.object(AbstractVectorSearchReadImpl, "_matching_candidate_rows",
side_effect=AssertionError("exact index recheck")):
assert query(table, predicate).to_list() == [{"id": 1}]
[email protected]("method, literal", [("contains", "zeta"),
("endswith", "zeta"), ("like", "%zeta%")])
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("mode", ["full", "fast"])
@pytest.mark.parametrize("refine", [False, True])
-def test_mixed_btree_and_bitmap_preserve_exact_matches(table, batch, mode,
refine):
+def test_bitmap_string_filters_obey_search_refinement(table, method, literal,
batch, mode, refine):
+ scalar_index(table, "bitmap")
+ table.raw_table = table.raw_table.copy({
+ "vector-index.search-mode": mode,
"global-index.filter.refine-from-data": str(refine).lower()})
+ pb = table.raw_table.new_read_builder().new_predicate_builder()
+ predicate = getattr(pb, method)("name", literal)
+ if batch:
+ search =
(table.raw_table.new_batch_vector_search_builder().with_vector_column("embedding")
+ .with_query_vectors([[0., 1.], [0.,
1.]]).with_filter(predicate).with_limit(1))
+ result = [list(value.results()) for value in
search.execute_batch_local()]
+ else:
+ search =
(table.raw_table.new_vector_search_builder().with_vector_column("embedding")
+ .with_query_vector([0.,
1.]).with_filter(predicate).with_limit(1))
+ result = list(search.execute_local().results())
+ expected = [1] if refine else []
+ assert result == ([expected, expected] if batch else expected)
+
+
[email protected]("batch", [False, True])
[email protected]("mode", ["full", "fast"])
[email protected]("refine", [False, True])
+def test_mixed_btree_and_bitmap_obey_search_refinement(table, batch, mode,
refine):
scalar_index(table, "btree")
scalar_index(table, "bitmap")
table.raw_table = table.raw_table.copy({
"vector-index.search-mode": mode,
"global-index.filter.refine-from-data": str(refine).lower()})
+ verify = AbstractVectorSearchReadImpl._matching_candidate_rows
with patch.object(AbstractVectorSearchReadImpl, "_matching_candidate_rows",
- side_effect=AssertionError("exact index recheck")):
+ autospec=True, side_effect=verify) as read:
result = query(table, "name LIKE '%zeta%'", batch).to_list()
- expected = [{"id": 1}]
+ assert read.call_count == (1 if refine else 0)
+ expected = [{"id": 1}] if refine else []
assert result == ([expected, expected] if batch else expected)