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 3ab3a2c65f [python] Support raw fallback for global indexes (#8310)
3ab3a2c65f is described below

commit 3ab3a2c65ffa5b46492b6b928a987babba24800e
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jun 21 22:48:59 2026 +0800

    [python] Support raw fallback for global indexes (#8310)
    
    This PR ports the Java raw fallback behavior for global indexes to
    Python. It adds `global-index.search-mode` support and lets scalar
    global index scans and vector search include unindexed row ranges when
    configured with `full` or `detail` search modes.
---
 .../test/java/org/apache/paimon/JavaPyE2ETest.java |  71 ++++
 paimon-python/dev/run_mixed_tests.sh               |  79 +++-
 .../pypaimon/common/options/core_options.py        |  19 +
 .../pypaimon/globalindex/global_index_coverage.py  | 145 +++++++
 .../pypaimon/globalindex/global_index_scanner.py   | 102 ++++-
 paimon-python/pypaimon/read/read_builder.py        |   8 +-
 .../pypaimon/read/scanner/file_scanner.py          |   5 +-
 paimon-python/pypaimon/read/table_scan.py          |  30 +-
 .../table/source/batch_vector_search_builder.py    |   1 +
 .../pypaimon/table/source/vector_search_builder.py |   2 +
 .../pypaimon/table/source/vector_search_read.py    | 314 ++++++++++++++-
 .../pypaimon/table/source/vector_search_scan.py    | 123 +++++-
 .../pypaimon/table/source/vector_search_split.py   |  44 ++-
 .../pypaimon/tests/e2e/java_py_read_write_test.py  |  62 +++
 paimon-python/pypaimon/tests/global_index_test.py  | 194 ++++++++++
 .../pypaimon/tests/vector_search_filter_test.py    | 423 +++++++++++++++++++++
 .../test/java/org/apache/paimon/JavaPyE2ETest.java | 117 ++++++
 17 files changed, 1685 insertions(+), 54 deletions(-)

diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java 
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 2a7584a0bc..695cc29d76 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -516,6 +516,77 @@ public class JavaPyE2ETest {
         testBtreeIndexWriteNull();
     }
 
+    @Test
+    @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
+    public void testBtreeRawFallbackWrite() throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.STRING(), 
DataTypes.STRING()},
+                        new String[] {"k", "v"});
+        Options options = new Options();
+        Path tablePath = new Path(warehouse.toString() + 
"/default.db/test_btree_raw_fallback");
+        LocalFileIO.create().delete(tablePath, true);
+        options.set(PATH, tablePath.toString());
+        options.set(ROW_TRACKING_ENABLED, true);
+        options.set(DATA_EVOLUTION_ENABLED, true);
+        options.set(GLOBAL_INDEX_ENABLED, true);
+        TableSchema tableSchema =
+                SchemaUtils.forceCommit(
+                        new SchemaManager(LocalFileIO.create(), tablePath),
+                        new Schema(
+                                rowType.getFields(),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                options.toMap(),
+                                ""));
+        AppendOnlyFileStoreTable table =
+                new AppendOnlyFileStoreTable(
+                        FileIOFinder.find(tablePath),
+                        tablePath,
+                        tableSchema,
+                        CatalogEnvironment.empty());
+
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(
+                    GenericRow.of(BinaryString.fromString("k1"), 
BinaryString.fromString("v1")));
+            write.write(
+                    GenericRow.of(BinaryString.fromString("k2"), 
BinaryString.fromString("v2")));
+            write.write(
+                    GenericRow.of(BinaryString.fromString("k3"), 
BinaryString.fromString("v3")));
+            commit.commit(write.prepareCommit());
+        }
+
+        SortedGlobalIndexBuilder builder =
+                new SortedGlobalIndexBuilder(table, 
"btree").withIndexField("k");
+        try (BatchTableCommit commit = writeBuilder.newCommit()) {
+            commit.commit(
+                    builder.build(
+                            builder.scan()
+                                    
.map(org.apache.paimon.utils.Pair::getValue)
+                                    .orElseThrow(
+                                            () ->
+                                                    new IllegalStateException(
+                                                            "Expected scan 
result when building index."))
+                                    .get(0),
+                            IOManager.create(warehouse.toString())));
+        }
+
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(
+                    GenericRow.of(BinaryString.fromString("k4"), 
BinaryString.fromString("v4")));
+            commit.commit(write.prepareCommit());
+        }
+
+        List<IndexManifestEntry> indexEntries =
+                
table.indexManifestFileReader().read(table.latestSnapshot().get().indexManifest);
+        assertThat(indexEntries)
+                .singleElement()
+                .matches(entry -> entry.indexFile().rowCount() == 3);
+    }
+
     @Test
     @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
     public void testBitmapIndexWrite() throws Exception {
diff --git a/paimon-python/dev/run_mixed_tests.sh 
b/paimon-python/dev/run_mixed_tests.sh
index c3d91893df..8da1cb345b 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -219,6 +219,29 @@ run_btree_index_test() {
     fi
 }
 
+run_btree_raw_fallback_test() {
+    echo -e "${YELLOW}=== Running BTree Raw Fallback Test (Java Write, Python 
Read) ===${NC}"
+
+    cd "$PROJECT_ROOT"
+
+    echo "Running Maven test for JavaPyE2ETest.testBtreeRawFallbackWrite..."
+    if mvn test 
-Dtest=org.apache.paimon.JavaPyE2ETest#testBtreeRawFallbackWrite -pl 
paimon-core -am -q -DfailIfNoTests=false -Drun.e2e.tests=true; then
+        echo -e "${GREEN}✓ Java test completed successfully${NC}"
+    else
+        echo -e "${RED}✗ Java test failed${NC}"
+        return 1
+    fi
+    cd "$PAIMON_PYTHON_DIR"
+    echo "Running Python test for 
JavaPyReadWriteTest.test_read_btree_raw_fallback..."
+    if python -m pytest 
java_py_read_write_test.py::JavaPyReadWriteTest::test_read_btree_raw_fallback 
-v; then
+        echo -e "${GREEN}✓ Python test completed successfully${NC}"
+        return 0
+    else
+        echo -e "${RED}✗ Python test failed${NC}"
+        return 1
+    fi
+}
+
 run_bitmap_index_test() {
     echo -e "${YELLOW}=== Step 6b: Running Bitmap Index Test (Java Write, 
Python Read) ===${NC}"
 
@@ -541,6 +564,32 @@ run_vindex_vector_test() {
     fi
 }
 
+run_vindex_vector_raw_fallback_test() {
+    echo -e "${YELLOW}=== Running paimon-vindex Vector Raw Fallback Test (Java 
Write, Python Read) ===${NC}"
+
+    cd "$PROJECT_ROOT"
+
+    echo "Running Maven test for 
JavaPyE2ETest.testVindexVectorRawFallbackWrite..."
+    if mvn test 
-Dtest=org.apache.paimon.JavaPyE2ETest#testVindexVectorRawFallbackWrite -pl 
paimon-vector -am -q -DfailIfNoTests=false -Drun.e2e.tests=true; then
+        echo -e "${GREEN}✓ Java test completed successfully${NC}"
+    else
+        echo -e "${RED}✗ Java test failed${NC}"
+        return 1
+    fi
+    cd "$PAIMON_PYTHON_DIR"
+    if ! ensure_paimon_vindex; then
+        return 1
+    fi
+    echo "Running Python test for 
JavaPyReadWriteTest.test_read_vindex_vector_raw_fallback..."
+    if python -m pytest 
java_py_read_write_test.py::JavaPyReadWriteTest::test_read_vindex_vector_raw_fallback
 -v; then
+        echo -e "${GREEN}✓ Python test completed successfully${NC}"
+        return 0
+    else
+        echo -e "${RED}✗ Python test failed${NC}"
+        return 1
+    fi
+}
+
 run_compact_conflict_test() {
     echo -e "${YELLOW}=== Running Compact Conflict Test (Java Write Base, 
Python Shard Update + Java Compact) ===${NC}"
 
@@ -796,6 +845,7 @@ main() {
     local java_read_result=0
     local pk_dv_result=0
     local btree_index_result=0
+    local btree_raw_fallback_result=0
     local bitmap_index_result=0
     local compressed_global_index_result=0
     local compressed_text_result=0
@@ -804,6 +854,7 @@ main() {
     local lumina_vector_result=0
     local lumina_vector_btree_result=0
     local vindex_vector_result=0
+    local vindex_vector_raw_fallback_result=0
     local compact_conflict_result=0
     local blob_compact_conflict_result=0
     local blob_alter_compact_result=0
@@ -873,6 +924,13 @@ main() {
 
     echo ""
 
+    # Run BTree raw fallback test (Java write indexed + unindexed rows, Python 
read)
+    if ! run_btree_raw_fallback_test; then
+        btree_raw_fallback_result=1
+    fi
+
+    echo ""
+
     # Run Bitmap index test (Java write, Python read)
     if ! run_bitmap_index_test; then
         bitmap_index_result=1
@@ -967,9 +1025,16 @@ main() {
         if ! run_vindex_vector_test; then
             vindex_vector_result=1
         fi
+
+        echo ""
+
+        if ! run_vindex_vector_raw_fallback_test; then
+            vindex_vector_raw_fallback_result=1
+        fi
     else
         echo -e "${YELLOW}⏭ Skipping paimon-vindex Vector Index Test (requires 
Python >= 3.9, current: $PYTHON_VERSION)${NC}"
         vindex_vector_result=0
+        vindex_vector_raw_fallback_result=0
     fi
 
     echo ""
@@ -1073,6 +1138,12 @@ main() {
         echo -e "${RED}✗ BTree Index Test (Java Write, Python Read): 
FAILED${NC}"
     fi
 
+    if [[ $btree_raw_fallback_result -eq 0 ]]; then
+        echo -e "${GREEN}✓ BTree Raw Fallback Test (Java Write, Python Read): 
PASSED${NC}"
+    else
+        echo -e "${RED}✗ BTree Raw Fallback Test (Java Write, Python Read): 
FAILED${NC}"
+    fi
+
     if [[ $bitmap_index_result -eq 0 ]]; then
         echo -e "${GREEN}✓ Bitmap Index Test (Java Write, Python Read): 
PASSED${NC}"
     else
@@ -1145,6 +1216,12 @@ main() {
         echo -e "${RED}✗ paimon-vindex Vector Index Test (Java Write, Python 
Read): FAILED${NC}"
     fi
 
+    if [[ $vindex_vector_raw_fallback_result -eq 0 ]]; then
+        echo -e "${GREEN}✓ paimon-vindex Vector Raw Fallback Test (Java Write, 
Python Read): PASSED${NC}"
+    else
+        echo -e "${RED}✗ paimon-vindex Vector Raw Fallback Test (Java Write, 
Python Read): FAILED${NC}"
+    fi
+
     if [[ $compact_conflict_result -eq 0 ]]; then
         echo -e "${GREEN}✓ Compact Conflict Test (Java Write+Compact, Python 
Read): PASSED${NC}"
     else
