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 ae8f9d5ea0 [python] Support data prefilters for full-text and hybrid 
search (#10006)
ae8f9d5ea0 is described below

commit ae8f9d5ea0c51aa56be3d4acd98e95f815720294
Author: chaoyang <[email protected]>
AuthorDate: Mon Sep 21 11:34:21 2026 +0800

    [python] Support data prefilters for full-text and hybrid search (#10006)
---
 docs/docs/pypaimon/multimodal-search.md            |  23 ++-
 paimon-python/pypaimon/multimodal/query.py         |   2 +-
 .../pypaimon/table/source/full_text_read.py        |  43 ++++-
 .../pypaimon/table/source/full_text_scan.py        |  23 ++-
 .../table/source/full_text_search_builder.py       |  23 +++
 .../table/source/global_index_row_filter.py        |  42 +++++
 .../pypaimon/table/source/hybrid_search_builder.py |   9 +-
 .../pypaimon/table/source/vector_search_read.py    |  18 +--
 .../pypaimon/tests/full_text_scalar_filter_test.py | 179 +++++++++++++++++++++
 .../pypaimon/tests/vector_search_filter_test.py    |   7 +-
 10 files changed, 319 insertions(+), 50 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-search.md 
b/docs/docs/pypaimon/multimodal-search.md
index 05d38be456..1c56a5e04b 100644
--- a/docs/docs/pypaimon/multimodal-search.md
+++ b/docs/docs/pypaimon/multimodal-search.md
@@ -60,8 +60,18 @@ has exactly one text column. To target a specific text 
column, pass `column`.
 
 Use `pre_filter` to prune search candidates before ranking. Use `where()` to
 filter the rows read from the search result. Both `pre_filter` and `where()`
-accept SQL-like predicate strings. For full-text search, `pre_filter` must only
-reference partition columns.
+accept SQL-like predicate strings. On data-evolution tables, full-text
+`pre_filter` supports ordinary data columns as well as partition columns.
+For primary-key full-text search, only partition predicates are supported.
+
+Full-text data predicates are evaluated before Top-K selection. PyPaimon reads
+the predicate's columns and row IDs at the search snapshot, then passes the
+matching row IDs to the full-text index. Scalar indexes can prune this read,
+but partial scalar-index coverage does not exclude matching rows covered by
+the full-text search plan. This filter read can scan all candidate rows;
+partition-only predicates keep the existing partition-pruning path. For
+unindexed data in `full-text-index.search-mode=full`, filtering preserves the
+unfiltered corpus used to calculate BM25 statistics.
 
 For data-evolution vector search, a scalar index may return candidates rather 
than exact
 matches, for example for BTree string-prefix or substring predicates, or when
@@ -92,7 +102,7 @@ neighbors = (
 )
 
 matches = (
-    docs.search("paimon vector", column="content")
+    docs.search("paimon vector", column="content", pre_filter="category = 
'lake'")
     .limit(10)
     .to_pandas()
 )
@@ -272,8 +282,11 @@ text column when the table has exactly one text column. To 
target a specific
 text column, pass `column` to `pm.text_route`.
 
 `pre_filter` is applied before ranking. It accepts a SQL-like predicate string.
-When a hybrid query has a full-text route, `pre_filter` must only reference
-partition columns.
+On data-evolution tables, ordinary data predicates are applied to both vector
+and full-text routes before each route selects its candidates. The vector
+route retains the `global-index.filter.refine-from-data` behavior described
+above, while the full-text route verifies data predicates through a 
filter-column
+read. Partition-only predicates prune both routes without that extra read.
 
 ```python
 # This example assumes the table is partitioned by dt.
diff --git a/paimon-python/pypaimon/multimodal/query.py 
b/paimon-python/pypaimon/multimodal/query.py
index a7c8a569a2..cb218fdc1e 100644
--- a/paimon-python/pypaimon/multimodal/query.py
+++ b/paimon-python/pypaimon/multimodal/query.py
@@ -685,7 +685,7 @@ class TextQuery(_PreFilterQuery):
             .with_limit(limit)
         )
         if query._pre_filter is not None:
-            builder = builder.with_partition_filter(query._pre_filter)
+            builder = builder.with_filter(query._pre_filter)
         return builder.execute_local()
 
 
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py 
b/paimon-python/pypaimon/table/source/full_text_read.py
index cccd1a90e1..c4236b6646 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -39,6 +39,7 @@ from pypaimon.table.source.full_text_search_split import (
 )
 from pypaimon.table.source.full_text_scan import FullTextScanPlan
 from pypaimon.utils.range import Range
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
 class FullTextRead(ABC):
@@ -62,6 +63,7 @@ class DataEvolutionFullTextRead(FullTextRead):
         text_column,
         query: str,
         partition_filter=None,
+        filter_=None,
     ):
         self._table = table
         self._limit = limit
@@ -72,6 +74,7 @@ class DataEvolutionFullTextRead(FullTextRead):
                 % self._text_columns)
         self._query = query
         self._partition_filter = partition_filter
+        self._filter = filter_
 
     def read_plan(self, plan: FullTextScanPlan) -> GlobalIndexResult:
         reader = copy(self)
@@ -88,9 +91,25 @@ 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)
-        raw_result = self._read_raw_search(
-            _raw_row_ranges(raw_splits), _index_type(index_splits))
+        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)
         return indexed_result.or_(raw_result).top_k(self._limit)
 
     def _eval_column_query(
@@ -170,7 +189,7 @@ class DataEvolutionFullTextRead(FullTextRead):
         future.add_done_callback(lambda _: reader.close())
         return future
 
-    def _read_raw_search(self, raw_row_ranges, index_type):
+    def _read_raw_search(self, raw_row_ranges, index_type, 
include_row_ids=None):
         raw_row_ranges = Range.sort_and_merge_overlap(raw_row_ranges, True)
         if not raw_row_ranges:
             return DictBasedScoredIndexResult({})
@@ -179,6 +198,15 @@ class DataEvolutionFullTextRead(FullTextRead):
 
         row_range_start = raw_row_ranges[0].from_
         row_range_end = raw_row_ranges[-1].to
+        search_kwargs = {}
+        if include_row_ids is not None:
+            raw_rows = GlobalIndexResult.from_ranges(raw_row_ranges).results()
+            include_row_ids = RoaringBitmap64.and_(include_row_ids, raw_rows)
+            if include_row_ids.is_empty():
+                return DictBasedScoredIndexResult({})
+            search_kwargs["include_row_ids"] = include_row_ids
+        # Build the same corpus as an unfiltered query so BM25 statistics stay
+        # unchanged. Filter row IDs in the native search, before selecting 
hits.
         table = self._read_raw_rows(raw_row_ranges)
         if table is None or table.num_rows == 0:
             return DictBasedScoredIndexResult({})
@@ -196,6 +224,7 @@ class DataEvolutionFullTextRead(FullTextRead):
             row_range_start,
             self._query,
             _candidate_limit(row_range_start, row_range_end),
+            **search_kwargs,
         ).top_k(self._limit)
 
     def _read_raw_rows(self, raw_row_ranges):
@@ -300,7 +329,7 @@ def _index_type(index_splits):
     return None
 
 
-def _search_raw_full_text(index_bytes, row_range_start, query, limit):
+def _search_raw_full_text(index_bytes, row_range_start, query, limit, 
include_row_ids=None):
     from paimon_ftindex import FullTextIndexReader
     from pypaimon.globalindex.full_text.native_full_text_global_index_reader 
import (
         PaimonFullTextInput,
@@ -308,7 +337,11 @@ def _search_raw_full_text(index_bytes, row_range_start, 
query, limit):
 
     reader = FullTextIndexReader(PaimonFullTextInput(BytesIO(index_bytes)))
     try:
-        row_ids, scores = reader.search(query, limit=limit)
+        search_kwargs = {}
+        if include_row_ids is not None:
+            relative_ids = 
GlobalIndexResult.create(include_row_ids).offset(-row_range_start).results()
+            search_kwargs["filter_bytes"] = relative_ids.serialize()
+        row_ids, scores = reader.search(query, limit=limit, **search_kwargs)
         return DictBasedScoredIndexResult(
             {
                 row_range_start + row_id: score
diff --git a/paimon-python/pypaimon/table/source/full_text_scan.py 
b/paimon-python/pypaimon/table/source/full_text_scan.py
index 29b44ee5b0..ae5d089d0b 100644
--- a/paimon-python/pypaimon/table/source/full_text_scan.py
+++ b/paimon-python/pypaimon/table/source/full_text_scan.py
@@ -115,18 +115,17 @@ class DataEvolutionFullTextScan(FullTextScan):
                     IndexFullTextSearchSplit(
                         column_name, range_key.from_, range_key.to, files))
 
-        if all_index_files:
-            raw_row_ranges = DataEvolutionGlobalIndexCoverage(
-                self._table,
-                snapshot,
-                partition_filter,
-                all_index_files,
-            ).unindexed_ranges(
-                list(text_column_ids),
-                search_mode=self._table.options.full_text_index_search_mode(),
-            )
-            if raw_row_ranges:
-                splits.append(RawFullTextSearchSplit(raw_row_ranges))
+        raw_row_ranges = DataEvolutionGlobalIndexCoverage(
+            self._table,
+            snapshot,
+            partition_filter,
+            all_index_files,
+        ).unindexed_ranges(
+            list(text_column_ids),
+            search_mode=self._table.options.full_text_index_search_mode(),
+        )
+        if raw_row_ranges:
+            splits.append(RawFullTextSearchSplit(raw_row_ranges))
 
         return FullTextScanPlan(splits, snapshot)
 
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 420f918ac5..72b34a92dd 100644
--- a/paimon-python/pypaimon/table/source/full_text_search_builder.py
+++ b/paimon-python/pypaimon/table/source/full_text_search_builder.py
@@ -49,6 +49,11 @@ class FullTextSearchBuilder(ABC):
         """Partition predicate used to prune index manifest entries."""
         pass
 
+    @abstractmethod
+    def with_filter(self, predicate) -> 'FullTextSearchBuilder':
+        """Filter data-evolution rows before full-text ranking."""
+        pass
+
     @abstractmethod
     def new_full_text_scan(self) -> FullTextScan:
         """Create full-text scan to scan index files."""
@@ -73,6 +78,7 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
         self._field_name: Optional[str] = None
         self._query: Optional[str] = None
         self._partition_filter = None
+        self._filter = None
 
     def with_limit(self, limit: int) -> 'FullTextSearchBuilder':
         self._limit = limit
@@ -83,6 +89,20 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
         self._query = query
         return self
 
+    def with_filter(self, predicate) -> 'FullTextSearchBuilder':
+        if predicate is None:
+            return self
+        from pypaimon.read.push_down_utils import _split_and, _get_all_fields
+
+        partition_keys = set(self._table.partition_keys or [])
+        for part in _split_and(predicate):
+            if partition_keys and 
_get_all_fields(part).issubset(partition_keys):
+                self.with_partition_filter(part)
+            else:
+                self._filter = (part if self._filter is None else
+                                PredicateBuilder.and_predicates([self._filter, 
part]))
+        return self
+
     def with_partition_filter(self, partition_filter) -> 
'FullTextSearchBuilder':
         if partition_filter is None:
             self._partition_filter = None
@@ -135,6 +155,8 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
         reject_search_under_query_auth(self._table)
         if self._limit <= 0:
             raise ValueError("Limit must be positive, set via with_limit()")
+        if self._filter is not None and not 
self._table.options.data_evolution_enabled():
+            raise NotImplementedError("Full-text row filters require a 
data-evolution table.")
         definition = self._primary_key_full_text_definition()
         if definition is not None:
             from pypaimon.common.options.core_options import 
GlobalIndexSearchMode
@@ -156,6 +178,7 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
             self._text_columns(),
             self._query,
             partition_filter=self._partition_filter,
+            filter_=self._filter,
         )
 
     def _text_columns(self):
diff --git a/paimon-python/pypaimon/table/source/global_index_row_filter.py 
b/paimon-python/pypaimon/table/source/global_index_row_filter.py
new file mode 100644
index 0000000000..0dc087f56a
--- /dev/null
+++ b/paimon-python/pypaimon/table/source/global_index_row_filter.py
@@ -0,0 +1,42 @@
+# 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.
+
+"""Exact row filtering for data-evolution search candidates."""
+
+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 matching_rows(table, predicate, candidates, partition_filter=None, 
snapshot=None):
+    """Read only filter dependencies and row IDs, closing both reader and 
source."""
+    matched = RoaringBitmap64()
+    if candidates.is_empty():
+        return matched
+    table = table_at_snapshot(table, snapshot)
+    builder = (table.new_read_builder().with_filter(predicate)
+               .with_projection([SpecialFields.ROW_ID.name]))
+    if partition_filter is not None:
+        builder = builder.with_partition_filter(partition_filter)
+    splits = 
builder.new_scan().with_row_ranges(candidates.to_range_list()).plan().splits()
+    reader, batches = builder.new_read()._new_arrow_batch_reader(splits)
+    with _ClosableArrowBatchReader(reader, batches) as batch_reader:
+        for batch in batch_reader:
+            for row_id in batch.column(SpecialFields.ROW_ID.name).to_pylist():
+                matched.add(row_id)
+    return matched
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py 
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 01736816db..42914c2f27 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -363,13 +363,6 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
             raise ValueError("Routes cannot be empty")
         if self._limit <= 0:
             raise ValueError("Limit must be positive, got: %s" % self._limit)
-        if self._filter is not None:
-            for route in self._routes:
-                if route.is_full_text():
-                    raise ValueError(
-                        "Hybrid search with full-text routes does not support "
-                        "non-partition filters because full-text indexes 
cannot "
-                        "apply row-id pre-filters before top-k ranking.")
 
     def _new_vector_search_builder(self, route):
         builder = (
@@ -393,6 +386,8 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
         )
         if self._partition_filter is not None:
             builder.with_partition_filter(self._partition_filter)
+        if self._filter is not None:
+            builder.with_filter(self._filter)
         return builder
 
     def _rrf(self, route_results):
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 59909966d0..0df1fec983 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -202,23 +202,9 @@ class AbstractVectorSearchReadImpl:
         return self._matching_candidate_rows(candidates, snapshot)
 
     def _matching_candidate_rows(self, candidates, snapshot):
-        from pypaimon.read.table_read import _ClosableArrowBatchReader
+        from pypaimon.table.source.global_index_row_filter import matching_rows
 
-        matched = RoaringBitmap64()
-        if candidates.is_empty():
-            return matched
-        table = global_index_live_row_filter.table_at_snapshot(self._table, 
snapshot)
-        builder = (table.new_read_builder().with_filter(self._filter)
-                   .with_projection([SpecialFields.ROW_ID.name]))
-        if self._partition_filter is not None:
-            builder = builder.with_partition_filter(self._partition_filter)
-        splits = 
builder.new_scan().with_row_ranges(candidates.to_range_list()).plan().splits()
-        reader, batches = builder.new_read()._new_arrow_batch_reader(splits)
-        with _ClosableArrowBatchReader(reader, batches) as batch_reader:
-            for batch in batch_reader:
-                for row_id in 
batch.column(SpecialFields.ROW_ID.name).to_pylist():
-                    matched.add(row_id)
-        return matched
+        return matching_rows(self._table, self._filter, candidates, 
self._partition_filter, snapshot)
 
     def _pre_filter(self, splits, snapshot=None):
         # Backwards-compatible helper used by older tests/callers.
diff --git a/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py 
b/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
new file mode 100644
index 0000000000..523f573dea
--- /dev/null
+++ b/paimon-python/pypaimon/tests/full_text_scalar_filter_test.py
@@ -0,0 +1,179 @@
+# 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.
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+import pypaimon.multimodal as pm
+from pypaimon.table.source.full_text_read import DataEvolutionFullTextRead
+from pypaimon.table.source.global_index_row_filter import matching_rows
+
+
[email protected]
+def docs(tmp_path):
+    pytest.importorskip("paimon_ftindex")
+    schema = pa.schema([("id", pa.int64()), ("text", pa.string()), ("label", 
pa.string()),
+                        ("pt", pa.string()), ("embedding", 
pa.list_(pa.float32(), 2))])
+    table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
+        "docs", schema=schema, partitioned=["pt"], options={
+            "file.format": "parquet", "vector.file.format": "parquet", 
"read.batch-size": "1",
+            "full-text-index.search-mode": "full", "vector-index.search-mode": 
"full"})
+    table.add(pa.table({"id": [0, 1, 2], "text": ["paimon", "paimon paimon", 
"paimon long document"],
+                        "label": ["other", "other", "target"], "pt": ["a"] * 3,
+                        "embedding": [[0., 1.], [1., 1.], [2., 1.]]}, 
schema=schema))
+    table.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index("text", "full-text")
+    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 builder(docs, predicate=None, limit=10):
+    result = docs.raw_table.new_full_text_search_builder().with_query(
+        "text", '{"match":{"query":"paimon"}}').with_limit(limit)
+    if predicate is not None:
+        from pypaimon.common.where_parser import parse_where_clause
+        result.with_filter(parse_where_clause(predicate, 
docs.raw_table.fields))
+    return result
+
+
+def scores(result):
+    return {row_id: result.score_getter()(row_id) for row_id in 
result.results()}
+
+
[email protected]("kind", [None, "btree", "bitmap"])
[email protected]("raw", [False, True])
+def test_full_text_data_filters_precede_top_k_and_preserve_scores(docs, kind, 
raw):
+    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]}
+    public = docs.search("paimon", column="text", pre_filter="label LIKE 
'%target%'").select(["id"]).limit(1)
+    assert public.to_list() == [{"id": best}]
+
+
+def test_partial_scalar_coverage_does_not_drop_full_text_matches(docs):
+    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}]
+
+
[email protected]("predicate, expected", [
+    ("pt = 'b' AND label = 'target'", [4]),
+    ("pt = 'a' OR id = 4", [0, 1, 2, 4]),
+    ("id = 2", [2]),
+])
+def test_partition_and_data_predicates_keep_boolean_semantics(docs, predicate, 
expected):
+    append_rows(docs)
+    actual = docs.search("paimon", column="text", 
pre_filter=predicate).select(["id"]).limit(10).to_list()
+    assert sorted(row["id"] for row in actual) == expected
+
+
+def test_partition_only_filter_keeps_existing_path(docs):
+    with patch("pypaimon.table.source.global_index_row_filter.matching_rows",
+               side_effect=AssertionError("partition-only data read")):
+        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):
+    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() == expected
+
+
+def test_non_partitioned_table_filters_before_top_k(tmp_path):
+    pytest.importorskip("paimon_ftindex")
+    schema = pa.schema([("id", pa.int64()), ("text", pa.string())])
+    docs = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
+        "docs", schema=schema, options={"file.format": "parquet"})
+    docs.add(pa.table({"id": [0, 1], "text": ["paimon", "paimon long 
document"]}, schema=schema))
+    docs.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index("text", "full-text")
+    assert docs.search("paimon", column="text").where("id = 
1").limit(1).to_list() == []
+    assert docs.search("paimon", column="text", pre_filter="id = 
1").select(["id"]).limit(1).to_list() == [
+        {"id": 1}]
+
+
+def test_empty_matches_skip_native_search(docs):
+    with patch.object(DataEvolutionFullTextRead, "_eval", 
side_effect=AssertionError("empty search")):
+        assert docs.search("paimon", column="text", pre_filter="id < 
0").limit(1).to_list() == []
+
+
[email protected]("selector", ["snapshot", "tag"])
+def test_filters_use_historical_snapshot_and_deletions(docs, selector):
+    saved = docs.raw_table.snapshot_manager().get_latest_snapshot()
+    docs.raw_table.create_tag("saved", snapshot_id=saved.id)
+    options = {"snapshot_id": saved.id} if selector == "snapshot" else 
{"tag_name": "saved"}
+    docs.delete("id = 2")
+    search = docs.search("paimon", column="text", pre_filter="label = 
'target'", **options)
+    assert search.select(["id"]).limit(1).to_list() == [{"id": 2}]
+    assert docs.search("paimon", column="text", pre_filter="label = 
'target'").limit(1).to_list() == []
+
+
+def test_commit_during_filter_read_keeps_execution_snapshot(docs):
+    search = docs.search("paimon", column="text", pre_filter="label = 
'target'").select(["id"]).limit(1)
+
+    def verify(table, predicate, candidates, partition_filter=None, 
snapshot=None):
+        docs.update("id = 2", {"label": "changed"})
+        return matching_rows(table, predicate, candidates, partition_filter, 
snapshot)
+
+    with patch("pypaimon.table.source.global_index_row_filter.matching_rows", 
verify):
+        assert search.to_list() == [{"id": 2}]
+    assert search.to_list() == []
+
+
+def test_hybrid_applies_data_filter_to_both_routes(docs):
+    search = docs.search_hybrid([
+        pm.vector_route("embedding", [0., 1.], limit=1),
+        pm.text_route("paimon", column="text", limit=1),
+    ], pre_filter="label = 'target'").select(["id"]).limit(1)
+    assert search.to_list() == [{"id": 2}]
+
+
+def test_data_filter_does_not_project_vectors_or_text(docs):
+    from pypaimon.read.table_read import TableRead
+    original = TableRead._new_arrow_batch_reader
+    projections = []
+
+    def read(reader, *args, **kwargs):
+        projections.append([field.name for field in reader.read_type])
+        return original(reader, *args, **kwargs)
+
+    with patch.object(TableRead, "_new_arrow_batch_reader", read):
+        assert docs.search("paimon", column="text", pre_filter="label = 
'target'").select(["id"]).limit(1).to_list()
+    assert projections == [["_ROW_ID"]]
+    assert all("embedding" not in fields and "text" not in fields for fields 
in projections)
+
+
+def test_primary_key_row_filter_is_explicitly_unsupported(docs):
+    docs.raw_table = docs.raw_table.copy({"data-evolution.enabled": "false"})
+    with pytest.raises(NotImplementedError, match="data-evolution"):
+        builder(docs, "id = 2").new_full_text_read()
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index 1d2731666f..837e792ea7 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -2683,7 +2683,7 @@ class HybridSearchBuilderTest(unittest.TestCase):
         self.assertEqual(("query", "content", match_query("paimon search", 
"And")),
                          captured_builders[1].calls[0])
 
-    def test_hybrid_search_rejects_data_filter_with_full_text_route(self):
+    def test_hybrid_search_forwards_data_filter_to_full_text_route(self):
         from pypaimon.table.source.hybrid_search_builder import (
             HybridSearchBuilderImpl,
         )
@@ -2701,9 +2701,8 @@ class HybridSearchBuilderTest(unittest.TestCase):
             .with_limit(5)
         )
 
-        with self.assertRaises(ValueError) as ctx:
-            builder.route_builders()
-        self.assertIn("full-text routes", str(ctx.exception))
+        route = builder.route_builders()[0]
+        self.assertEqual(pb.equal("id", 1), route.search_builder._filter)
 
     def test_hybrid_search_rejects_full_text_route_options(self):
         from pypaimon.table.source.hybrid_search_builder import (

Reply via email to