@@ -1198,7 +1275,7 @@ main() {
     # Clean up warehouse directory after all tests
     cleanup_warehouse
 
-    if [[ $java_write_result -eq 0 && $python_read_result -eq 0 && 
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 && 
$btree_index_result -eq 0 && $bitmap_index_result -eq 0 && 
$compressed_global_index_result -eq 0 && $compressed_text_result -eq 0 && 
$tantivy_fulltext_result -eq 0 && $lumina_vector_result -eq 0 && 
$lumina_vector_btree_result -eq 0 && $vindex_vector_result -eq 0 && 
$compact_conflict_result -eq 0 && $blob_compact_conflict_result -eq 0 && $blob_ 
[...]
+    if [[ $java_write_result -eq 0 && $python_read_result -eq 0 && 
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 && 
$btree_index_result -eq 0 && $btree_raw_fallback_result -eq 0 && 
$bitmap_index_result -eq 0 && $compressed_global_index_result -eq 0 && 
$compressed_text_result -eq 0 && $tantivy_fulltext_result -eq 0 && 
$lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 && 
$vindex_vector_result -eq 0 && $vindex_vector_raw_fallback_result -eq 0 && [...]
         echo -e "${GREEN}🎉 All tests passed! Java-Python interoperability 
verified.${NC}"
         return 0
     else
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 93b7b92b49..2b24bc1be4 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -90,6 +90,12 @@ class GlobalIndexColumnUpdateAction(str, Enum):
     DROP_PARTITION_INDEX = "DROP_PARTITION_INDEX"
 
 
+class GlobalIndexSearchMode(str, Enum):
+    FAST = "fast"
+    FULL = "full"
+    DETAIL = "detail"
+
+
 class CoreOptions:
     """Core options for Paimon tables."""
     # File format constants
@@ -618,6 +624,16 @@ class CoreOptions:
         .with_description("Whether to enable global index for scan.")
     )
 
+    GLOBAL_INDEX_SEARCH_MODE: ConfigOption[GlobalIndexSearchMode] = (
+        ConfigOptions.key("global-index.search-mode")
+        .enum_type(GlobalIndexSearchMode)
+        .default_value(GlobalIndexSearchMode.FAST)
+        .with_description(
+            "Search mode for global index queries. "
+            "Supported values are 'fast', 'full', and 'detail'."
+        )
+    )
+
     GLOBAL_INDEX_THREAD_NUM: ConfigOption[int] = (
         ConfigOptions.key("global-index.thread-num")
         .int_type()
@@ -1109,6 +1125,9 @@ class CoreOptions:
     def global_index_enabled(self, default=None):
         return self.options.get(CoreOptions.GLOBAL_INDEX_ENABLED, default)
 
+    def global_index_search_mode(self):
+        return self.options.get(CoreOptions.GLOBAL_INDEX_SEARCH_MODE)
+
     def global_index_thread_num(self) -> Optional[int]:
         return self.options.get(CoreOptions.GLOBAL_INDEX_THREAD_NUM)
 
diff --git a/paimon-python/pypaimon/globalindex/global_index_coverage.py 
b/paimon-python/pypaimon/globalindex/global_index_coverage.py
new file mode 100644
index 0000000000..9f94f89495
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/global_index_coverage.py
@@ -0,0 +1,145 @@
+# 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.
+
+"""Row ranges covered and not covered by global index files."""
+
+from typing import Collection, Dict, List, Optional, Union
+
+from pypaimon.common.options.core_options import CoreOptions, 
GlobalIndexSearchMode
+from pypaimon.common.options.options import Options
+from pypaimon.common.predicate import Predicate
+from pypaimon.read.push_down_utils import _get_all_fields
+from pypaimon.schema.data_types import DataField
+from pypaimon.utils.range import Range
+
+
+class GlobalIndexCoverage:
+    """Computes global-index coverage by field id."""
+
+    def __init__(
+        self,
+        table,
+        snapshot,
+        partition_filter,
+        index_files: Collection['IndexFileMeta'],
+    ):
+        self._table = table
+        self._snapshot = snapshot
+        self._partition_filter = partition_filter
+        self._coverage_by_field: Dict[int, List[Range]] = {}
+        for index_file in index_files:
+            meta = index_file.global_index_meta
+            if meta is None:
+                continue
+            row_range = Range(meta.row_range_start, meta.row_range_end)
+            self._add_coverage(meta.index_field_id, row_range)
+            if meta.extra_field_ids is not None:
+                for extra_field_id in meta.extra_field_ids:
+                    self._add_coverage(extra_field_id, row_range)
+
+    def unindexed_ranges(
+        self,
+        fields_or_field_id: Union[List[DataField], int],
+        predicate: Optional[Predicate] = None,
+    ) -> List[Range]:
+        if isinstance(fields_or_field_id, int):
+            field_ids = {fields_or_field_id}
+        else:
+            field_by_name = {f.name: f for f in fields_or_field_id}
+            field_ids = set()
+            for name in _get_all_fields(predicate):
+                field = field_by_name.get(name)
+                if field is not None:
+                    field_ids.add(field.id)
+        return self._unindexed_ranges(field_ids)
+
+    def _add_coverage(self, field_id: int, row_range: Range) -> None:
+        self._coverage_by_field.setdefault(field_id, []).append(row_range)
+
+    def _indexed_ranges(self, field_ids: Collection[int]) -> List[Range]:
+        ranges = None
+        for field_id in field_ids:
+            field_ranges = self._coverage_by_field.get(field_id)
+            if not field_ranges:
+                return []
+            field_ranges = Range.sort_and_merge_overlap(field_ranges, True)
+            ranges = field_ranges if ranges is None else Range.and_(ranges, 
field_ranges)
+        if ranges is None:
+            return []
+        return Range.sort_and_merge_overlap(ranges, True)
+
+    def _unindexed_ranges(self, field_ids: Collection[int]) -> List[Range]:
+        search_mode = _global_index_search_mode(self._table)
+        if search_mode == GlobalIndexSearchMode.FAST:
+            return []
+        next_row_id = getattr(self._snapshot, "next_row_id", None)
+        if self._snapshot is None or next_row_id is None:
+            return []
+        if next_row_id <= 0:
+            return []
+
+        if search_mode == GlobalIndexSearchMode.DETAIL:
+            data_ranges = self._data_ranges_by_data_files()
+        else:
+            data_ranges = [Range(0, next_row_id - 1)]
+
+        indexed_ranges = Range.sort_and_merge_overlap(
+            self._indexed_ranges(field_ids), True)
+        unindexed = []
+        for data_range in Range.sort_and_merge_overlap(data_ranges, True):
+            unindexed.extend(data_range.exclude(indexed_ranges))
+        return Range.sort_and_merge_overlap(unindexed, True)
+
+    def _data_ranges_by_data_files(self) -> List[Range]:
+        if hasattr(self._table, "data_ranges_for_global_index_coverage"):
+            return self._table.data_ranges_for_global_index_coverage(
+                self._snapshot,
+                self._partition_filter,
+            )
+
+        manifest_list_manager = getattr(self._table, "manifest_list_manager", 
None)
+        if manifest_list_manager is None:
+            from pypaimon.manifest.manifest_list_manager import 
ManifestListManager
+            manifest_list_manager = ManifestListManager(self._table)
+        manifest_files = manifest_list_manager.read_all(self._snapshot)
+        from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+        manager = ManifestFileManager(self._table)
+        entries = manager.read_entries_parallel(
+            manifest_files,
+            self._entry_matches_partition,
+            max_workers=self._table.options.scan_manifest_parallelism(),
+        )
+        data_ranges = []
+        for entry in entries:
+            row_range = entry.file.row_id_range()
+            if row_range is not None:
+                data_ranges.append(row_range)
+        return data_ranges
+
+    def _entry_matches_partition(self, entry) -> bool:
+        if self._partition_filter is None:
+            return True
+        return self._partition_filter.test(entry.partition)
+
+
+def _global_index_search_mode(table):
+    options = getattr(table, "options", None)
+    if options is None:
+        return GlobalIndexSearchMode.FAST
+    if hasattr(options, "global_index_search_mode"):
+        return options.global_index_search_mode()
+    return CoreOptions(Options.from_none()).global_index_search_mode()
diff --git a/paimon-python/pypaimon/globalindex/global_index_scanner.py 
b/paimon-python/pypaimon/globalindex/global_index_scanner.py
index 23af17e405..7f2470d382 100644
--- a/paimon-python/pypaimon/globalindex/global_index_scanner.py
+++ b/paimon-python/pypaimon/globalindex/global_index_scanner.py
@@ -27,6 +27,7 @@ from pypaimon.globalindex.global_index_result import 
GlobalIndexResult
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.options.options import Options
 from pypaimon.common.predicate import Predicate
+from pypaimon.globalindex.global_index_coverage import GlobalIndexCoverage
 from pypaimon.read.push_down_utils import _get_all_fields
 from pypaimon.schema.data_types import DataField
 from pypaimon.utils.range import Range
@@ -43,11 +44,20 @@ class GlobalIndexScanner:
         index_files: Collection['IndexFileMeta'],
         thread_num: Optional[int] = None,
         options: Optional[CoreOptions] = None,
+        table=None,
+        snapshot=None,
+        partition_filter=None,
     ):
         self._options = options or CoreOptions(Options.from_none())
         self._executor = ThreadPoolExecutor(
             max_workers=thread_num or 32
         )
+        self._fields = fields
+        self._coverage = (
+            GlobalIndexCoverage(table, snapshot, partition_filter, index_files)
+            if table is not None
+            else None
+        )
         self._evaluator = self._create_evaluator(
             fields, file_io, index_path, index_files
         )
@@ -59,17 +69,10 @@ class GlobalIndexScanner:
             if global_index_meta is None:
                 continue
 
-            field_id = global_index_meta.index_field_id
             index_type = index_file.index_type
-
-            if field_id not in index_metas:
-                index_metas[field_id] = {}
-            if index_type not in index_metas[field_id]:
-                index_metas[field_id][index_type] = {}
-
-            range_key = Range(global_index_meta.row_range_start, 
global_index_meta.row_range_end)
-            if range_key not in index_metas[field_id][index_type]:
-                index_metas[field_id][index_type][range_key] = []
+            field_ids = [global_index_meta.index_field_id]
+            if global_index_meta.extra_field_ids is not None:
+                field_ids.extend(global_index_meta.extra_field_ids)
 
             io_meta = GlobalIndexIOMeta(
                 file_name=index_file.file_name,
@@ -77,7 +80,17 @@ class GlobalIndexScanner:
                 metadata=global_index_meta.index_meta,
                 external_path=index_file.external_path,
             )
-            index_metas[field_id][index_type][range_key].append(io_meta)
+            range_key = Range(
+                global_index_meta.row_range_start,
+                global_index_meta.row_range_end)
+            for field_id in field_ids:
+                if field_id not in index_metas:
+                    index_metas[field_id] = {}
+                if index_type not in index_metas[field_id]:
+                    index_metas[field_id][index_type] = {}
+                if range_key not in index_metas[field_id][index_type]:
+                    index_metas[field_id][index_type][range_key] = []
+                index_metas[field_id][index_type][range_key].append(io_meta)
 
         executor = self._executor
         options = self._options
@@ -105,13 +118,17 @@ class GlobalIndexScanner:
         if index_files is not None:
             if len(index_files) == 0:
                 return None
+            core_options = _core_options(table)
             return GlobalIndexScanner(
                 fields=table.fields,
                 file_io=table.file_io,
                 
index_path=table.path_factory().global_index_path_factory().index_path(),
                 index_files=index_files,
-                thread_num=table.options.global_index_thread_num(),
-                options=table.options,
+                thread_num=core_options.global_index_thread_num(),
+                options=core_options,
+                table=table,
+                snapshot=_resolve_snapshot(table, snapshot),
+                partition_filter=partition_filter,
             )
 
         # Scan index files from snapshot using partition_filter and predicate
@@ -129,29 +146,47 @@ class GlobalIndexScanner:
             global_index_meta = entry.index_file.global_index_meta
             if global_index_meta is None:
                 return False
-            return global_index_meta.index_field_id in filter_field_ids
+            if global_index_meta.index_field_id in filter_field_ids:
+                return True
+            if global_index_meta.extra_field_ids is not None:
+                return any(
+                    field_id in filter_field_ids
+                    for field_id in global_index_meta.extra_field_ids
+                )
+            return False
 
         if snapshot is None:
-            snapshot = table.snapshot_manager().get_latest_snapshot()
+            snapshot = _resolve_snapshot(table, None)
         index_file_handler = IndexFileHandler(table=table)
         entries = index_file_handler.scan(snapshot, index_file_filter)
         scanned_index_files = [entry.index_file for entry in entries]
 
         if len(scanned_index_files) == 0:
             return None
+        core_options = _core_options(table)
         return GlobalIndexScanner(
             fields=table.fields,
             file_io=table.file_io,
             
index_path=table.path_factory().global_index_path_factory().index_path(),
             index_files=scanned_index_files,
-            thread_num=table.options.global_index_thread_num(),
-            options=table.options,
+            thread_num=core_options.global_index_thread_num(),
+            options=core_options,
+            table=table,
+            snapshot=snapshot,
+            partition_filter=partition_filter,
         )
 
     def scan(self, predicate: Optional[Predicate]) -> 
Optional[GlobalIndexResult]:
         """Scan the global index with the given predicate."""
         return self._evaluator.evaluate(predicate)
 
+    def unindexed_rows(self, predicate: Optional[Predicate]) -> 
GlobalIndexResult:
+        """Return coarse row ids not covered by global indexes."""
+        if self._coverage is None:
+            return GlobalIndexResult.create_empty()
+        return GlobalIndexResult.from_ranges(
+            self._coverage.unindexed_ranges(self._fields, predicate))
+
     def close(self):
         """Close the scanner and release resources."""
         self._evaluator.close()
@@ -164,6 +199,39 @@ class GlobalIndexScanner:
         self.close()
 
 
+def _resolve_snapshot(table, snapshot):
+    if snapshot is not None:
+        return snapshot
+    snapshot_manager = table.snapshot_manager()
+    if snapshot_manager is None:
+        return None
+    try:
+        from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+        table_options = getattr(table.table_schema, "options", {})
+        scan_keys = getattr(TimeTravelUtil, "SCAN_KEYS", None)
+        if scan_keys is None:
+            from pypaimon.snapshot.time_travel_util import SCAN_KEYS as 
scan_keys
+        has_time_travel = any(key in table_options for key in scan_keys)
+        resolved = TimeTravelUtil.try_travel_to_snapshot(
+            Options(table.table_schema.options),
+            table.tag_manager(),
+            snapshot_manager,
+        )
+        if resolved is not None and (
+                has_time_travel or getattr(resolved, "next_row_id", None) is 
not None):
+            return resolved
+    except Exception:
+        pass
+    return snapshot_manager.get_latest_snapshot()
+
+
+def _core_options(table):
+    options = getattr(table, "options", None)
+    if options is None:
+        return CoreOptions(Options.from_none())
+    return options
+
+
 def _create_readers(file_io, index_path, index_type_metas, field, 
executor=None, options=None):
     """Create readers for a specific field, dispatched by index_type.
 
diff --git a/paimon-python/pypaimon/read/read_builder.py 
b/paimon-python/pypaimon/read/read_builder.py
index 5537c97dd6..eea670db53 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -44,12 +44,17 @@ class ReadBuilder:
         # in ``read_type()`` and downstream consumers.
         self._projection: Optional[List[str]] = None
         self._nested_paths: Optional[List[List[int]]] = None
+        self._partition_filter: Optional[Predicate] = None
         self._limit: Optional[int] = None
 
     def with_filter(self, predicate: Predicate) -> 'ReadBuilder':
         self._predicate = predicate
         return self
 
+    def with_partition_filter(self, partition_filter: Predicate) -> 
'ReadBuilder':
+        self._partition_filter = partition_filter
+        return self
+
     def with_projection(self, projection: List[str]) -> 'ReadBuilder':
         """Project to the given column names.
 
@@ -77,7 +82,8 @@ class ReadBuilder:
         return TableScan(
             table=self.table,
             predicate=self._predicate,
-            limit=self._limit
+            limit=self._limit,
+            partition_predicate=self._partition_filter,
         )
 
     def new_read(self) -> TableRead:
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index e5f4e332a1..3facb2f7d5 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -384,7 +384,10 @@ class FileScanner:
             if scanner is None:
                 return None
             with scanner:
-                return scanner.scan(self.predicate)
+                result = scanner.scan(self.predicate)
+                if result is None:
+                    return None
+                return result.or_(scanner.unindexed_rows(self.predicate))
         except Exception:
             return None
 
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 36568c618f..b2fcaa8604 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -33,13 +33,15 @@ class TableScan:
         self,
         table,
         predicate: Optional[Predicate],
-        limit: Optional[int]
+        limit: Optional[int],
+        partition_predicate: Optional[Predicate] = None,
     ):
         from pypaimon.table.file_store_table import FileStoreTable
 
         self.table: FileStoreTable = table
         self.predicate = predicate
         self.limit = limit
+        self.partition_predicate = partition_predicate
         self.file_scanner = self._create_file_scanner()
 
     def plan(self) -> Plan:
@@ -79,7 +81,11 @@ class TableScan:
             earliest_snapshot = snapshot_manager.try_get_earliest_snapshot()
             latest_snapshot = snapshot_manager.get_latest_snapshot()
             if earliest_snapshot is None or latest_snapshot is None:
-                return FileScanner(self.table, lambda: ([], None))
+                return FileScanner(
+                    self.table,
+                    lambda: ([], None),
+                    partition_predicate=self.partition_predicate,
+                )
             start_timestamp = int(ts[0])
             end_timestamp = int(ts[1])
             if start_timestamp >= end_timestamp:
@@ -87,7 +93,11 @@ class TableScan:
                     "Ending timestamp %s should be >= starting timestamp %s." 
% (end_timestamp, start_timestamp))
             if (start_timestamp == end_timestamp or start_timestamp > 
latest_snapshot.time_millis
                     or end_timestamp < earliest_snapshot.time_millis):
-                return FileScanner(self.table, lambda: ([], None))
+                return FileScanner(
+                    self.table,
+                    lambda: ([], None),
+                    partition_predicate=self.partition_predicate,
+                )
 
             starting_snapshot = 
snapshot_manager.earlier_or_equal_time_mills(start_timestamp)
             earliest_snapshot = snapshot_manager.try_get_earliest_snapshot()
@@ -118,7 +128,13 @@ class TableScan:
                     manifests.extend(manifest_files)
                 return manifests, end_snapshot
 
-            return FileScanner(self.table, incremental_manifest, 
self.predicate, self.limit)
+            return FileScanner(
+                self.table,
+                incremental_manifest,
+                self.predicate,
+                self.limit,
+                partition_predicate=self.partition_predicate,
+            )
 
         if has_time_travel:
             def time_travel_manifest_scanner():
@@ -135,7 +151,8 @@ class TableScan:
                 self.table,
                 time_travel_manifest_scanner,
                 self.predicate,
-                self.limit
+                self.limit,
+                partition_predicate=self.partition_predicate,
             )
 
         def all_manifests():
@@ -146,7 +163,8 @@ class TableScan:
             self.table,
             all_manifests,
             self.predicate,
-            self.limit
+            self.limit,
+            partition_predicate=self.partition_predicate,
         )
 
     def with_shard(self, idx_of_this_subtask, number_of_para_subtasks) -> 
'TableScan':
diff --git a/paimon-python/pypaimon/table/source/batch_vector_search_builder.py 
b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
index f987680a36..199759487b 100644
--- a/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
+++ b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
@@ -123,5 +123,6 @@ class 
BatchVectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl,
             self._vector_column,
             self._query_vectors,
             filter_=self._filter,
+            partition_filter=self._partition_filter,
             options=self._options,
         )
diff --git a/paimon-python/pypaimon/table/source/vector_search_builder.py 
b/paimon-python/pypaimon/table/source/vector_search_builder.py
index f985471929..25b2d2e4c5 100644
--- a/paimon-python/pypaimon/table/source/vector_search_builder.py
+++ b/paimon-python/pypaimon/table/source/vector_search_builder.py
@@ -216,6 +216,7 @@ class AbstractVectorSearchBuilderImpl:
             self._vector_column,
             filter_=self._filter,
             partition_filter=self._partition_filter,
+            options=self._options,
         )
 
 
@@ -245,5 +246,6 @@ class 
VectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl, VectorSearchBuild
             self._vector_column,
             self._query_vector,
             filter_=self._filter,
+            partition_filter=self._partition_filter,
             options=self._options,
         )
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index 566b093cd6..9656308071 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -25,6 +25,12 @@ from pypaimon.globalindex.global_index_result import 
GlobalIndexResult
 from pypaimon.globalindex.offset_global_index_reader import 
OffsetGlobalIndexReader
 from pypaimon.globalindex.vector_search import VectorSearch
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
+from pypaimon.table.source.vector_search_split import (
+    IndexVectorSearchSplit,
+    RawVectorSearchSplit,
+)
+from pypaimon.utils.range import Range
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
 
 class VectorSearchRead(ABC):
@@ -56,18 +62,27 @@ class BatchVectorSearchRead(ABC):
 class AbstractVectorSearchReadImpl:
     """Base implementation for vector search reads."""
 
-    def __init__(self, table, limit, vector_column, filter_=None, 
options=None):
+    def __init__(
+        self,
+        table,
+        limit,
+        vector_column,
+        filter_=None,
+        partition_filter=None,
+        options=None,
+    ):
         self._table = table
         self._limit = limit
         self._vector_column = vector_column
         self._filter = filter_
+        self._partition_filter = partition_filter
         self._options = dict(options or {})
 
-    def _pre_filter(self, splits):
-        # type: (list) -> Optional[RoaringBitmap64]
-        """Evaluate the scalar filter against scalar global indexes to produce 
a row-id bitmap."""
+    def _pre_filters(self, splits):
+        # type: (list) -> List[RoaringBitmap64]
+        """Evaluate scalar indexes and return one include bitmap per index 
split."""
         if self._filter is None:
-            return None
+            return []
 
         # Collect scalar index files across splits, deduplicated by file name.
         seen = set()
@@ -79,18 +94,77 @@ class AbstractVectorSearchReadImpl:
                 seen.add(index_file.file_name)
                 scalar_files.append(index_file)
 
+        if not scalar_files:
+            return _empty_bitmaps(len(splits))
+
+        from pypaimon.globalindex.global_index_scanner import 
GlobalIndexScanner
+        scanner = GlobalIndexScanner.create(
+            self._table,
+            index_files=scalar_files,
+            partition_filter=self._partition_filter,
+        )
+        if scanner is None:
+            return _empty_bitmaps(len(splits))
+        try:
+            result = scanner.scan(self._filter)
+            if result is None:
+                return _empty_bitmaps(len(splits))
+            matched_rows = result.results()
+        finally:
+            scanner.close()
+
+        include_row_ids = []
+        for split in splits:
+            split_rows = _bitmap_of_range(
+                Range(split.row_range_start, split.row_range_end))
+            include_row_ids.append(RoaringBitmap64.and_(matched_rows, 
split_rows))
+        return include_row_ids
+
+    def _pre_filter(self, splits):
+        # Backwards-compatible helper used by older tests/callers.
+        pre_filters = self._pre_filters(splits)
+        if not pre_filters:
+            return None
+        merged = RoaringBitmap64()
+        for bitmap in pre_filters:
+            merged = RoaringBitmap64.or_(merged, bitmap)
+        return merged
+
+    def _raw_pre_filter(self, splits):
+        if self._filter is None:
+            return None
+        raw_rows = _bitmap_of_ranges(_raw_row_ranges(splits))
+        if raw_rows.is_empty():
+            return None
+
+        seen = set()
+        scalar_files = []
+        for split in splits:
+            for index_file in split.scalar_index_files:
+                if index_file.file_name in seen:
+                    continue
+                seen.add(index_file.file_name)
+                scalar_files.append(index_file)
         if not scalar_files:
             return None
 
         from pypaimon.globalindex.global_index_scanner import 
GlobalIndexScanner
-        scanner = GlobalIndexScanner.create(self._table, 
index_files=scalar_files)
+        scanner = GlobalIndexScanner.create(
+            self._table,
+            index_files=scalar_files,
+            partition_filter=self._partition_filter,
+        )
         if scanner is None:
             return None
         try:
             result = scanner.scan(self._filter)
             if result is None:
                 return None
-            return result.results()
+            include = result.results()
+            include = RoaringBitmap64.or_(
+                include,
+                scanner.unindexed_rows(self._filter).results())
+            return RoaringBitmap64.and_(include, raw_rows)
         finally:
             scanner.close()
 
@@ -136,28 +210,90 @@ class AbstractVectorSearchReadImpl:
         future.add_done_callback(lambda _: reader.close())
         return future
 
+    def _read_raw_search(self, raw_row_ranges, pre_filter, query_vector, 
index_type=None):
+        raw_row_ranges = Range.sort_and_merge_overlap(raw_row_ranges, True)
+        if pre_filter is not None:
+            raw_row_ranges = Range.and_(
+                raw_row_ranges,
+                Range.sort_and_merge_overlap(pre_filter.to_range_list(), True),
+            )
+        if not raw_row_ranges:
+            return DictBasedScoredIndexResult({})
+
+        read_builder = self._table.new_read_builder()
+        if self._partition_filter is not None:
+            read_builder = read_builder.with_partition_filter(
+                self._partition_filter)
+        if self._filter is not None:
+            read_builder = read_builder.with_filter(self._filter)
+        from pypaimon.table.special_fields import SpecialFields
+        projection = [f.name for f in self._table.fields]
+        if SpecialFields.ROW_ID.name not in projection:
+            projection.append(SpecialFields.ROW_ID.name)
+        read_builder = read_builder.with_projection(projection)
+        plan = read_builder.new_scan().with_global_index_result(
+            GlobalIndexResult.from_ranges(raw_row_ranges)).plan()
+        table = read_builder.new_read().to_arrow(plan.splits())
+        if table is None or table.num_rows == 0:
+            return DictBasedScoredIndexResult({})
+
+        row_ids = table.column(SpecialFields.ROW_ID.name).to_pylist()
+        vectors = table.column(self._vector_column.name).to_pylist()
+        metric = _raw_search_metric(
+            self._table, self._vector_column, self._options, index_type)
+        scores = {}
+        for row_id, stored in zip(row_ids, vectors):
+            if stored is None:
+                continue
+            stored_vector = _to_vector_list(stored)
+            if len(stored_vector) != len(query_vector):
+                raise ValueError(
+                    "Query vector dimension mismatch: expected %d, got %d"
+                    % (len(stored_vector), len(query_vector)))
+            scores[row_id] = _compute_score(query_vector, stored_vector, 
metric)
+        return DictBasedScoredIndexResult(scores).top_k(self._limit)
+
 
 class VectorSearchReadImpl(AbstractVectorSearchReadImpl, VectorSearchRead):
     """Implementation for VectorSearchRead."""
 
     def __init__(self, table, limit, vector_column, query_vector, filter_=None,
-                 options=None):
+                 partition_filter=None, options=None):
         super().__init__(table, limit, vector_column,
-                         filter_=filter_, options=options)
+                         filter_=filter_,
+                         partition_filter=partition_filter,
+                         options=options)
         self._query_vector = query_vector
 
     def read(self, splits):
         # type: (List[VectorSearchSplit]) -> GlobalIndexResult
-        if not splits:
+        index_splits, raw_splits = _split_search_splits(splits)
+        if not index_splits and not raw_splits:
             return GlobalIndexResult.create_empty()
 
-        pre_filter = self._pre_filter(splits)
+        indexed = (
+            DictBasedScoredIndexResult({})
+            if not index_splits
+            else self._read_indexed(index_splits, self._query_vector)
+        )
+        raw_result = self._read_raw_search(
+            _raw_row_ranges(raw_splits),
+            self._raw_pre_filter(raw_splits),
+            self._query_vector,
+            _raw_search_index_type(raw_splits),
+        )
+        return indexed.or_(raw_result).top_k(self._limit)
+
+    def _read_indexed(self, splits, query_vector):
+        pre_filters = self._pre_filters(splits)
         futures = [
             self._eval(
                 split.row_range_start, split.row_range_end,
-                split.vector_index_files, self._query_vector, pre_filter
+                split.vector_index_files,
+                query_vector,
+                None if not pre_filters else pre_filters[i]
             )
-            for split in splits
+            for i, split in enumerate(splits)
         ]
 
         wait(futures)
@@ -179,25 +315,30 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
     """Batch vector search read; result ``i`` corresponds to query vector 
``i``."""
 
     def __init__(self, table, limit, vector_column, query_vectors,
-                 filter_=None, options=None):
+                 filter_=None, partition_filter=None, options=None):
         super().__init__(table, limit, vector_column,
-                         filter_=filter_, options=options)
+                         filter_=filter_,
+                         partition_filter=partition_filter,
+                         options=options)
         self._query_vectors = list(query_vectors)
 
     def read_batch(self, splits):
         # type: (List[VectorSearchSplit]) -> List[GlobalIndexResult]
         n = len(self._query_vectors)
-        if not splits:
+        index_splits, raw_splits = _split_search_splits(splits)
+        if not index_splits and not raw_splits:
             return [GlobalIndexResult.create_empty() for _ in range(n)]
 
-        pre_filter = self._pre_filter(splits)
+        pre_filters = self._pre_filters(index_splits)
         futures_by_vector = [
             [
                 self._eval(
                     split.row_range_start, split.row_range_end,
-                    split.vector_index_files, vector, pre_filter
+                    split.vector_index_files,
+                    vector,
+                    None if not pre_filters else pre_filters[i]
                 )
-                for split in splits
+                for i, split in enumerate(index_splits)
             ]
             for vector in self._query_vectors
         ]
@@ -206,6 +347,9 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
             wait(futures)
 
         results = []
+        raw_pre_filter = self._raw_pre_filter(raw_splits)
+        raw_ranges = _raw_row_ranges(raw_splits)
+        raw_index_type = _raw_search_index_type(raw_splits)
         for futures in futures_by_vector:
             merged_scores = {}
             for future in futures:
@@ -215,7 +359,11 @@ class 
BatchVectorSearchReadImpl(AbstractVectorSearchReadImpl,
                     for row_id in split_result.results():
                         if row_id not in merged_scores:
                             merged_scores[row_id] = score_getter(row_id)
-            
results.append(DictBasedScoredIndexResult(merged_scores).top_k(self._limit))
+            indexed = DictBasedScoredIndexResult(merged_scores)
+            vector = self._query_vectors[len(results)]
+            raw = self._read_raw_search(
+                raw_ranges, raw_pre_filter, vector, raw_index_type)
+            results.append(indexed.or_(raw).top_k(self._limit))
         return results
 
 
@@ -238,3 +386,129 @@ def _create_vector_reader(index_type, file_io, 
index_path, index_io_meta_list, o
             file_io, index_path, index_io_meta_list, options
         )
     raise ValueError("Unsupported vector index type: '%s'" % index_type)
+
+
+def _split_search_splits(splits):
+    index_splits = []
+    raw_splits = []
+    for split in splits:
+        if isinstance(split, IndexVectorSearchSplit):
+            index_splits.append(split)
+        elif isinstance(split, RawVectorSearchSplit):
+            raw_splits.append(split)
+    return index_splits, raw_splits
+
+
+def _raw_row_ranges(raw_splits):
+    ranges = []
+    for split in raw_splits:
+        ranges.extend(split.row_ranges)
+    return Range.sort_and_merge_overlap(ranges, True)
+
+
+def _raw_search_index_type(raw_splits):
+    for split in raw_splits:
+        if split.index_type is not None:
+            return split.index_type
+    return None
+
+
+def _empty_bitmaps(size):
+    return [RoaringBitmap64() for _ in range(size)]
+
+
+def _bitmap_of_range(row_range):
+    bitmap = RoaringBitmap64()
+    bitmap.add_range(row_range.from_, row_range.to)
+    return bitmap
+
+
+def _bitmap_of_ranges(ranges):
+    bitmap = RoaringBitmap64()
+    for row_range in ranges:
+        bitmap.add_range(row_range.from_, row_range.to)
+    return bitmap
+
+
+def _to_vector_list(value):
+    if hasattr(value, "to_list"):
+        return value.to_list()
+    if hasattr(value, "as_py"):
+        value = value.as_py()
+    return list(value)
+
+
+def _raw_search_metric(table, vector_column, options, index_type=None):
+    candidates = []
+    field_prefix = "fields.%s." % vector_column.name
+    index_prefix = "%s." % index_type if index_type else None
+    for key in [
+        field_prefix + "distance.metric",
+        field_prefix + "metric",
+        *(([
+            index_prefix + "distance.metric",
+            index_prefix + "metric",
+        ]) if index_prefix is not None else []),
+        "test.vector.metric",
+        "lumina.distance.metric",
+        "distance.metric",
+        "metric",
+    ]:
+        if key in options:
+            candidates.append(options[key])
+    table_options = getattr(getattr(table, "options", None), "options", None)
+    table_map = table_options.to_map() if table_options is not None else {}
+    for key in [
+        field_prefix + "distance.metric",
+        field_prefix + "metric",
+        *(([
+            index_prefix + "distance.metric",
+            index_prefix + "metric",
+        ]) if index_prefix is not None else []),
+        "test.vector.metric",
+        "lumina.distance.metric",
+        "distance.metric",
+        "metric",
+    ]:
+        if key in table_map:
+            candidates.append(table_map[key])
+    if candidates:
+        return _normalize_metric(candidates[0])
+
+    inferred = None
+    for key, value in list(options.items()) + list(table_map.items()):
+        if key.endswith(".distance.metric") or key.endswith(".metric"):
+            metric = _normalize_metric(value)
+            if metric in ("l2", "cosine", "inner_product"):
+                if inferred is not None and inferred != metric:
+                    return "l2"
+                inferred = metric
+    return inferred or "l2"
+
+
+def _normalize_metric(metric):
+    return str(metric).lower().replace("-", "_")
+
+
+def _compute_score(query, stored, metric):
+    if metric == "l2":
+        sum_sq = 0.0
+        for q, s in zip(query, stored):
+            diff = float(q) - float(s)
+            sum_sq += diff * diff
+        return 1.0 / (1.0 + sum_sq)
+    if metric == "cosine":
+        dot = 0.0
+        norm_a = 0.0
+        norm_b = 0.0
+        for q, s in zip(query, stored):
+            q = float(q)
+            s = float(s)
+            dot += q * s
+            norm_a += q * q
+            norm_b += s * s
+        denominator = (norm_a ** 0.5) * (norm_b ** 0.5)
+        return 0.0 if denominator == 0 else dot / denominator
+    if metric == "inner_product":
+        return sum(float(q) * float(s) for q, s in zip(query, stored))
+    raise ValueError("Unknown vector search metric: %s" % metric)
diff --git a/paimon-python/pypaimon/table/source/vector_search_scan.py 
b/paimon-python/pypaimon/table/source/vector_search_scan.py
index 5b8b300d5c..1b6f9bc930 100644
--- a/paimon-python/pypaimon/table/source/vector_search_scan.py
+++ b/paimon-python/pypaimon/table/source/vector_search_scan.py
@@ -20,7 +20,12 @@
 from abc import ABC, abstractmethod
 from collections import defaultdict
 
-from pypaimon.table.source.vector_search_split import VectorSearchSplit
+from pypaimon.globalindex.global_index_coverage import GlobalIndexCoverage
+from pypaimon.table.source.vector_search_split import (
+    IndexVectorSearchSplit,
+    RawVectorSearchSplit,
+    VectorSearchSplit,
+)
 from pypaimon.utils.range import Range
 
 
@@ -48,11 +53,19 @@ class VectorSearchScan(ABC):
 class VectorSearchScanImpl(VectorSearchScan):
     """Implementation for VectorSearchScan."""
 
-    def __init__(self, table, vector_column, filter_=None, 
partition_filter=None):
+    def __init__(
+        self,
+        table,
+        vector_column,
+        filter_=None,
+        partition_filter=None,
+        options=None,
+    ):
         self._table = table
         self._vector_column = vector_column
         self._filter = filter_
         self._partition_filter = partition_filter
+        self._options = dict(options or {})
 
     def scan(self):
         # type: () -> VectorSearchScanPlan
@@ -86,6 +99,13 @@ class VectorSearchScanImpl(VectorSearchScan):
 
         partition_filter = self._partition_filter
 
+        def contains_field(global_index_meta, field_id):
+            if global_index_meta.index_field_id == field_id:
+                return True
+            if global_index_meta.extra_field_ids is not None:
+                return field_id in global_index_meta.extra_field_ids
+            return False
+
         def index_file_filter(entry):
             if partition_filter is not None:
                 if not partition_filter.test(entry.partition):
@@ -96,13 +116,17 @@ class VectorSearchScanImpl(VectorSearchScan):
             field_id = global_index_meta.index_field_id
             if vector_column.id == field_id:
                 return True
-            return field_id in filter_field_ids
+            for filter_field_id in filter_field_ids:
+                if contains_field(global_index_meta, filter_field_id):
+                    return True
+            return False
 
         entries = index_file_handler.scan(snapshot, index_file_filter)
         all_index_files = [entry.index_file for entry in entries]
 
         # Group vector index files by (rowRangeStart, rowRangeEnd).
         vector_by_range = defaultdict(list)
+        vector_index_files = []
         for index_file in all_index_files:
             meta = index_file.global_index_meta
             assert meta is not None
@@ -110,6 +134,12 @@ class VectorSearchScanImpl(VectorSearchScan):
                 continue
             range_key = Range(meta.row_range_start, meta.row_range_end)
             vector_by_range[range_key].append(index_file)
+            vector_index_files.append(index_file)
+
+        vector_index_type = _vector_index_type(vector_column, all_index_files)
+        if vector_index_type is None:
+            vector_index_type = _configured_vector_index_type(
+                self._table, vector_column, self._options)
 
         # For each vector range, attach matching scalar index files whose
         # row range intersects the vector range.
@@ -125,7 +155,7 @@ class VectorSearchScanImpl(VectorSearchScan):
                 if range_key.overlaps(scalar_range):
                     scalar_files.append(index_file)
             splits.append(
-                VectorSearchSplit(
+                IndexVectorSearchSplit(
                     range_key.from_,
                     range_key.to,
                     vector_files,
@@ -133,4 +163,89 @@ class VectorSearchScanImpl(VectorSearchScan):
                 )
             )
 
+        raw_row_ranges = GlobalIndexCoverage(
+            self._table,
+            snapshot,
+            partition_filter,
+            vector_index_files,
+        ).unindexed_ranges(vector_column.id)
+        scalar_index_files = [
+            f for f in all_index_files
+            if f.global_index_meta is not None
+            and f.global_index_meta.index_field_id != vector_column.id
+        ]
+        if self._filter is not None:
+            raw_row_ranges = Range.sort_and_merge_overlap(
+                raw_row_ranges
+                + GlobalIndexCoverage(
+                    self._table,
+                    snapshot,
+                    partition_filter,
+                    scalar_index_files,
+                ).unindexed_ranges(self._table.fields, self._filter),
+                True,
+            )
+        if raw_row_ranges:
+            splits.append(
+                RawVectorSearchSplit(
+                    raw_row_ranges,
+                    _scalar_index_files_for_ranges(
+                        all_index_files,
+                        raw_row_ranges,
+                        vector_column.id,
+                    ),
+                    vector_index_type,
+                )
+            )
+
         return VectorSearchScanPlan(splits)
+
+
+def _has_intersection(ranges, row_range):
+    for r in ranges:
+        if r.overlaps(row_range):
+            return True
+    return False
+
+
+def _scalar_index_files_for_ranges(all_index_files, row_ranges, 
vector_field_id):
+    scalar_files = []
+    for index_file in all_index_files:
+        meta = index_file.global_index_meta
+        if meta is None or meta.index_field_id == vector_field_id:
+            continue
+        if _has_intersection(row_ranges, Range(meta.row_range_start, 
meta.row_range_end)):
+            scalar_files.append(index_file)
+    return scalar_files
+
+
+def _vector_index_type(vector_column, index_files):
+    index_type = None
+    for index_file in index_files:
+        meta = index_file.global_index_meta
+        if meta is None or meta.index_field_id != vector_column.id:
+            continue
+        if index_type is None:
+            index_type = index_file.index_type
+        elif index_type != index_file.index_type:
+            raise ValueError(
+                "Vector column '%s' has multiple index types: %s and %s."
+                % (vector_column.name, index_type, index_file.index_type)
+            )
+    return index_type
+
+
+def _configured_vector_index_type(table, vector_column, options):
+    keys = [
+        "index_type",
+        "index-type",
+        "vector.index-type",
+        "fields.%s.index-type" % vector_column.name,
+    ]
+    table_options = getattr(getattr(table, "options", None), "options", None)
+    table_map = table_options.to_map() if table_options is not None else {}
+    for key in keys:
+        value = options.get(key) or table_map.get(key)
+        if value is not None:
+            return str(value).lower().strip()
+    return None
diff --git a/paimon-python/pypaimon/table/source/vector_search_split.py 
b/paimon-python/pypaimon/table/source/vector_search_split.py
index f8279d5c20..a8f2428688 100644
--- a/paimon-python/pypaimon/table/source/vector_search_split.py
+++ b/paimon-python/pypaimon/table/source/vector_search_split.py
@@ -15,19 +15,55 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Split of vector search."""
+"""Splits of vector search."""
 
 from dataclasses import dataclass, field
-from typing import List
+from typing import List, Optional
 
 from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.utils.range import Range
 
 
-@dataclass
 class VectorSearchSplit:
-    """Split of vector search."""
+    """Base split of vector search.
+
+    The constructor keeps the historical ``VectorSearchSplit(...)`` shape as
+    an alias for an indexed split so existing Python callers/tests continue to
+    work while new code can distinguish split implementations with
+    ``isinstance``.
+    """
+
+    def __new__(
+        cls,
+        row_range_start=None,
+        row_range_end=None,
+        vector_index_files=None,
+        scalar_index_files=None,
+    ):
+        if cls is VectorSearchSplit:
+            return IndexVectorSearchSplit(
+                row_range_start,
+                row_range_end,
+                vector_index_files,
+                scalar_index_files,
+            )
+        return object.__new__(cls)
+
+
+@dataclass
+class IndexVectorSearchSplit(VectorSearchSplit):
+    """Split to read vector index files."""
 
     row_range_start: int
     row_range_end: int
     vector_index_files: List[IndexFileMeta]
     scalar_index_files: List[IndexFileMeta] = field(default_factory=list)
+
+
+@dataclass
+class RawVectorSearchSplit(VectorSearchSplit):
+    """Split to scan raw vectors."""
+
+    row_ranges: List[Range]
+    scalar_index_files: List[IndexFileMeta] = field(default_factory=list)
+    index_type: Optional[str] = None
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py 
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index f940f38a69..691bd16dd9 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -448,6 +448,27 @@ class JavaPyReadWriteTest(unittest.TestCase):
         if sys.version_info[:2] >= (3, 7):
             self._test_index_manifest_inherited_after_write()
 
+    def test_read_btree_raw_fallback(self):
+        table = self.catalog.get_table('default.test_btree_raw_fallback')
+        fast_builder = table.new_read_builder()
+        fast_predicate = fast_builder.new_predicate_builder().equal('k', 'k4')
+        fast_builder.with_filter(fast_predicate)
+        fast_result = fast_builder.new_read().to_arrow(
+            fast_builder.new_scan().plan().splits())
+        self.assertEqual(0, fast_result.num_rows)
+
+        full_table = table.copy({'global-index.search-mode': 'full'})
+        read_builder = full_table.new_read_builder()
+        read_builder.with_filter(
+            read_builder.new_predicate_builder().equal('k', 'k4'))
+        actual = read_builder.new_read().to_arrow(
+            read_builder.new_scan().plan().splits())
+        expected = pa.Table.from_pydict({
+            'k': ['k4'],
+            'v': ['v4'],
+        })
+        self.assertEqual(expected, actual)
+
     def _test_read_btree_index_generic(self, table_name: str, k, k_type):
         table = self.catalog.get_table('default.' + table_name)
         read_builder: ReadBuilder = table.new_read_builder()
@@ -1253,6 +1274,47 @@ class JavaPyReadWriteTest(unittest.TestCase):
         print(f"paimon-vindex vector search matched rows: ids={ids}")
         self.assertIn(0, ids)
 
+    def test_read_vindex_vector_raw_fallback(self):
+        """Test raw fallback for a paimon-vindex vector index built by Java."""
+        if sys.version_info < (3, 9):
+            self.skipTest("paimon-vindex requires Python >= 3.9")
+        try:
+            import paimon_vindex  # noqa: F401
+        except ImportError:
+            self.skipTest("paimon-vindex is not installed")
+
+        table = self.catalog.get_table(
+            'default.test_vindex_vector_raw_fallback')
+        fast_result = (table.new_vector_search_builder()
+                       .with_vector_column('embedding')
+                       .with_query_vector([1.0, 0.0, 0.0, 0.0])
+                       .with_limit(1)
+                       .execute_local())
+        fast_ids = sorted(list(fast_result.results()))
+        print(
+            "paimon-vindex fast-mode vector search matched rows: "
+            f"ids={fast_ids}")
+        self.assertNotIn(3, fast_ids)
+
+        full_table = table.copy({'global-index.search-mode': 'full'})
+        full_result = (full_table.new_vector_search_builder()
+                       .with_vector_column('embedding')
+                       .with_query_vector([1.0, 0.0, 0.0, 0.0])
+                       .with_limit(1)
+                       .execute_local())
+        row_ids = sorted(list(full_result.results()))
+        print(
+            "paimon-vindex full-mode vector search matched rows: "
+            f"ids={row_ids}")
+        self.assertEqual([3], row_ids)
+
+        read_builder = full_table.new_read_builder()
+        scan = read_builder.new_scan().with_global_index_result(full_result)
+        table_read = read_builder.new_read()
+        pa_table = table_read.to_arrow(scan.plan().splits())
+        self.assertEqual(pa_table.num_rows, 1)
+        self.assertEqual([3], pa_table.column('id').to_pylist())
+
     def test_read_lumina_vector_with_btree_filter(self):
         """Vector search + btree scalar pre-filter, using a table that Java
         populated with both a Lumina vector index on `embedding` and a BTree
diff --git a/paimon-python/pypaimon/tests/global_index_test.py 
b/paimon-python/pypaimon/tests/global_index_test.py
index 873e933584..60ca559cda 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -20,8 +20,15 @@ from unittest.mock import patch
 
 import pyarrow as pa
 
+from pypaimon.common.options.core_options import CoreOptions, 
GlobalIndexSearchMode
+from pypaimon.common.options.options import Options
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.index.index_file_meta import IndexFileMeta
 from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.snapshot.snapshot_manager import SnapshotManager
 from pypaimon.tests.data_evolution_test_helpers import (
     BatchModeMixin,
@@ -49,6 +56,193 @@ class GlobalIndexTest(unittest.TestCase):
         self.assertEqual(result.results().cardinality(), 10001)
 
 
+class _CoverageOptions:
+    def __init__(self, mode):
+        self.options = Options({"global-index.search-mode": mode})
+
+    def global_index_search_mode(self):
+        return CoreOptions(self.options).global_index_search_mode()
+
+
+class _CoverageTable:
+    def __init__(self, mode, data_ranges=None):
+        self.options = _CoverageOptions(mode)
+        self.fields = [
+            DataField(0, "id", AtomicType("INT")),
+            DataField(1, "name", AtomicType("STRING")),
+        ]
+        self._data_ranges = data_ranges or []
+
+    def data_ranges_for_global_index_coverage(self, snapshot, 
partition_filter):
+        return self._data_ranges
+
+
+class _CoverageSnapshot:
+    def __init__(self, next_row_id):
+        self.next_row_id = next_row_id
+
+
+def _coverage_index_file(field_id, start, end, extra_field_ids=None):
+    return IndexFileMeta(
+        index_type="btree",
+        file_name="idx-%s-%s-%s" % (field_id, start, end),
+        file_size=1,
+        row_count=end - start + 1,
+        global_index_meta=GlobalIndexMeta(
+            row_range_start=start,
+            row_range_end=end,
+            index_field_id=field_id,
+            extra_field_ids=extra_field_ids,
+            index_meta=b"",
+        ),
+    )
+
+
+class GlobalIndexCoverageTest(unittest.TestCase):
+
+    def test_fast_mode_does_not_return_unindexed_ranges(self):
+        from pypaimon.globalindex.global_index_coverage import 
GlobalIndexCoverage
+
+        table = _CoverageTable(GlobalIndexSearchMode.FAST)
+        coverage = GlobalIndexCoverage(
+            table,
+            _CoverageSnapshot(10),
+            None,
+            [_coverage_index_file(0, 0, 4)],
+        )
+
+        self.assertEqual([], coverage.unindexed_ranges(0))
+
+    def test_full_mode_uses_snapshot_next_row_id(self):
+        from pypaimon.globalindex.global_index_coverage import 
GlobalIndexCoverage
+
+        table = _CoverageTable("full")
+        coverage = GlobalIndexCoverage(
+            table,
+            _CoverageSnapshot(10),
+            None,
+            [_coverage_index_file(0, 0, 4)],
+        )
+
+        self.assertEqual([Range(5, 9)], coverage.unindexed_ranges(0))
+
+    def test_full_mode_intersects_coverage_for_all_predicate_fields(self):
+        from pypaimon.globalindex.global_index_coverage import 
GlobalIndexCoverage
+
+        table = _CoverageTable("full")
+        coverage = GlobalIndexCoverage(
+            table,
+            _CoverageSnapshot(10),
+            None,
+            [
+                _coverage_index_file(0, 0, 9),
+                _coverage_index_file(1, 0, 4),
+            ],
+        )
+        predicate = PredicateBuilder.and_predicates(
+            [
+                Predicate(method="equal", index=0, field="id", literals=[1]),
+                Predicate(method="equal", index=1, field="name", 
literals=["a"]),
+            ]
+        )
+
+        self.assertEqual(
+            [Range(5, 9)],
+            coverage.unindexed_ranges(table.fields, predicate),
+        )
+
+    def test_extra_fields_count_as_index_coverage(self):
+        from pypaimon.globalindex.global_index_coverage import 
GlobalIndexCoverage
+
+        table = _CoverageTable("full")
+        coverage = GlobalIndexCoverage(
+            table,
+            _CoverageSnapshot(10),
+            None,
+            [_coverage_index_file(0, 0, 9, extra_field_ids=[1])],
+        )
+
+        self.assertEqual([], coverage.unindexed_ranges(1))
+
+    def test_detail_mode_uses_table_data_ranges(self):
+        from pypaimon.globalindex.global_index_coverage import 
GlobalIndexCoverage
+
+        table = _CoverageTable("detail", data_ranges=[Range(0, 2), Range(7, 
9)])
+        coverage = GlobalIndexCoverage(
+            table,
+            _CoverageSnapshot(10),
+            None,
+            [_coverage_index_file(0, 0, 4)],
+        )
+
+        self.assertEqual([Range(7, 9)], coverage.unindexed_ranges(0))
+
+
+class GlobalIndexScalarFallbackTest(unittest.TestCase):
+
+    def 
test_eval_global_index_merges_unindexed_rows_when_index_scan_succeeds(self):
+        from pypaimon.read.scanner.file_scanner import FileScanner
+
+        class _Options:
+            def global_index_enabled(self):
+                return True
+
+        class _Table:
+            options = _Options()
+
+        predicate = Predicate(method="equal", index=0, field="id", 
literals=[1])
+        scanner = FileScanner.__new__(FileScanner)
+        scanner.predicate = predicate
+        scanner.partition_key_predicate = None
+        scanner.table = _Table()
+
+        index_result = GlobalIndexResult.from_range(Range(1, 1))
+        unindexed = GlobalIndexResult.from_range(Range(5, 6))
+        fake_scanner = unittest.mock.MagicMock()
+        fake_scanner.scan.return_value = index_result
+        fake_scanner.unindexed_rows.return_value = unindexed
+        fake_scanner.__enter__.return_value = fake_scanner
+        fake_scanner.__exit__.return_value = None
+
+        with unittest.mock.patch(
+                
"pypaimon.globalindex.global_index_scanner.GlobalIndexScanner.create",
+                return_value=fake_scanner):
+            result = scanner._eval_global_index(snapshot=object())
+
+        self.assertEqual(
+            [Range(1, 1), Range(5, 6)],
+            result.results().to_range_list(),
+        )
+
+    def test_eval_global_index_keeps_none_as_full_scan(self):
+        from pypaimon.read.scanner.file_scanner import FileScanner
+
+        class _Options:
+            def global_index_enabled(self):
+                return True
+
+        class _Table:
+            options = _Options()
+
+        scanner = FileScanner.__new__(FileScanner)
+        scanner.predicate = Predicate(
+            method="equal", index=0, field="id", literals=[1])
+        scanner.partition_key_predicate = None
+        scanner.table = _Table()
+
+        fake_scanner = unittest.mock.MagicMock()
+        fake_scanner.scan.return_value = None
+        fake_scanner.__enter__.return_value = fake_scanner
+        fake_scanner.__exit__.return_value = None
+
+        with unittest.mock.patch(
+                
"pypaimon.globalindex.global_index_scanner.GlobalIndexScanner.create",
+                return_value=fake_scanner):
+            result = scanner._eval_global_index(snapshot=object())
+
+        self.assertIsNone(result)
+
+
 class PlanSnapshotFetchRegressionTest(
         BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
 
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index f2b453346e..fe63890a42 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -44,6 +44,7 @@ from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.source.vector_search_builder import VectorSearchBuilderImpl
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+from pypaimon.utils.range import Range
 
 
 # ----------------------------- table stubs ---------------------------------
@@ -122,6 +123,15 @@ def _entry(partition_row, field_id, index_type, file_name,
 def _patch_snapshot(testcase, entries):
     """Stub IndexFileHandler.scan + snapshot resolution."""
 
+    mock.patch.stopall()
+    for attr in ("_scan_patch", "_travel_patch"):
+        patcher = getattr(testcase, attr, None)
+        if patcher is not None:
+            try:
+                patcher.stop()
+            except RuntimeError:
+                pass
+
     def _scan(snapshot, entry_filter=None):
         if entry_filter is None:
             return list(entries)
@@ -1005,6 +1015,174 @@ class VectorSearchFilterTest(unittest.TestCase):
         self.assertEqual("oss://bucket/id-btree-0.index",
                          captured_io_metas[0][0].external_path)
 
+    def test_full_mode_scan_adds_raw_split_for_unindexed_vector_rows(self):
+        from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.common.options.options import Options
+        from pypaimon.table.source.vector_search_split import (
+            IndexVectorSearchSplit,
+            RawVectorSearchSplit,
+        )
+
+        class _Options:
+            options = Options({"global-index.search-mode": "full"})
+
+            def global_index_search_mode(self_inner):
+                return 
CoreOptions(self_inner.options).global_index_search_mode()
+
+        class _Snapshots:
+            def get_latest_snapshot(self_inner):
+                return types.SimpleNamespace(next_row_id=10)
+
+        table = _StubTable(fields=[self.id_field, self.embedding_field],
+                           entries=[self.entries[0]])
+        table.options = _Options()
+        table.snapshot_manager = lambda: _Snapshots()
+        self._scan_patch.stop()
+        self._travel_patch.stop()
+        _patch_snapshot(self, [self.entries[0]])
+        self._travel_patch.stop()
+
+        splits = (
+            VectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vector([1.0, 0.0, 0.0, 0.0])
+            .with_limit(3)
+            .new_vector_search_scan()
+            .scan()
+            .splits()
+        )
+
+        self.assertEqual(2, len(splits))
+        self.assertTrue(any(isinstance(s, IndexVectorSearchSplit)
+                            for s in splits))
+        raw = [s for s in splits if isinstance(s, RawVectorSearchSplit)]
+        self.assertEqual(1, len(raw))
+        self.assertEqual([Range(5, 9)], raw[0].row_ranges)
+
+    def test_full_mode_scan_adds_raw_split_for_uncovered_scalar_filter(self):
+        from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.common.options.options import Options
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        class _Options:
+            options = Options({"global-index.search-mode": "full"})
+
+            def global_index_search_mode(self_inner):
+                return 
CoreOptions(self_inner.options).global_index_search_mode()
+
+        class _Snapshots:
+            def get_latest_snapshot(self_inner):
+                return types.SimpleNamespace(next_row_id=10)
+
+        # Vector index covers all rows, but there is no scalar id index, so
+        # full mode must produce a raw split for the scalar-filtered path.
+        table = _StubTable(
+            fields=[self.id_field, self.embedding_field],
+            entries=[
+                _entry(None, field_id=1,
+                       index_type="lumina-vector-ann",
+                       file_name="vec-all.index",
+                       row_range_start=0,
+                       row_range_end=9)
+            ],
+        )
+        table.options = _Options()
+        table.snapshot_manager = lambda: _Snapshots()
+        self._scan_patch.stop()
+        self._travel_patch.stop()
+        _patch_snapshot(self, table._entries)
+        self._travel_patch.stop()
+        filter_pred = Predicate(method="greaterOrEqual", index=0, field="id",
+                                literals=[5])
+
+        splits = (
+            VectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vector([1.0, 0.0, 0.0, 0.0])
+            .with_limit(3)
+            .with_filter(filter_pred)
+            .new_vector_search_scan()
+            .scan()
+            .splits()
+        )
+
+        raw = [s for s in splits if isinstance(s, RawVectorSearchSplit)]
+        self.assertEqual(1, len(raw))
+        self.assertEqual([Range(0, 9)], raw[0].row_ranges)
+
+    def test_scan_threads_builder_options_to_raw_split_index_type(self):
+        from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.common.options.options import Options
+        from pypaimon.table.source.vector_search_split import 
RawVectorSearchSplit
+
+        class _Options:
+            options = Options({"global-index.search-mode": "full"})
+
+            def global_index_search_mode(self_inner):
+                return 
CoreOptions(self_inner.options).global_index_search_mode()
+
+        class _Snapshots:
+            def get_latest_snapshot(self_inner):
+                return types.SimpleNamespace(next_row_id=10)
+
+        table = _StubTable(fields=[self.id_field, self.embedding_field],
+                           entries=[])
+        table.options = _Options()
+        table.snapshot_manager = lambda: _Snapshots()
+        self._scan_patch.stop()
+        self._travel_patch.stop()
+        _patch_snapshot(self, [])
+        self._travel_patch.stop()
+
+        splits = (
+            VectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vector([1.0, 0.0, 0.0, 0.0])
+            .with_limit(3)
+            .with_option("index-type", "ivf-flat")
+            .new_vector_search_scan()
+            .scan()
+            .splits()
+        )
+
+        raw = [s for s in splits if isinstance(s, RawVectorSearchSplit)]
+        self.assertEqual(1, len(raw))
+        self.assertEqual("ivf-flat", raw[0].index_type)
+
+    def test_scan_attaches_scalar_index_when_filter_hits_extra_field(self):
+        id_name_index = _entry(None, field_id=2, index_type="btree",
+                               file_name="name-id.index",
+                               row_range_start=0,
+                               row_range_end=9)
+        id_name_index.index_file.global_index_meta.extra_field_ids = [0]
+        table = _StubTable(fields=[
+            self.id_field,
+            self.embedding_field,
+            _field(2, "name", "STRING"),
+        ], entries=[
+            self.entries[0],
+            id_name_index,
+        ])
+        self._scan_patch.stop()
+        self._travel_patch.stop()
+        _patch_snapshot(self, table._entries)
+        filter_pred = Predicate(method="equal", index=0, field="id",
+                                literals=[5])
+
+        splits = (
+            VectorSearchBuilderImpl(table)
+            .with_vector_column("embedding")
+            .with_query_vector([1.0, 0.0, 0.0, 0.0])
+            .with_limit(3)
+            .with_filter(filter_pred)
+            .new_vector_search_scan()
+            .scan()
+            .splits()
+        )
+
+        self.assertEqual(["name-id.index"],
+                         [f.file_name for f in splits[0].scalar_index_files])
+
 
 class VectorSearchMultiShardScalarTest(unittest.TestCase):
     """Scalar pre-filter across multiple btree shards of the same field.
@@ -1016,6 +1194,9 @@ class VectorSearchMultiShardScalarTest(unittest.TestCase):
       - An empty first shard does NOT short-circuit subsequent shards.
     """
 
+    def tearDown(self):
+        mock.patch.stopall()
+
     def test_hit_only_in_later_shard_returns_global_row_id(self):
         from pypaimon.globalindex.global_index_result import GlobalIndexResult
         from pypaimon.globalindex.global_index_scanner import (
@@ -1202,6 +1383,95 @@ class 
VectorSearchMultiShardScalarTest(unittest.TestCase):
         self.assertIsNotNone(result)
         self.assertEqual([3], sorted(list(result.results())))
 
+    def test_scanner_reports_unindexed_rows_for_full_mode(self):
+        from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.common.options.options import Options
+        from pypaimon.globalindex.global_index_scanner import (
+            GlobalIndexScanner,
+        )
+
+        class _Options:
+            options = Options({"global-index.search-mode": "full"})
+
+            def global_index_search_mode(self_inner):
+                return 
CoreOptions(self_inner.options).global_index_search_mode()
+
+            def global_index_thread_num(self_inner):
+                return 32
+
+        class _Snapshots:
+            def get_latest_snapshot(self_inner):
+                return types.SimpleNamespace(next_row_id=10)
+
+        id_field = _field(0, "id")
+        emb_field = _field(1, "embedding", "FLOAT")
+        indexed = _entry(None, field_id=0, index_type="btree",
+                         file_name="id-0.index",
+                         row_range_start=0, row_range_end=4).index_file
+        table = _StubTable(fields=[id_field, emb_field], entries=[])
+        table.options = _Options()
+        table.snapshot_manager = lambda: _Snapshots()
+
+        scanner = GlobalIndexScanner.create(table, index_files=[indexed])
+        try:
+            result = scanner.unindexed_rows(
+                Predicate(method="equal", index=0, field="id", literals=[7]))
+        finally:
+            scanner.close()
+
+        self.assertEqual([Range(5, 9)], result.results().to_range_list())
+
+    def test_scanner_create_selects_extra_field_indexes(self):
+        from pypaimon.globalindex.global_index_scanner import (
+            GlobalIndexScanner,
+        )
+
+        name_field = _field(0, "name", "STRING")
+        id_field = _field(1, "id")
+        emb_field = _field(2, "embedding", "FLOAT")
+        multi = _entry(None, field_id=0, index_type="btree",
+                       file_name="name-id.index",
+                       row_range_start=0, row_range_end=9).index_file
+        multi.global_index_meta.extra_field_ids = [1]
+        table = _StubTable(
+            fields=[name_field, id_field, emb_field],
+            entries=[
+                IndexManifestEntry(kind=0, partition=None,
+                                   bucket=0, index_file=multi)
+            ],
+        )
+        _patch_snapshot(self, table._entries)
+
+        class _StubBTreeReader:
+            def __init__(self_inner, key_serializer, file_io, index_path,
+                         io_meta):
+                pass
+
+            def visit_equal(self_inner, literal):
+                return GlobalIndexResult.create_empty()
+
+            def close(self_inner):
+                pass
+
+        with mock.patch(
+                
"pypaimon.globalindex.btree.lazy_filtered_btree_reader.BTreeIndexReader",
+                _StubBTreeReader):
+            with mock.patch(
+                    
"pypaimon.globalindex.sorted_file_global_index_reader.SortedIndexFileMeta.deserialize",
+                    return_value=BTreeIndexMeta(first_key=b'', 
last_key=b'zzzz', has_nulls=False)):
+                scanner = GlobalIndexScanner.create(
+                    table,
+                    predicate=Predicate(method="equal", index=1, field="id",
+                                        literals=[3]),
+                )
+                try:
+                    self.assertIsNotNone(scanner)
+                    readers = scanner._evaluator._readers_function(id_field)
+                    self.assertTrue(readers)
+                finally:
+                    if scanner is not None:
+                        scanner.close()
+
 
 class VectorSearchPartitionedFilterTest(unittest.TestCase):
     """Partitioned-table paths: with_filter auto-split + partition-filter
@@ -1489,6 +1759,159 @@ class VectorSearchManySplitsTest(unittest.TestCase):
         scores = sorted(result.score_getter()(rid) for rid in result.results())
         self.assertEqual(scores, [float(i) for i in range(1190, 1200)])
 
+    def test_read_merges_raw_search_results(self):
+        from pypaimon.globalindex.vector_search_result import (
+            DictBasedScoredIndexResult,
+        )
+        from pypaimon.table.source.vector_search_read import 
VectorSearchReadImpl
+        from pypaimon.table.source.vector_search_split import (
+            IndexVectorSearchSplit,
+            RawVectorSearchSplit,
+        )
+
+        embedding_field = _field(1, "embedding", "FLOAT")
+        entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+                       file_name="vec.index",
+                       row_range_start=0, row_range_end=4)
+        table = _StubTable(fields=[embedding_field], entries=[entry])
+
+        def _fake_create(index_type, file_io, index_path,
+                         index_io_meta_list, options=None):
+            class _FakeReader:
+                def visit_vector_search(self_inner, vs):
+                    return _completed_future(
+                        DictBasedScoredIndexResult({1: 0.1}))
+
+                def close(self_inner):
+                    pass
+
+            return _FakeReader()
+
+        split = IndexVectorSearchSplit(
+            row_range_start=0,
+            row_range_end=4,
+            vector_index_files=[entry.index_file],
+        )
+        raw = RawVectorSearchSplit([Range(5, 9)], [], "lumina-vector-ann")
+
+        with mock.patch(
+                
"pypaimon.table.source.vector_search_read._create_vector_reader",
+                side_effect=_fake_create):
+            reader = VectorSearchReadImpl(
+                table, limit=2, vector_column=embedding_field,
+                query_vector=[1.0], filter_=None)
+            with mock.patch.object(
+                    reader,
+                    "_read_raw_search",
+                    return_value=DictBasedScoredIndexResult({8: 0.9})) as 
raw_read:
+                result = reader.read([split, raw])
+
+        raw_read.assert_called_once()
+        self.assertEqual([1, 8], sorted(list(result.results())))
+
+    def test_read_uses_empty_index_prefilter_when_scalar_index_missing(self):
+        from pypaimon.table.source.vector_search_read import 
VectorSearchReadImpl
+        from pypaimon.table.source.vector_search_split import 
IndexVectorSearchSplit
+
+        id_field = _field(0, "id")
+        embedding_field = _field(1, "embedding", "FLOAT")
+        entry = _entry(None, field_id=1, index_type="lumina-vector-ann",
+                       file_name="vec.index",
+                       row_range_start=0, row_range_end=4)
+        table = _StubTable(fields=[id_field, embedding_field], entries=[entry])
+        filter_pred = Predicate(method="equal", index=0, field="id",
+                                literals=[3])
+        split = IndexVectorSearchSplit(
+            row_range_start=0,
+            row_range_end=4,
+            vector_index_files=[entry.index_file],
+            scalar_index_files=[],
+        )
+
+        reader = VectorSearchReadImpl(
+            table, limit=2, vector_column=embedding_field,
+            query_vector=[1.0], filter_=filter_pred)
+
+        pre_filters = reader._pre_filters([split])
+
+        self.assertEqual(1, len(pre_filters))
+        self.assertEqual(0, pre_filters[0].cardinality())
+
+    def test_raw_search_uses_partition_filter_and_index_type_metric(self):
+        import pyarrow as pa
+
+        from pypaimon.table.source.vector_search_read import 
VectorSearchReadImpl
+
+        id_field = _field(0, "id")
+        embedding_field = _field(1, "embedding", "FLOAT")
+        table = _StubTable(fields=[id_field, embedding_field], entries=[])
+        partition_filter = Predicate(method="equal", index=0, field="pt",
+                                     literals=[1])
+        filter_pred = Predicate(method="greaterOrEqual", index=0, field="id",
+                                literals=[0])
+        calls = {}
+
+        class _Plan:
+            def splits(self_inner):
+                return ["split"]
+
+        class _Scan:
+            def with_global_index_result(self_inner, result):
+                calls["global_index_ranges"] = result.results().to_range_list()
+                return self_inner
+
+            def plan(self_inner):
+                return _Plan()
+
+        class _Read:
+            def to_arrow(self_inner, splits):
+                calls["splits"] = list(splits)
+                return pa.table({
+                    "id": pa.array([5, 6], type=pa.int32()),
+                    "embedding": pa.array([[1.0], [0.0]]),
+                    "_ROW_ID": pa.array([5, 6], type=pa.int64()),
+                })
+
+        class _Builder:
+            def with_partition_filter(self_inner, predicate):
+                calls["partition_filter"] = predicate
+                return self_inner
+
+            def with_filter(self_inner, predicate):
+                calls["filter"] = predicate
+                return self_inner
+
+            def with_projection(self_inner, projection):
+                calls["projection"] = list(projection)
+                return self_inner
+
+            def new_scan(self_inner):
+                return _Scan()
+
+            def new_read(self_inner):
+                return _Read()
+
+        table.new_read_builder = lambda: _Builder()
+        reader = VectorSearchReadImpl(
+            table,
+            limit=1,
+            vector_column=embedding_field,
+            query_vector=[1.0],
+            filter_=filter_pred,
+            partition_filter=partition_filter,
+            options={"ivf-flat.metric": "inner_product"},
+        )
+
+        result = reader._read_raw_search(
+            [Range(5, 6)], None, [1.0], "ivf-flat")
+
+        self.assertIs(partition_filter, calls["partition_filter"])
+        self.assertIs(filter_pred, calls["filter"])
+        self.assertEqual([Range(5, 6)], calls["global_index_ranges"])
+        self.assertIn("_ROW_ID", calls["projection"])
+        self.assertEqual(["split"], calls["splits"])
+        self.assertEqual([5], sorted(list(result.results())))
+
     def tearDown(self):
         mock.patch.stopall()
 
diff --git a/paimon-vector/src/test/java/org/apache/paimon/JavaPyE2ETest.java 
b/paimon-vector/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 033245f9eb..4c8cbfd59c 100644
--- a/paimon-vector/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-vector/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -197,4 +197,121 @@ public class JavaPyE2ETest {
         assertThat(indexEntries.get(0).indexFile().indexType())
                 .isEqualTo(IvfFlatVectorGlobalIndexerFactory.IDENTIFIER);
     }
+
+    @Test
+    @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
+    public void testVindexVectorRawFallbackWrite() throws Exception {
+        String tableName = "test_vindex_vector_raw_fallback";
+        Path tablePath = new Path(warehouse.toString() + "/default.db/" + 
tableName);
+        LocalFileIO fileIO = LocalFileIO.create();
+        if (fileIO.exists(tablePath)) {
+            fileIO.delete(tablePath, true);
+        }
+
+        int dimension = 4;
+
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), new ArrayType(new 
FloatType())},
+                        new String[] {"id", "embedding"});
+
+        Options options = new Options();
+        options.set(PATH, tablePath.toString());
+        options.set(ROW_TRACKING_ENABLED, true);
+        options.set(DATA_EVOLUTION_ENABLED, true);
+        options.set(GLOBAL_INDEX_ENABLED, true);
+        options.setString(
+                IvfFlatVectorGlobalIndexerFactory.IDENTIFIER + ".dimension",
+                String.valueOf(dimension));
+        options.setString(IvfFlatVectorGlobalIndexerFactory.IDENTIFIER + 
".metric", "l2");
+        options.setString(IvfFlatVectorGlobalIndexerFactory.IDENTIFIER + 
".nlist", "2");
+
+        TableSchema tableSchema =
+                SchemaUtils.forceCommit(
+                        new SchemaManager(fileIO, tablePath),
+                        new Schema(
+                                rowType.getFields(),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                options.toMap(),
+                                ""));
+
+        AppendOnlyFileStoreTable table =
+                new AppendOnlyFileStoreTable(
+                        FileIOFinder.find(tablePath),
+                        tablePath,
+                        tableSchema,
+                        CatalogEnvironment.empty());
+
+        float[][] indexedVectors =
+                new float[][] {
+                    new float[] {0.0f, 1.0f, 0.0f, 0.0f},
+                    new float[] {0.0f, 0.0f, 1.0f, 0.0f},
+                    new float[] {0.0f, 0.0f, 0.0f, 1.0f}
+                };
+
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            for (int i = 0; i < indexedVectors.length; i++) {
+                write.write(GenericRow.of(i, new 
GenericArray(indexedVectors[i])));
+            }
+            commit.commit(write.prepareCommit());
+        }
+
+        DataField embeddingField = table.rowType().getField("embedding");
+        Options indexOptions = table.coreOptions().toConfiguration();
+
+        GlobalIndexSingleColumnWriter writer =
+                (GlobalIndexSingleColumnWriter)
+                        GlobalIndexBuilderUtils.createIndexWriter(
+                                table,
+                                IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                embeddingField,
+                                indexOptions);
+
+        for (int i = 0; i < indexedVectors.length; i++) {
+            writer.write(indexedVectors[i], i);
+        }
+
+        List<ResultEntry> entries = writer.finish();
+        assertThat(entries).hasSize(1);
+        assertThat(entries.get(0).rowCount()).isEqualTo(indexedVectors.length);
+
+        Range rowRange = new Range(0, indexedVectors.length - 1);
+        List<IndexFileMeta> indexFiles =
+                GlobalIndexBuilderUtils.toIndexFileMetas(
+                        table.fileIO(),
+                        table.store().pathFactory().globalIndexFileFactory(),
+                        table.coreOptions(),
+                        rowRange,
+                        embeddingField.id(),
+                        IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                        entries);
+
+        DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
+        CommitMessage message =
+                new CommitMessageImpl(
+                        BinaryRow.EMPTY_ROW,
+                        0,
+                        null,
+                        dataIncrement,
+                        CompactIncrement.emptyIncrement());
+        try (BatchTableCommit commit = writeBuilder.newCommit()) {
+            commit.commit(Collections.singletonList(message));
+        }
+
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            write.write(GenericRow.of(3, new GenericArray(new float[] {1.0f, 
0.0f, 0.0f, 0.0f})));
+            commit.commit(write.prepareCommit());
+        }
+
+        List<org.apache.paimon.manifest.IndexManifestEntry> indexEntries =
+                
table.indexManifestFileReader().read(table.latestSnapshot().get().indexManifest());
+        assertThat(indexEntries).hasSize(1);
+        
assertThat(indexEntries.get(0).indexFile().rowCount()).isEqualTo(indexedVectors.length);
+        assertThat(indexEntries.get(0).indexFile().indexType())
+                .isEqualTo(IvfFlatVectorGlobalIndexerFactory.IDENTIFIER);
+    }
 }


Reply via email to