SteNicholas commented on code in PR #490:
URL: https://github.com/apache/paimon-cpp/pull/490#discussion_r4476170949


##########
src/paimon/core/operation/data_evolution_split_read.h:
##########
@@ -97,8 +97,7 @@ class DataEvolutionSplitRead : public AbstractSplitRead {
         const std::shared_ptr<Predicate>& predicate, DeletionVector::Factory 
dv_factory,
         const std::optional<std::vector<Range>>& row_ranges,
         const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
-        const std::map<std::string, std::string>& file_index_options,
-        std::vector<float>* index_scores) const override;
+        const std::map<std::string, std::string>& file_index_options) const 
override;

Review Comment:
   The merge build still has a caller passing ten arguments. The test added on 
`main` by `eb0dd421` passes `/*index_scores=*/nullptr` after 
`file_index_options`. Please sync with `main` and remove that trailing argument 
there too so `paimon-core-test` can compile. Both 
[Clang](https://github.com/apache/paimon-cpp/actions/runs/41948662626/job/126987074702)
 and [GCC 
8](https://github.com/apache/paimon-cpp/actions/runs/41948662640/job/126987074094)
 report the same mismatch.



##########
include/paimon/file_index/file_indexer.h:
##########
@@ -47,6 +47,10 @@ class PAIMON_EXPORT FileIndexer {
         return false;
     }
 
+    /// Create a reader for an empty index entry without serialized bytes.
+    /// Scalar indexers use the default reader. Search indexers must preserve 
their capabilities.
+    virtual std::shared_ptr<FileIndexReader> CreateEmptyReader() const;

Review Comment:
   Inserting `CreateEmptyReader` here changes dispatch for already-built index 
plugins, including scalar plugins that never use search. Clang's vtable dump 
puts `CreateReader` in slot 4 before this change and slot 5 afterwards. Slot 5 
in an old plugin is `CreateWriter`, which has a different signature. Could we 
expose this through a separate capability interface, as with 
`WrittenFileStatsProvider`, or version the plugin ABI and reject incompatible 
binaries at load time? An ABI test built entirely with the new headers misses 
this case.



##########
src/paimon/core/io/file_index_evaluator.cpp:
##########
@@ -52,64 +54,165 @@ namespace paimon {
 class MemoryPool;
 enum class FieldType;
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
+FileIndexEvaluator::FileIndexEvaluator(std::shared_ptr<arrow::Schema> 
data_schema,
+                                       std::shared_ptr<DataFileMeta> file_meta,
+                                       
std::unique_ptr<FileIndexFormat::Reader> format_reader)
+    : data_schema_(std::move(data_schema)),
+      file_meta_(std::move(file_meta)),
+      format_reader_(std::move(format_reader)) {}
+
+Result<std::unique_ptr<FileIndexEvaluator>> FileIndexEvaluator::Create(
     const std::shared_ptr<arrow::Schema>& data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
+    const std::map<std::string, std::string>& options,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
     const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<FileSystem>& file_system,
     const std::shared_ptr<MemoryPool>& pool) {
-    return Evaluate(/*only_use_embedding_index=*/false, data_schema, options, 
predicate,
-                    data_file_path_factory, file_meta, file_system, pool);
+    std::unique_ptr<FileIndexFormat::Reader> format_reader;
+    if (file_meta->row_count >= 0 && file_meta->row_count <= 
RoaringBitmap32::MAX_VALUE) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
+                               ExtractIndexInputStream(path_factory, 
file_meta, file_system));
+        if (input_stream) {
+            PAIMON_ASSIGN_OR_RAISE(format_reader,
+                                   FileIndexFormat::CreateReader(input_stream, 
pool, options));
+        }
+    }
+    return std::unique_ptr<FileIndexEvaluator>(
+        new FileIndexEvaluator(data_schema, file_meta, 
std::move(format_reader)));
 }
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
-    const std::shared_ptr<arrow::Schema>& data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<MemoryPool>& pool) {
-    return Evaluate(/*only_use_embedding_index=*/true, data_schema, options, 
predicate,
-                    /*data_file_path_factory=*/nullptr, file_meta, 
/*file_system=*/nullptr, pool);
+Result<std::vector<std::shared_ptr<FileIndexReader>>> 
FileIndexEvaluator::GetReaders(
+    const std::string& field_name) {
+    auto iter = field_readers_.find(field_name);
+    if (iter != field_readers_.end()) {
+        return iter->second;
+    }
+    std::shared_ptr<arrow::Field> field = 
data_schema_->GetFieldByName(field_name);
+    if (!field) {
+        return Status::Invalid(fmt::format("field '{}' does not exist in data 
schema", field_name));
+    }
+    std::vector<std::shared_ptr<FileIndexReader>> readers;
+    if (format_reader_) {
+        ::ArrowSchema c_schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(
+            arrow::ExportSchema(*arrow::schema({std::move(field)}), 
&c_schema));
+        PAIMON_ASSIGN_OR_RAISE(readers, 
format_reader_->ReadColumnIndex(field_name, &c_schema));
+    }
+    field_readers_.emplace(field_name, readers);
+    return readers;
 }
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
-    bool only_use_embedding_index, const std::shared_ptr<arrow::Schema>& 
data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
-    const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<FileSystem>& file_system,
-    const std::shared_ptr<MemoryPool>& pool) {
-    if (predicate == nullptr || file_meta->row_count > 
RoaringBitmap32::MAX_VALUE) {
-        return FileIndexResult::Remain();
+Result<std::shared_ptr<FileIndexReader>> FileIndexEvaluator::GetSearchReader(
+    const std::string& field_name, FileIndexReader::SearchType search_type) {
+    if (!data_schema_->GetFieldByName(field_name)) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' does not exist in data schema", 
field_name));
+    }
+    if (!format_reader_) {
+        return Status::Invalid(fmt::format("Data file '{}' has no File Index 
for search field '{}'",
+                                           file_meta_->file_name, field_name));
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<FileIndexReader>> 
readers,
+                           GetReaders(field_name));
+    std::shared_ptr<FileIndexReader> selected;
+    for (const auto& reader : readers) {
+        if (!reader->SupportsSearch(search_type)) {
+            continue;
+        }
+        if (selected) {
+            return Status::Invalid(fmt::format(
+                "Multiple File Index readers support requested search for 
field '{}'", field_name));
+        }
+        selected = reader;
     }
-    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
-                           ExtractIndexInputStream(only_use_embedding_index, 
data_file_path_factory,
-                                                   file_meta, file_system));
-    if (!input_stream) {
-        // no index files, just return REMAIN
+    if (!selected) {
+        return Status::NotImplemented(fmt::format(
+            "No File Index reader supports requested search for field '{}'", 
field_name));
+    }
+    return selected;
+}
+
+Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::EvaluateLeaf(
+    const std::shared_ptr<LeafPredicate>& predicate, bool needs_scores) {
+    if (predicate->GetFunction().GetType() == Function::Type::FULL_TEXT_MATCH) 
{
+        PAIMON_ASSIGN_OR_RAISE(
+            std::shared_ptr<FileIndexReader> reader,
+            GetSearchReader(predicate->FieldName(), 
FileIndexReader::SearchType::FULL_TEXT_MATCH));
+        auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+        if (!full_text) {
+            return Status::Invalid("FullTextMatch requires a full-text match 
predicate");
+        }
+        return reader->VisitFullTextMatch(full_text->GetFullTextMatch(), 
needs_scores);
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<FileIndexReader>> 
readers,
+                           GetReaders(predicate->FieldName()));
+    std::shared_ptr<FileIndexResult> result = FileIndexResult::Remain();
+    for (const auto& reader : readers) {
+        PAIMON_ASSIGN_OR_RAISE(
+            std::shared_ptr<FileIndexResult> child,
+            
PredicateUtils::VisitPredicate<std::shared_ptr<FileIndexResult>>(predicate, 
reader));
+        PAIMON_ASSIGN_OR_RAISE(result, result->And(child));
+        PAIMON_ASSIGN_OR_RAISE(bool remain, result->IsRemain());

Review Comment:
   An empty `IN` or `NOT IN` list can crash here on a Bloom-indexed column. The 
Bloom reader inherits both default visitors. With an empty list they return 
success with a null pointer, and `Remain()->And(child)` passes it through to 
`result->IsRemain()`. I reproduced both null results with this revision. Please 
handle the empty list in the default visitors and add a Bloom-backed regression 
with `[1, 2, null]`: `IN {}` should keep zero rows and `NOT IN {}` should keep 
`1` and `2`.



##########
docs/source/user_guide/file_index.rst:
##########
@@ -45,7 +59,9 @@ positions in that schema. For example, the following 
constructs
        std::shared_ptr<paimon::Predicate> filter,
        paimon::PredicateBuilder::Or({text, price}));
    paimon::ReadContextBuilder builder(table_path);
-   builder.SetReadFieldNames({"body", "price"}).SetPredicate(filter);
+   builder.EnablePreciseFileIndexEvaluate(true)
+       .SetReadFieldNames({"body", "price"})
+       .SetPredicate(filter);

Review Comment:
   Could the example show where to attach this mixed predicate when planning 
splits? Passing the same predicate to `ScanContextBuilder::SetPredicate` fails 
in `FileStoreScan` as soon as it sees `FullTextMatch`. The working public-entry 
test plans the splits first and attaches the full predicate to 
`ReadContextBuilder`. Please also mention that the example requires a plugin 
offering file-level full-text matching; enabling the global full-text index 
alone does not supply that reader.



##########
docs/source/user_guide/file_index.rst:
##########
@@ -21,6 +21,20 @@ File Index
 Full-text predicates and root TopK
 ----------------------------------
 
+Enable ``ReadContextBuilder::EnablePreciseFileIndexEvaluate(true)`` to 
precisely
+filter rows at the file-index-query stage, before reading all columns 
requested by
+the query from each data file. It evaluates the complete predicate to compute
+exact matching row positions within that file.
+
+For a root full-text or vector TopK request, predicate filtering, row-range
+restrictions and deletion vectors are applied before the TopK limit. Filtering 
after

Review Comment:
   Please spell out that `limit` applies independently to each file. With two 
matching files and `limit=1`, a caller can receive two rows. Within each file 
the reader emits selected rows in physical order, so consumers that need a 
table-wide TopK must merge by score. A two-file regression that checks the row 
count and score alignment would make this contract explicit.



##########
src/paimon/core/operation/precise_file_index_read.cpp:
##########
@@ -0,0 +1,573 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/operation/precise_file_index_read.h"
+
+#include <algorithm>
+#include <cmath>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/abi.h"
+#include "arrow/type.h"
+#include "fmt/format.h"
+#include "paimon/common/reader/complete_index_score_file_batch_reader.h"
+#include "paimon/common/reader/predicate_batch_reader.h"
+#include "paimon/common/reader/reader_utils.h"
+#include "paimon/common/types/data_field.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/core/deletionvectors/bitmap64_deletion_vector.h"
+#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/file_index_evaluator.h"
+#include "paimon/core/io/file_index_options.h"
+#include "paimon/core/operation/internal_read_context.h"
+#include "paimon/core/operation/raw_file_split_read.h"
+#include "paimon/core/schema/schema_manager.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/utils/field_mapping.h"
+#include "paimon/file_index/bitmap_index_result.h"
+#include "paimon/file_index/file_index_result.h"
+#include "paimon/file_index/scored_file_index_result.h"
+#include "paimon/format/reader_builder.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/function.h"
+#include "paimon/predicate/leaf_predicate.h"
+#include "paimon/predicate/vector_search.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+namespace {
+
+Result<std::string> ResolveSearchFieldName(
+    const std::string& field_name, const std::map<std::string, std::string>& 
field_name_mapping,
+    const std::string& file_name) {
+    auto iter = field_name_mapping.find(field_name);
+    if (iter == field_name_mapping.end()) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' has no matching field ID in data 
schema for file '{}'",
+                        field_name, file_name));
+    }
+    return iter->second;
+}
+
+Status PreparePredicate(
+    const std::shared_ptr<Predicate>& predicate,
+    const std::map<std::string, DataField>& read_by_name,
+    const std::map<int32_t, std::pair<int32_t, DataField>>& data_by_id,
+    const std::vector<std::string>& partition_keys, FileIndexEvaluator* 
indexes,
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>>* 
mapped_leaves) {
+    if (!predicate) {
+        return Status::OK();
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        const Function::Type type = compound->GetFunction().GetType();
+        if (type != Function::Type::AND && type != Function::Type::OR) {
+            return Status::Invalid(
+                fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+        }
+        for (const auto& child : compound->Children()) {
+            PAIMON_RETURN_NOT_OK(PreparePredicate(child, read_by_name, 
data_by_id, partition_keys,
+                                                  indexes, mapped_leaves));
+        }
+        return Status::OK();
+    }
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    if (!leaf) {
+        return Status::Invalid("File predicate must contain leaf or compound 
predicates");
+    }
+    bool full_text = leaf->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    if (!full_text && ObjectUtils::Contains(partition_keys, 
leaf->FieldName())) {
+        mapped_leaves->emplace(leaf, nullptr);
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::optional<std::shared_ptr<Predicate>> mapped,
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(leaf, 
read_by_name, data_by_id));
+    std::shared_ptr<LeafPredicate> mapped_leaf;
+    if (mapped) {
+        mapped_leaf = std::dynamic_pointer_cast<LeafPredicate>(*mapped);
+        if (!mapped_leaf) {
+            return Status::Invalid("Mapped file predicate must be a leaf");
+        }
+        if (full_text) {
+            PAIMON_ASSIGN_OR_RAISE(
+                [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+                indexes->GetSearchReader(mapped_leaf->FieldName(),
+                                         
FileIndexReader::SearchType::FULL_TEXT_MATCH));
+        }
+    }
+    mapped_leaves->emplace(leaf, std::move(mapped_leaf));
+    return Status::OK();
+}
+
+Status PrepareSearch(const std::shared_ptr<InternalReadContext>& context,
+                     const std::map<std::string, std::string>& field_names,
+                     const std::string& file_name, FileIndexEvaluator* indexes,
+                     std::shared_ptr<VectorSearch>* vector_search,
+                     std::shared_ptr<FullTextSearch>* full_text_search) {
+    if (context->GetVectorSearch()) {
+        *vector_search = 
std::make_shared<VectorSearch>(*context->GetVectorSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*vector_search)->field_name,
+            ResolveSearchFieldName((*vector_search)->field_name, field_names, 
file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*vector_search)->field_name,
+                                     
FileIndexReader::SearchType::VECTOR_SEARCH));
+    }
+    if (context->GetFullTextSearch()) {
+        *full_text_search = 
std::make_shared<FullTextSearch>(*context->GetFullTextSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*full_text_search)->field_name,
+            ResolveSearchFieldName((*full_text_search)->field_name, 
field_names, file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*full_text_search)->field_name,
+                                     
FileIndexReader::SearchType::FULL_TEXT_SEARCH));
+    }
+    return Status::OK();
+}
+
+}  // namespace
+
+Result<std::unique_ptr<PreciseFileIndexRead>> PreciseFileIndexRead::Create(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options) {
+    std::shared_ptr<TableSchema> table_schema = 
split_read.context_->GetTableSchema();
+    if (file->schema_id != table_schema->Id()) {
+        PAIMON_ASSIGN_OR_RAISE(table_schema,
+                               
split_read.schema_manager_->ReadSchema(file->schema_id));
+    }
+    const auto data_schema = 
DataField::ConvertDataFieldsToArrowSchema(table_schema->Fields());
+    if (file->row_count < 0 || file->row_count > RoaringBitmap32::MAX_VALUE) {
+        return Status::NotImplemented(
+            "Precise file-index evaluation requires bitmap32 physical row 
positions");
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<DataField> data_fields,
+                           
DataField::ConvertArrowSchemaToDataFields(data_schema));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::vector<DataField> read_fields,
+        
DataField::ConvertArrowSchemaToDataFields(split_read.context_->GetReadSchema()));
+    auto field_names = FieldMappingBuilder::CreateFieldNameMapping(
+        split_read.context_->GetTableSchema()->Fields(), data_fields);
+    std::map<std::string, std::string> index_options;
+    PAIMON_ASSIGN_OR_RAISE(index_options,
+                           FileIndexOptions::RewriteFieldNames(
+                               split_read.context_->GetCoreOptions().ToMap(), 
field_names));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<FileIndexEvaluator> indexes,
+        FileIndexEvaluator::Create(data_schema, index_options, path_factory, 
file,
+                                   
split_read.context_->GetCoreOptions().GetFileSystem(),
+                                   split_read.pool_));
+
+    std::map<std::string, DataField> read_by_name;
+    for (const auto& field : read_fields) {
+        read_by_name.emplace(field.Name(), field);
+    }
+    // Field ID -> (zero-based position in data_fields, field definition from 
the file's schema).
+    std::map<int32_t, std::pair<int32_t, DataField>> data_by_id;
+    for (size_t i = 0; i < data_fields.size(); ++i) {
+        data_by_id.emplace(data_fields[i].Id(), std::make_pair(i, 
data_fields[i]));
+    }
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>> 
mapped_leaves;
+    // Precheck the entire tree before an empty candidate set can 
short-circuit it.
+    PAIMON_RETURN_NOT_OK(PreparePredicate(split_read.context_->GetPredicate(), 
read_by_name,
+                                          data_by_id, 
split_read.context_->GetPartitionKeys(),
+                                          indexes.get(), &mapped_leaves));
+    std::shared_ptr<VectorSearch> vector_search;
+    std::shared_ptr<FullTextSearch> full_text_search;
+    PAIMON_RETURN_NOT_OK(PrepareSearch(split_read.context_, field_names, 
file->file_name,
+                                       indexes.get(), &vector_search, 
&full_text_search));
+    return std::unique_ptr<PreciseFileIndexRead>(new PreciseFileIndexRead(
+        split_read, partition, file, field_mapping_builder, path_factory, 
extra_format_options,
+        std::move(indexes), mapped_leaves, vector_search, full_text_search));
+}
+
+PreciseFileIndexRead::PreciseFileIndexRead(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options,
+    std::unique_ptr<FileIndexEvaluator> indexes,
+    const std::map<std::shared_ptr<LeafPredicate>, 
std::shared_ptr<LeafPredicate>>& mapped_leaves,
+    const std::shared_ptr<VectorSearch>& vector_search,
+    const std::shared_ptr<FullTextSearch>& full_text_search)
+    : split_read_(split_read),
+      partition_(partition),
+      file_(file),
+      field_mapping_builder_(field_mapping_builder),
+      path_factory_(path_factory),
+      extra_format_options_(extra_format_options),
+      indexes_(std::move(indexes)),
+      mapped_leaves_(mapped_leaves),
+      vector_search_(vector_search),
+      full_text_search_(full_text_search) {}
+
+Result<RoaringBitmap32> PreciseFileIndexRead::CreateCandidates(
+    const DeletionVector::Factory& dv_factory,
+    const std::optional<std::vector<Range>>& row_ranges) const {
+    RoaringBitmap32 candidates;
+    if (row_ranges) {
+        for (const auto& range : *row_ranges) {
+            candidates.AddRange(static_cast<int32_t>(range.from),
+                                static_cast<int32_t>(range.to + 1));
+        }
+    } else {
+        candidates.AddRange(0, static_cast<int32_t>(file_->row_count));
+    }
+    if (dv_factory) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DeletionVector> dv, 
dv_factory(file_->file_name));
+        if (auto bitmap = dynamic_cast<BitmapDeletionVector*>(dv.get())) {
+            candidates -= *bitmap->GetBitmap();
+        } else if (dv) {
+            return Status::NotImplemented(
+                "Precise file-index evaluation requires bitmap32 deletion 
vectors");
+        }
+    }
+    return candidates;
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::Evaluate(
+    const std::shared_ptr<Predicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    if (!predicate || candidates.IsEmpty()) {
+        return Evaluation{candidates, {}};
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        return EvaluateLeaf(leaf, candidates);
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        return EvaluateCompound(compound, candidates);
+    }
+    return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+}
+
+Result<PreciseFileIndexRead::Evaluation> 
PreciseFileIndexRead::EvaluateCompound(
+    const std::shared_ptr<CompoundPredicate>& predicate, const 
RoaringBitmap32& candidates) const {
+    const Function::Type type = predicate->GetFunction().GetType();
+    if (type == Function::Type::OR) {
+        Evaluation result;
+        for (const auto& child : predicate->Children()) {
+            // Every branch must see the same input, including rows matched by 
earlier branches.
+            PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, Evaluate(child, 
candidates));
+            result.rows |= child_result.rows;
+            result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        }
+        return result;
+    }
+    if (type != Function::Type::AND) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+
+    struct Child {
+        std::shared_ptr<Predicate> predicate;
+        std::optional<double> selectivity;
+    };
+    std::vector<Child> children;
+    children.reserve(predicate->Children().size());
+    for (const auto& child : predicate->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> selectivity, 
EstimateSelectivity(child));
+        children.push_back({child, selectivity});
+    }
+    std::stable_sort(children.begin(), children.end(), [](const Child& lhs, 
const Child& rhs) {
+        if (!lhs.selectivity) {
+            return false;
+        }
+        return !rhs.selectivity || *lhs.selectivity < *rhs.selectivity;
+    });
+
+    Evaluation result{candidates, {}};
+    for (const auto& child : children) {
+        PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, 
Evaluate(child.predicate, result.rows));
+        result.rows = std::move(child_result.rows);
+        result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        if (result.rows.IsEmpty()) {
+            break;
+        }
+    }
+    return result;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateSelectivity(
+    const std::shared_ptr<Predicate>& predicate) const {
+    if (!predicate) {
+        return std::optional<double>();
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> estimate, 
EstimateLeafSelectivity(leaf));
+        if (estimate && (!std::isfinite(*estimate) || *estimate < 0.0 || 
*estimate > 1.0)) {
+            return std::optional<double>();
+        }
+        return estimate;
+    }
+    auto compound = std::dynamic_pointer_cast<CompoundPredicate>(predicate);
+    if (!compound) {
+        return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+    }
+    const Function::Type type = compound->GetFunction().GetType();
+    if (type != Function::Type::AND && type != Function::Type::OR) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+    std::optional<double> estimate =
+        type == Function::Type::AND ? std::nullopt : std::make_optional(0.0);
+    // These are scheduling hints. Continue visiting all children to propagate 
errors.
+    for (const auto& child : compound->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> child_estimate, 
EstimateSelectivity(child));
+        if (type == Function::Type::AND) {
+            if (child_estimate) {
+                if (estimate.has_value()) {
+                    estimate = std::min(*estimate, *child_estimate);
+                } else {
+                    estimate = *child_estimate;
+                }
+            }
+        } else if (estimate && child_estimate) {
+            estimate = std::min(1.0, *estimate + *child_estimate);
+        } else {
+            estimate = std::nullopt;
+        }
+    }
+    return estimate;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateLeafSelectivity(
+    const std::shared_ptr<LeafPredicate>& predicate) const {
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (!iter->second) {
+        return std::optional<double>();
+    }
+    return indexes_->EstimateSelectivity(iter->second);
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::EvaluateLeaf(
+    const std::shared_ptr<LeafPredicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    bool is_full_text = predicate->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    bool needs_scores =
+        is_full_text &&
+        split_read_.context_->NeedsScores(
+            {InternalReadContext::IndexScoreSource::Type::FULL_TEXT_MATCH, 
predicate->FieldName()});
+    std::shared_ptr<FileIndexResult> index_result = FileIndexResult::Remain();
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (iter->second) {
+        // TODO(lisizhuo.lsz): Pass candidates through 
FileIndexEvaluator::EvaluateLeaf to index
+        // readers so they can skip rows excluded by earlier predicates, row 
ranges, or deletion
+        // vectors.
+        PAIMON_ASSIGN_OR_RAISE(index_result, 
indexes_->EvaluateLeaf(iter->second, needs_scores));
+    }
+    if (!index_result) {
+        return Status::Invalid("file predicate index returned a null result");
+    }
+    Evaluation result{candidates, {}};
+    if (needs_scores) {
+        auto scored = 
std::dynamic_pointer_cast<ScoredFileIndexResult>(index_result);
+        if (!scored) {
+            return Status::Invalid(
+                fmt::format("full-text index for field '{}' did not return 
requested scores",
+                            predicate->FieldName()));
+        }
+        result.rows &= scored->GetRowPositions();
+        result.scores.emplace(predicate->FieldName(), std::move(scored));
+        return result;
+    }
+
+    if (auto bitmap = 
std::dynamic_pointer_cast<BitmapIndexResult>(index_result)) {
+        PAIMON_ASSIGN_OR_RAISE(const RoaringBitmap32* rows, 
bitmap->GetBitmap());
+        result.rows &= *rows;

Review Comment:
   This intersection can discard a true match before `ScanScalar` can recheck 
it. For a millisecond timestamp column, the row `1000 ms` satisfies `ts < 
1000.5 ms`. `RangeBitmapTypeAdapter` converts both operands to the key `1000`, 
so the index tests `1000 < 1000` and removes the row. I reproduced the key 
conversion on this revision. Please bypass that index when the bound loses 
precision, or adjust the bound for the comparison operator. A regression 
comparing indexed and unindexed reads at this boundary would also protect TopK 
from ranking the wrong rows.



##########
src/paimon/core/operation/precise_file_index_read.cpp:
##########
@@ -0,0 +1,573 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/operation/precise_file_index_read.h"
+
+#include <algorithm>
+#include <cmath>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/abi.h"
+#include "arrow/type.h"
+#include "fmt/format.h"
+#include "paimon/common/reader/complete_index_score_file_batch_reader.h"
+#include "paimon/common/reader/predicate_batch_reader.h"
+#include "paimon/common/reader/reader_utils.h"
+#include "paimon/common/types/data_field.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/core/deletionvectors/bitmap64_deletion_vector.h"
+#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/file_index_evaluator.h"
+#include "paimon/core/io/file_index_options.h"
+#include "paimon/core/operation/internal_read_context.h"
+#include "paimon/core/operation/raw_file_split_read.h"
+#include "paimon/core/schema/schema_manager.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/utils/field_mapping.h"
+#include "paimon/file_index/bitmap_index_result.h"
+#include "paimon/file_index/file_index_result.h"
+#include "paimon/file_index/scored_file_index_result.h"
+#include "paimon/format/reader_builder.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/function.h"
+#include "paimon/predicate/leaf_predicate.h"
+#include "paimon/predicate/vector_search.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+namespace {
+
+Result<std::string> ResolveSearchFieldName(
+    const std::string& field_name, const std::map<std::string, std::string>& 
field_name_mapping,
+    const std::string& file_name) {
+    auto iter = field_name_mapping.find(field_name);
+    if (iter == field_name_mapping.end()) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' has no matching field ID in data 
schema for file '{}'",
+                        field_name, file_name));
+    }
+    return iter->second;
+}
+
+Status PreparePredicate(
+    const std::shared_ptr<Predicate>& predicate,
+    const std::map<std::string, DataField>& read_by_name,
+    const std::map<int32_t, std::pair<int32_t, DataField>>& data_by_id,
+    const std::vector<std::string>& partition_keys, FileIndexEvaluator* 
indexes,
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>>* 
mapped_leaves) {
+    if (!predicate) {
+        return Status::OK();
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        const Function::Type type = compound->GetFunction().GetType();
+        if (type != Function::Type::AND && type != Function::Type::OR) {
+            return Status::Invalid(
+                fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+        }
+        for (const auto& child : compound->Children()) {
+            PAIMON_RETURN_NOT_OK(PreparePredicate(child, read_by_name, 
data_by_id, partition_keys,
+                                                  indexes, mapped_leaves));
+        }
+        return Status::OK();
+    }
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    if (!leaf) {
+        return Status::Invalid("File predicate must contain leaf or compound 
predicates");
+    }
+    bool full_text = leaf->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    if (!full_text && ObjectUtils::Contains(partition_keys, 
leaf->FieldName())) {
+        mapped_leaves->emplace(leaf, nullptr);
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::optional<std::shared_ptr<Predicate>> mapped,
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(leaf, 
read_by_name, data_by_id));
+    std::shared_ptr<LeafPredicate> mapped_leaf;
+    if (mapped) {
+        mapped_leaf = std::dynamic_pointer_cast<LeafPredicate>(*mapped);
+        if (!mapped_leaf) {
+            return Status::Invalid("Mapped file predicate must be a leaf");
+        }
+        if (full_text) {
+            PAIMON_ASSIGN_OR_RAISE(
+                [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+                indexes->GetSearchReader(mapped_leaf->FieldName(),
+                                         
FileIndexReader::SearchType::FULL_TEXT_MATCH));
+        }
+    }
+    mapped_leaves->emplace(leaf, std::move(mapped_leaf));
+    return Status::OK();
+}
+
+Status PrepareSearch(const std::shared_ptr<InternalReadContext>& context,
+                     const std::map<std::string, std::string>& field_names,
+                     const std::string& file_name, FileIndexEvaluator* indexes,
+                     std::shared_ptr<VectorSearch>* vector_search,
+                     std::shared_ptr<FullTextSearch>* full_text_search) {
+    if (context->GetVectorSearch()) {
+        *vector_search = 
std::make_shared<VectorSearch>(*context->GetVectorSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*vector_search)->field_name,
+            ResolveSearchFieldName((*vector_search)->field_name, field_names, 
file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*vector_search)->field_name,
+                                     
FileIndexReader::SearchType::VECTOR_SEARCH));
+    }
+    if (context->GetFullTextSearch()) {
+        *full_text_search = 
std::make_shared<FullTextSearch>(*context->GetFullTextSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*full_text_search)->field_name,
+            ResolveSearchFieldName((*full_text_search)->field_name, 
field_names, file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*full_text_search)->field_name,
+                                     
FileIndexReader::SearchType::FULL_TEXT_SEARCH));
+    }
+    return Status::OK();
+}
+
+}  // namespace
+
+Result<std::unique_ptr<PreciseFileIndexRead>> PreciseFileIndexRead::Create(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options) {
+    std::shared_ptr<TableSchema> table_schema = 
split_read.context_->GetTableSchema();
+    if (file->schema_id != table_schema->Id()) {
+        PAIMON_ASSIGN_OR_RAISE(table_schema,
+                               
split_read.schema_manager_->ReadSchema(file->schema_id));
+    }
+    const auto data_schema = 
DataField::ConvertDataFieldsToArrowSchema(table_schema->Fields());
+    if (file->row_count < 0 || file->row_count > RoaringBitmap32::MAX_VALUE) {
+        return Status::NotImplemented(
+            "Precise file-index evaluation requires bitmap32 physical row 
positions");
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<DataField> data_fields,
+                           
DataField::ConvertArrowSchemaToDataFields(data_schema));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::vector<DataField> read_fields,
+        
DataField::ConvertArrowSchemaToDataFields(split_read.context_->GetReadSchema()));
+    auto field_names = FieldMappingBuilder::CreateFieldNameMapping(
+        split_read.context_->GetTableSchema()->Fields(), data_fields);
+    std::map<std::string, std::string> index_options;
+    PAIMON_ASSIGN_OR_RAISE(index_options,
+                           FileIndexOptions::RewriteFieldNames(
+                               split_read.context_->GetCoreOptions().ToMap(), 
field_names));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<FileIndexEvaluator> indexes,
+        FileIndexEvaluator::Create(data_schema, index_options, path_factory, 
file,
+                                   
split_read.context_->GetCoreOptions().GetFileSystem(),
+                                   split_read.pool_));
+
+    std::map<std::string, DataField> read_by_name;
+    for (const auto& field : read_fields) {
+        read_by_name.emplace(field.Name(), field);
+    }
+    // Field ID -> (zero-based position in data_fields, field definition from 
the file's schema).
+    std::map<int32_t, std::pair<int32_t, DataField>> data_by_id;
+    for (size_t i = 0; i < data_fields.size(); ++i) {
+        data_by_id.emplace(data_fields[i].Id(), std::make_pair(i, 
data_fields[i]));
+    }
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>> 
mapped_leaves;
+    // Precheck the entire tree before an empty candidate set can 
short-circuit it.
+    PAIMON_RETURN_NOT_OK(PreparePredicate(split_read.context_->GetPredicate(), 
read_by_name,
+                                          data_by_id, 
split_read.context_->GetPartitionKeys(),
+                                          indexes.get(), &mapped_leaves));
+    std::shared_ptr<VectorSearch> vector_search;
+    std::shared_ptr<FullTextSearch> full_text_search;
+    PAIMON_RETURN_NOT_OK(PrepareSearch(split_read.context_, field_names, 
file->file_name,
+                                       indexes.get(), &vector_search, 
&full_text_search));
+    return std::unique_ptr<PreciseFileIndexRead>(new PreciseFileIndexRead(
+        split_read, partition, file, field_mapping_builder, path_factory, 
extra_format_options,
+        std::move(indexes), mapped_leaves, vector_search, full_text_search));
+}
+
+PreciseFileIndexRead::PreciseFileIndexRead(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options,
+    std::unique_ptr<FileIndexEvaluator> indexes,
+    const std::map<std::shared_ptr<LeafPredicate>, 
std::shared_ptr<LeafPredicate>>& mapped_leaves,
+    const std::shared_ptr<VectorSearch>& vector_search,
+    const std::shared_ptr<FullTextSearch>& full_text_search)
+    : split_read_(split_read),
+      partition_(partition),
+      file_(file),
+      field_mapping_builder_(field_mapping_builder),
+      path_factory_(path_factory),
+      extra_format_options_(extra_format_options),
+      indexes_(std::move(indexes)),
+      mapped_leaves_(mapped_leaves),
+      vector_search_(vector_search),
+      full_text_search_(full_text_search) {}
+
+Result<RoaringBitmap32> PreciseFileIndexRead::CreateCandidates(
+    const DeletionVector::Factory& dv_factory,
+    const std::optional<std::vector<Range>>& row_ranges) const {
+    RoaringBitmap32 candidates;
+    if (row_ranges) {
+        for (const auto& range : *row_ranges) {
+            candidates.AddRange(static_cast<int32_t>(range.from),
+                                static_cast<int32_t>(range.to + 1));
+        }
+    } else {
+        candidates.AddRange(0, static_cast<int32_t>(file_->row_count));
+    }
+    if (dv_factory) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DeletionVector> dv, 
dv_factory(file_->file_name));
+        if (auto bitmap = dynamic_cast<BitmapDeletionVector*>(dv.get())) {
+            candidates -= *bitmap->GetBitmap();
+        } else if (dv) {
+            return Status::NotImplemented(
+                "Precise file-index evaluation requires bitmap32 deletion 
vectors");
+        }
+    }
+    return candidates;
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::Evaluate(
+    const std::shared_ptr<Predicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    if (!predicate || candidates.IsEmpty()) {
+        return Evaluation{candidates, {}};
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        return EvaluateLeaf(leaf, candidates);
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        return EvaluateCompound(compound, candidates);
+    }
+    return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+}
+
+Result<PreciseFileIndexRead::Evaluation> 
PreciseFileIndexRead::EvaluateCompound(
+    const std::shared_ptr<CompoundPredicate>& predicate, const 
RoaringBitmap32& candidates) const {
+    const Function::Type type = predicate->GetFunction().GetType();
+    if (type == Function::Type::OR) {
+        Evaluation result;
+        for (const auto& child : predicate->Children()) {
+            // Every branch must see the same input, including rows matched by 
earlier branches.
+            PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, Evaluate(child, 
candidates));
+            result.rows |= child_result.rows;
+            result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        }
+        return result;
+    }
+    if (type != Function::Type::AND) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+
+    struct Child {
+        std::shared_ptr<Predicate> predicate;
+        std::optional<double> selectivity;
+    };
+    std::vector<Child> children;
+    children.reserve(predicate->Children().size());
+    for (const auto& child : predicate->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> selectivity, 
EstimateSelectivity(child));
+        children.push_back({child, selectivity});
+    }
+    std::stable_sort(children.begin(), children.end(), [](const Child& lhs, 
const Child& rhs) {
+        if (!lhs.selectivity) {
+            return false;
+        }
+        return !rhs.selectivity || *lhs.selectivity < *rhs.selectivity;
+    });
+
+    Evaluation result{candidates, {}};
+    for (const auto& child : children) {
+        PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, 
Evaluate(child.predicate, result.rows));
+        result.rows = std::move(child_result.rows);
+        result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        if (result.rows.IsEmpty()) {
+            break;
+        }
+    }
+    return result;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateSelectivity(
+    const std::shared_ptr<Predicate>& predicate) const {
+    if (!predicate) {
+        return std::optional<double>();
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> estimate, 
EstimateLeafSelectivity(leaf));
+        if (estimate && (!std::isfinite(*estimate) || *estimate < 0.0 || 
*estimate > 1.0)) {
+            return std::optional<double>();
+        }
+        return estimate;
+    }
+    auto compound = std::dynamic_pointer_cast<CompoundPredicate>(predicate);
+    if (!compound) {
+        return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+    }
+    const Function::Type type = compound->GetFunction().GetType();
+    if (type != Function::Type::AND && type != Function::Type::OR) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+    std::optional<double> estimate =
+        type == Function::Type::AND ? std::nullopt : std::make_optional(0.0);
+    // These are scheduling hints. Continue visiting all children to propagate 
errors.
+    for (const auto& child : compound->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> child_estimate, 
EstimateSelectivity(child));
+        if (type == Function::Type::AND) {
+            if (child_estimate) {
+                if (estimate.has_value()) {
+                    estimate = std::min(*estimate, *child_estimate);
+                } else {
+                    estimate = *child_estimate;
+                }
+            }
+        } else if (estimate && child_estimate) {
+            estimate = std::min(1.0, *estimate + *child_estimate);
+        } else {
+            estimate = std::nullopt;
+        }
+    }
+    return estimate;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateLeafSelectivity(
+    const std::shared_ptr<LeafPredicate>& predicate) const {
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (!iter->second) {
+        return std::optional<double>();
+    }
+    return indexes_->EstimateSelectivity(iter->second);
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::EvaluateLeaf(
+    const std::shared_ptr<LeafPredicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    bool is_full_text = predicate->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    bool needs_scores =
+        is_full_text &&
+        split_read_.context_->NeedsScores(
+            {InternalReadContext::IndexScoreSource::Type::FULL_TEXT_MATCH, 
predicate->FieldName()});
+    std::shared_ptr<FileIndexResult> index_result = FileIndexResult::Remain();
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (iter->second) {
+        // TODO(lisizhuo.lsz): Pass candidates through 
FileIndexEvaluator::EvaluateLeaf to index
+        // readers so they can skip rows excluded by earlier predicates, row 
ranges, or deletion
+        // vectors.
+        PAIMON_ASSIGN_OR_RAISE(index_result, 
indexes_->EvaluateLeaf(iter->second, needs_scores));
+    }
+    if (!index_result) {
+        return Status::Invalid("file predicate index returned a null result");
+    }
+    Evaluation result{candidates, {}};
+    if (needs_scores) {
+        auto scored = 
std::dynamic_pointer_cast<ScoredFileIndexResult>(index_result);
+        if (!scored) {
+            return Status::Invalid(
+                fmt::format("full-text index for field '{}' did not return 
requested scores",
+                            predicate->FieldName()));
+        }
+        result.rows &= scored->GetRowPositions();
+        result.scores.emplace(predicate->FieldName(), std::move(scored));
+        return result;
+    }
+
+    if (auto bitmap = 
std::dynamic_pointer_cast<BitmapIndexResult>(index_result)) {
+        PAIMON_ASSIGN_OR_RAISE(const RoaringBitmap32* rows, 
bitmap->GetBitmap());
+        result.rows &= *rows;
+    } else if (is_full_text) {
+        return Status::Invalid(fmt::format("full-text index for field '{}' did 
not return a bitmap",
+                                           predicate->FieldName()));
+    } else if (std::dynamic_pointer_cast<Skip>(index_result)) {
+        return Evaluation{};
+    } else if (!std::dynamic_pointer_cast<Remain>(index_result)) {
+        return Status::Invalid(
+            fmt::format("invalid index result for predicate {}", 
predicate->ToString()));
+    }
+    // TODO(lisizhuo.lsz): Whether to ScanScalar depends on whether the index 
returns exact results.
+    if (!is_full_text && !result.rows.IsEmpty()) {
+        // Scalar indexes may contain false positives, even if they return a 
bitmap.
+        PAIMON_ASSIGN_OR_RAISE(RoaringBitmap32 exact_rows, 
ScanScalar(predicate, result.rows));
+        result.rows &= exact_rows;
+    }
+    return result;
+}
+
+Result<std::unique_ptr<FileBatchReader>> 
PreciseFileIndexRead::CreateSelectedFileReader(
+    const RoaringBitmap32& selection, const FieldMappingBuilder* 
field_mapping_builder) const {
+    PAIMON_ASSIGN_OR_RAISE(std::string format, file_->FileFormat());
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReaderBuilder> builder,
+                           split_read_.PrepareReaderBuilder(format, 
extra_format_options_));
+    return split_read_.CreateFieldMappingReader(path_factory_->ToPath(file_), 
file_, partition_,
+                                                std::move(builder), 
field_mapping_builder, nullptr,
+                                                std::nullopt, path_factory_, 
&selection);

Review Comment:
   When all physical rows are eligible, passing `&selection` still makes the 
Lance reader expand the bitmap into one `uint64_t` per row. A 100-million-row 
file needs about 800 MB just for those IDs before it can return the first 
batch. This happens even with no predicate, TopK, deletion or range 
restriction. Please pass an absent bitmap to the format reader when the 
selection covers the whole file, while preserving the precise path's validation 
and filtering for partial selections.



##########
src/paimon/core/operation/precise_file_index_read.cpp:
##########
@@ -0,0 +1,573 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/operation/precise_file_index_read.h"
+
+#include <algorithm>
+#include <cmath>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/abi.h"
+#include "arrow/type.h"
+#include "fmt/format.h"
+#include "paimon/common/reader/complete_index_score_file_batch_reader.h"
+#include "paimon/common/reader/predicate_batch_reader.h"
+#include "paimon/common/reader/reader_utils.h"
+#include "paimon/common/types/data_field.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/core/deletionvectors/bitmap64_deletion_vector.h"
+#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/file_index_evaluator.h"
+#include "paimon/core/io/file_index_options.h"
+#include "paimon/core/operation/internal_read_context.h"
+#include "paimon/core/operation/raw_file_split_read.h"
+#include "paimon/core/schema/schema_manager.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/utils/field_mapping.h"
+#include "paimon/file_index/bitmap_index_result.h"
+#include "paimon/file_index/file_index_result.h"
+#include "paimon/file_index/scored_file_index_result.h"
+#include "paimon/format/reader_builder.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/function.h"
+#include "paimon/predicate/leaf_predicate.h"
+#include "paimon/predicate/vector_search.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+namespace {
+
+Result<std::string> ResolveSearchFieldName(
+    const std::string& field_name, const std::map<std::string, std::string>& 
field_name_mapping,
+    const std::string& file_name) {
+    auto iter = field_name_mapping.find(field_name);
+    if (iter == field_name_mapping.end()) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' has no matching field ID in data 
schema for file '{}'",
+                        field_name, file_name));
+    }
+    return iter->second;
+}
+
+Status PreparePredicate(
+    const std::shared_ptr<Predicate>& predicate,
+    const std::map<std::string, DataField>& read_by_name,
+    const std::map<int32_t, std::pair<int32_t, DataField>>& data_by_id,
+    const std::vector<std::string>& partition_keys, FileIndexEvaluator* 
indexes,
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>>* 
mapped_leaves) {
+    if (!predicate) {
+        return Status::OK();
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        const Function::Type type = compound->GetFunction().GetType();
+        if (type != Function::Type::AND && type != Function::Type::OR) {
+            return Status::Invalid(
+                fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+        }
+        for (const auto& child : compound->Children()) {
+            PAIMON_RETURN_NOT_OK(PreparePredicate(child, read_by_name, 
data_by_id, partition_keys,
+                                                  indexes, mapped_leaves));
+        }
+        return Status::OK();
+    }
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    if (!leaf) {
+        return Status::Invalid("File predicate must contain leaf or compound 
predicates");
+    }
+    bool full_text = leaf->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    if (!full_text && ObjectUtils::Contains(partition_keys, 
leaf->FieldName())) {
+        mapped_leaves->emplace(leaf, nullptr);
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::optional<std::shared_ptr<Predicate>> mapped,
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(leaf, 
read_by_name, data_by_id));
+    std::shared_ptr<LeafPredicate> mapped_leaf;
+    if (mapped) {
+        mapped_leaf = std::dynamic_pointer_cast<LeafPredicate>(*mapped);
+        if (!mapped_leaf) {
+            return Status::Invalid("Mapped file predicate must be a leaf");
+        }
+        if (full_text) {
+            PAIMON_ASSIGN_OR_RAISE(
+                [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+                indexes->GetSearchReader(mapped_leaf->FieldName(),
+                                         
FileIndexReader::SearchType::FULL_TEXT_MATCH));
+        }
+    }
+    mapped_leaves->emplace(leaf, std::move(mapped_leaf));
+    return Status::OK();
+}
+
+Status PrepareSearch(const std::shared_ptr<InternalReadContext>& context,
+                     const std::map<std::string, std::string>& field_names,
+                     const std::string& file_name, FileIndexEvaluator* indexes,
+                     std::shared_ptr<VectorSearch>* vector_search,
+                     std::shared_ptr<FullTextSearch>* full_text_search) {
+    if (context->GetVectorSearch()) {
+        *vector_search = 
std::make_shared<VectorSearch>(*context->GetVectorSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*vector_search)->field_name,
+            ResolveSearchFieldName((*vector_search)->field_name, field_names, 
file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*vector_search)->field_name,
+                                     
FileIndexReader::SearchType::VECTOR_SEARCH));
+    }
+    if (context->GetFullTextSearch()) {
+        *full_text_search = 
std::make_shared<FullTextSearch>(*context->GetFullTextSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*full_text_search)->field_name,
+            ResolveSearchFieldName((*full_text_search)->field_name, 
field_names, file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*full_text_search)->field_name,
+                                     
FileIndexReader::SearchType::FULL_TEXT_SEARCH));
+    }
+    return Status::OK();
+}
+
+}  // namespace
+
+Result<std::unique_ptr<PreciseFileIndexRead>> PreciseFileIndexRead::Create(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options) {
+    std::shared_ptr<TableSchema> table_schema = 
split_read.context_->GetTableSchema();

Review Comment:
   Could this local be named `file_schema`? For an older file it holds the 
historical schema loaded on lines 163–164, while `CreateFieldNameMapping` below 
still uses the current table schema. The distinction matters when a column has 
been renamed.



##########
src/paimon/core/operation/precise_file_index_read.cpp:
##########
@@ -0,0 +1,573 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/operation/precise_file_index_read.h"
+
+#include <algorithm>
+#include <cmath>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/c/abi.h"
+#include "arrow/type.h"
+#include "fmt/format.h"
+#include "paimon/common/reader/complete_index_score_file_batch_reader.h"
+#include "paimon/common/reader/predicate_batch_reader.h"
+#include "paimon/common/reader/reader_utils.h"
+#include "paimon/common/types/data_field.h"
+#include "paimon/common/utils/object_utils.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/core/deletionvectors/bitmap64_deletion_vector.h"
+#include "paimon/core/deletionvectors/bitmap_deletion_vector.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/file_index_evaluator.h"
+#include "paimon/core/io/file_index_options.h"
+#include "paimon/core/operation/internal_read_context.h"
+#include "paimon/core/operation/raw_file_split_read.h"
+#include "paimon/core/schema/schema_manager.h"
+#include "paimon/core/schema/table_schema.h"
+#include "paimon/core/utils/field_mapping.h"
+#include "paimon/file_index/bitmap_index_result.h"
+#include "paimon/file_index/file_index_result.h"
+#include "paimon/file_index/scored_file_index_result.h"
+#include "paimon/format/reader_builder.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/function.h"
+#include "paimon/predicate/leaf_predicate.h"
+#include "paimon/predicate/vector_search.h"
+#include "paimon/utils/roaring_bitmap64.h"
+
+namespace paimon {
+namespace {
+
+Result<std::string> ResolveSearchFieldName(
+    const std::string& field_name, const std::map<std::string, std::string>& 
field_name_mapping,
+    const std::string& file_name) {
+    auto iter = field_name_mapping.find(field_name);
+    if (iter == field_name_mapping.end()) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' has no matching field ID in data 
schema for file '{}'",
+                        field_name, file_name));
+    }
+    return iter->second;
+}
+
+Status PreparePredicate(
+    const std::shared_ptr<Predicate>& predicate,
+    const std::map<std::string, DataField>& read_by_name,
+    const std::map<int32_t, std::pair<int32_t, DataField>>& data_by_id,
+    const std::vector<std::string>& partition_keys, FileIndexEvaluator* 
indexes,
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>>* 
mapped_leaves) {
+    if (!predicate) {
+        return Status::OK();
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        const Function::Type type = compound->GetFunction().GetType();
+        if (type != Function::Type::AND && type != Function::Type::OR) {
+            return Status::Invalid(
+                fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+        }
+        for (const auto& child : compound->Children()) {
+            PAIMON_RETURN_NOT_OK(PreparePredicate(child, read_by_name, 
data_by_id, partition_keys,
+                                                  indexes, mapped_leaves));
+        }
+        return Status::OK();
+    }
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    if (!leaf) {
+        return Status::Invalid("File predicate must contain leaf or compound 
predicates");
+    }
+    bool full_text = leaf->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    if (!full_text && ObjectUtils::Contains(partition_keys, 
leaf->FieldName())) {
+        mapped_leaves->emplace(leaf, nullptr);
+        return Status::OK();
+    }
+    PAIMON_ASSIGN_OR_RAISE(
+        std::optional<std::shared_ptr<Predicate>> mapped,
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(leaf, 
read_by_name, data_by_id));
+    std::shared_ptr<LeafPredicate> mapped_leaf;
+    if (mapped) {
+        mapped_leaf = std::dynamic_pointer_cast<LeafPredicate>(*mapped);
+        if (!mapped_leaf) {
+            return Status::Invalid("Mapped file predicate must be a leaf");
+        }
+        if (full_text) {
+            PAIMON_ASSIGN_OR_RAISE(
+                [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+                indexes->GetSearchReader(mapped_leaf->FieldName(),
+                                         
FileIndexReader::SearchType::FULL_TEXT_MATCH));
+        }
+    }
+    mapped_leaves->emplace(leaf, std::move(mapped_leaf));
+    return Status::OK();
+}
+
+Status PrepareSearch(const std::shared_ptr<InternalReadContext>& context,
+                     const std::map<std::string, std::string>& field_names,
+                     const std::string& file_name, FileIndexEvaluator* indexes,
+                     std::shared_ptr<VectorSearch>* vector_search,
+                     std::shared_ptr<FullTextSearch>* full_text_search) {
+    if (context->GetVectorSearch()) {
+        *vector_search = 
std::make_shared<VectorSearch>(*context->GetVectorSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*vector_search)->field_name,
+            ResolveSearchFieldName((*vector_search)->field_name, field_names, 
file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*vector_search)->field_name,
+                                     
FileIndexReader::SearchType::VECTOR_SEARCH));
+    }
+    if (context->GetFullTextSearch()) {
+        *full_text_search = 
std::make_shared<FullTextSearch>(*context->GetFullTextSearch());
+        PAIMON_ASSIGN_OR_RAISE(
+            (*full_text_search)->field_name,
+            ResolveSearchFieldName((*full_text_search)->field_name, 
field_names, file_name));
+        PAIMON_ASSIGN_OR_RAISE(
+            [[maybe_unused]] std::shared_ptr<FileIndexReader> reader,
+            indexes->GetSearchReader((*full_text_search)->field_name,
+                                     
FileIndexReader::SearchType::FULL_TEXT_SEARCH));
+    }
+    return Status::OK();
+}
+
+}  // namespace
+
+Result<std::unique_ptr<PreciseFileIndexRead>> PreciseFileIndexRead::Create(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options) {
+    std::shared_ptr<TableSchema> table_schema = 
split_read.context_->GetTableSchema();
+    if (file->schema_id != table_schema->Id()) {
+        PAIMON_ASSIGN_OR_RAISE(table_schema,
+                               
split_read.schema_manager_->ReadSchema(file->schema_id));
+    }
+    const auto data_schema = 
DataField::ConvertDataFieldsToArrowSchema(table_schema->Fields());
+    if (file->row_count < 0 || file->row_count > RoaringBitmap32::MAX_VALUE) {
+        return Status::NotImplemented(
+            "Precise file-index evaluation requires bitmap32 physical row 
positions");
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<DataField> data_fields,
+                           
DataField::ConvertArrowSchemaToDataFields(data_schema));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::vector<DataField> read_fields,
+        
DataField::ConvertArrowSchemaToDataFields(split_read.context_->GetReadSchema()));
+    auto field_names = FieldMappingBuilder::CreateFieldNameMapping(
+        split_read.context_->GetTableSchema()->Fields(), data_fields);
+    std::map<std::string, std::string> index_options;
+    PAIMON_ASSIGN_OR_RAISE(index_options,
+                           FileIndexOptions::RewriteFieldNames(
+                               split_read.context_->GetCoreOptions().ToMap(), 
field_names));
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<FileIndexEvaluator> indexes,
+        FileIndexEvaluator::Create(data_schema, index_options, path_factory, 
file,
+                                   
split_read.context_->GetCoreOptions().GetFileSystem(),
+                                   split_read.pool_));
+
+    std::map<std::string, DataField> read_by_name;
+    for (const auto& field : read_fields) {
+        read_by_name.emplace(field.Name(), field);
+    }
+    // Field ID -> (zero-based position in data_fields, field definition from 
the file's schema).
+    std::map<int32_t, std::pair<int32_t, DataField>> data_by_id;
+    for (size_t i = 0; i < data_fields.size(); ++i) {
+        data_by_id.emplace(data_fields[i].Id(), std::make_pair(i, 
data_fields[i]));
+    }
+    std::map<std::shared_ptr<LeafPredicate>, std::shared_ptr<LeafPredicate>> 
mapped_leaves;
+    // Precheck the entire tree before an empty candidate set can 
short-circuit it.
+    PAIMON_RETURN_NOT_OK(PreparePredicate(split_read.context_->GetPredicate(), 
read_by_name,
+                                          data_by_id, 
split_read.context_->GetPartitionKeys(),
+                                          indexes.get(), &mapped_leaves));
+    std::shared_ptr<VectorSearch> vector_search;
+    std::shared_ptr<FullTextSearch> full_text_search;
+    PAIMON_RETURN_NOT_OK(PrepareSearch(split_read.context_, field_names, 
file->file_name,
+                                       indexes.get(), &vector_search, 
&full_text_search));
+    return std::unique_ptr<PreciseFileIndexRead>(new PreciseFileIndexRead(
+        split_read, partition, file, field_mapping_builder, path_factory, 
extra_format_options,
+        std::move(indexes), mapped_leaves, vector_search, full_text_search));
+}
+
+PreciseFileIndexRead::PreciseFileIndexRead(
+    const RawFileSplitRead& split_read, const BinaryRow& partition,
+    const std::shared_ptr<DataFileMeta>& file, const FieldMappingBuilder* 
field_mapping_builder,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
+    const std::map<std::string, std::string>& extra_format_options,
+    std::unique_ptr<FileIndexEvaluator> indexes,
+    const std::map<std::shared_ptr<LeafPredicate>, 
std::shared_ptr<LeafPredicate>>& mapped_leaves,
+    const std::shared_ptr<VectorSearch>& vector_search,
+    const std::shared_ptr<FullTextSearch>& full_text_search)
+    : split_read_(split_read),
+      partition_(partition),
+      file_(file),
+      field_mapping_builder_(field_mapping_builder),
+      path_factory_(path_factory),
+      extra_format_options_(extra_format_options),
+      indexes_(std::move(indexes)),
+      mapped_leaves_(mapped_leaves),
+      vector_search_(vector_search),
+      full_text_search_(full_text_search) {}
+
+Result<RoaringBitmap32> PreciseFileIndexRead::CreateCandidates(
+    const DeletionVector::Factory& dv_factory,
+    const std::optional<std::vector<Range>>& row_ranges) const {
+    RoaringBitmap32 candidates;
+    if (row_ranges) {
+        for (const auto& range : *row_ranges) {
+            candidates.AddRange(static_cast<int32_t>(range.from),
+                                static_cast<int32_t>(range.to + 1));
+        }
+    } else {
+        candidates.AddRange(0, static_cast<int32_t>(file_->row_count));
+    }
+    if (dv_factory) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DeletionVector> dv, 
dv_factory(file_->file_name));
+        if (auto bitmap = dynamic_cast<BitmapDeletionVector*>(dv.get())) {
+            candidates -= *bitmap->GetBitmap();
+        } else if (dv) {
+            return Status::NotImplemented(
+                "Precise file-index evaluation requires bitmap32 deletion 
vectors");
+        }
+    }
+    return candidates;
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::Evaluate(
+    const std::shared_ptr<Predicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    if (!predicate || candidates.IsEmpty()) {
+        return Evaluation{candidates, {}};
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        return EvaluateLeaf(leaf, candidates);
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        return EvaluateCompound(compound, candidates);
+    }
+    return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+}
+
+Result<PreciseFileIndexRead::Evaluation> 
PreciseFileIndexRead::EvaluateCompound(
+    const std::shared_ptr<CompoundPredicate>& predicate, const 
RoaringBitmap32& candidates) const {
+    const Function::Type type = predicate->GetFunction().GetType();
+    if (type == Function::Type::OR) {
+        Evaluation result;
+        for (const auto& child : predicate->Children()) {
+            // Every branch must see the same input, including rows matched by 
earlier branches.
+            PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, Evaluate(child, 
candidates));
+            result.rows |= child_result.rows;
+            result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        }
+        return result;
+    }
+    if (type != Function::Type::AND) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+
+    struct Child {
+        std::shared_ptr<Predicate> predicate;
+        std::optional<double> selectivity;
+    };
+    std::vector<Child> children;
+    children.reserve(predicate->Children().size());
+    for (const auto& child : predicate->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> selectivity, 
EstimateSelectivity(child));
+        children.push_back({child, selectivity});
+    }
+    std::stable_sort(children.begin(), children.end(), [](const Child& lhs, 
const Child& rhs) {
+        if (!lhs.selectivity) {
+            return false;
+        }
+        return !rhs.selectivity || *lhs.selectivity < *rhs.selectivity;
+    });
+
+    Evaluation result{candidates, {}};
+    for (const auto& child : children) {
+        PAIMON_ASSIGN_OR_RAISE(Evaluation child_result, 
Evaluate(child.predicate, result.rows));
+        result.rows = std::move(child_result.rows);
+        result.scores.insert(child_result.scores.begin(), 
child_result.scores.end());
+        if (result.rows.IsEmpty()) {
+            break;
+        }
+    }
+    return result;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateSelectivity(
+    const std::shared_ptr<Predicate>& predicate) const {
+    if (!predicate) {
+        return std::optional<double>();
+    }
+    if (auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> estimate, 
EstimateLeafSelectivity(leaf));
+        if (estimate && (!std::isfinite(*estimate) || *estimate < 0.0 || 
*estimate > 1.0)) {
+            return std::optional<double>();
+        }
+        return estimate;
+    }
+    auto compound = std::dynamic_pointer_cast<CompoundPredicate>(predicate);
+    if (!compound) {
+        return Status::Invalid(fmt::format("unsupported file predicate {}", 
predicate->ToString()));
+    }
+    const Function::Type type = compound->GetFunction().GetType();
+    if (type != Function::Type::AND && type != Function::Type::OR) {
+        return Status::Invalid(
+            fmt::format("unsupported compound file predicate {}", 
predicate->ToString()));
+    }
+    std::optional<double> estimate =
+        type == Function::Type::AND ? std::nullopt : std::make_optional(0.0);
+    // These are scheduling hints. Continue visiting all children to propagate 
errors.
+    for (const auto& child : compound->Children()) {
+        PAIMON_ASSIGN_OR_RAISE(std::optional<double> child_estimate, 
EstimateSelectivity(child));
+        if (type == Function::Type::AND) {
+            if (child_estimate) {
+                if (estimate.has_value()) {
+                    estimate = std::min(*estimate, *child_estimate);
+                } else {
+                    estimate = *child_estimate;
+                }
+            }
+        } else if (estimate && child_estimate) {
+            estimate = std::min(1.0, *estimate + *child_estimate);
+        } else {
+            estimate = std::nullopt;
+        }
+    }
+    return estimate;
+}
+
+Result<std::optional<double>> PreciseFileIndexRead::EstimateLeafSelectivity(
+    const std::shared_ptr<LeafPredicate>& predicate) const {
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (!iter->second) {
+        return std::optional<double>();
+    }
+    return indexes_->EstimateSelectivity(iter->second);
+}
+
+Result<PreciseFileIndexRead::Evaluation> PreciseFileIndexRead::EvaluateLeaf(
+    const std::shared_ptr<LeafPredicate>& predicate, const RoaringBitmap32& 
candidates) const {
+    bool is_full_text = predicate->GetFunction().GetType() == 
Function::Type::FULL_TEXT_MATCH;
+    bool needs_scores =
+        is_full_text &&
+        split_read_.context_->NeedsScores(
+            {InternalReadContext::IndexScoreSource::Type::FULL_TEXT_MATCH, 
predicate->FieldName()});
+    std::shared_ptr<FileIndexResult> index_result = FileIndexResult::Remain();
+    auto iter = mapped_leaves_.find(predicate);
+    if (iter == mapped_leaves_.end()) {
+        return Status::Invalid("File predicate mapping was not prepared");
+    }
+    if (iter->second) {
+        // TODO(lisizhuo.lsz): Pass candidates through 
FileIndexEvaluator::EvaluateLeaf to index
+        // readers so they can skip rows excluded by earlier predicates, row 
ranges, or deletion
+        // vectors.
+        PAIMON_ASSIGN_OR_RAISE(index_result, 
indexes_->EvaluateLeaf(iter->second, needs_scores));
+    }
+    if (!index_result) {
+        return Status::Invalid("file predicate index returned a null result");
+    }
+    Evaluation result{candidates, {}};
+    if (needs_scores) {
+        auto scored = 
std::dynamic_pointer_cast<ScoredFileIndexResult>(index_result);
+        if (!scored) {
+            return Status::Invalid(
+                fmt::format("full-text index for field '{}' did not return 
requested scores",
+                            predicate->FieldName()));
+        }
+        result.rows &= scored->GetRowPositions();
+        result.scores.emplace(predicate->FieldName(), std::move(scored));
+        return result;
+    }
+
+    if (auto bitmap = 
std::dynamic_pointer_cast<BitmapIndexResult>(index_result)) {
+        PAIMON_ASSIGN_OR_RAISE(const RoaringBitmap32* rows, 
bitmap->GetBitmap());
+        result.rows &= *rows;
+    } else if (is_full_text) {
+        return Status::Invalid(fmt::format("full-text index for field '{}' did 
not return a bitmap",
+                                           predicate->FieldName()));
+    } else if (std::dynamic_pointer_cast<Skip>(index_result)) {
+        return Evaluation{};
+    } else if (!std::dynamic_pointer_cast<Remain>(index_result)) {
+        return Status::Invalid(
+            fmt::format("invalid index result for predicate {}", 
predicate->ToString()));
+    }
+    // TODO(lisizhuo.lsz): Whether to ScanScalar depends on whether the index 
returns exact results.
+    if (!is_full_text && !result.rows.IsEmpty()) {
+        // Scalar indexes may contain false positives, even if they return a 
bitmap.
+        PAIMON_ASSIGN_OR_RAISE(RoaringBitmap32 exact_rows, 
ScanScalar(predicate, result.rows));
+        result.rows &= exact_rows;
+    }
+    return result;
+}
+
+Result<std::unique_ptr<FileBatchReader>> 
PreciseFileIndexRead::CreateSelectedFileReader(
+    const RoaringBitmap32& selection, const FieldMappingBuilder* 
field_mapping_builder) const {
+    PAIMON_ASSIGN_OR_RAISE(std::string format, file_->FileFormat());
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReaderBuilder> builder,
+                           split_read_.PrepareReaderBuilder(format, 
extra_format_options_));
+    return split_read_.CreateFieldMappingReader(path_factory_->ToPath(file_), 
file_, partition_,
+                                                std::move(builder), 
field_mapping_builder, nullptr,
+                                                std::nullopt, path_factory_, 
&selection);
+}
+
+Result<RoaringBitmap32> PreciseFileIndexRead::ScanScalar(
+    const std::shared_ptr<LeafPredicate>& predicate, const RoaringBitmap32& 
input) const {
+    auto field = 
split_read_.context_->GetReadSchema()->GetFieldByName(predicate->FieldName());
+    if (!field) {
+        return Status::Invalid(fmt::format(
+            "Scalar predicate field '{}' is missing from the read schema", 
predicate->FieldName()));
+    }
+    // TODO(lisizhuo.lsz): Push the scalar predicate to use min/max statistics 
for pruning row
+    // groups/pages.
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<FieldMappingBuilder> field_mapping_builder,
+        FieldMappingBuilder::Create(arrow::schema({field}),
+                                    split_read_.context_->GetPartitionKeys(), 
nullptr));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileBatchReader> reader,
+                           CreateSelectedFileReader(input, 
field_mapping_builder.get()));
+    FileBatchReader& file_reader = *reader;
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<PredicateBatchReader> filtered_reader,
+        PredicateBatchReader::Create(std::move(reader), predicate,
+                                     
split_read_.context_->GetArrowMemoryPool()));
+    ScopeGuard close([&]() { filtered_reader->Close(); });

Review Comment:
   The scalar recheck reader can scan an entire column and then be closed here 
without its I/O metrics reaching `GetReaderMetrics()` on the returned reader. 
If the predicate eliminates every row, the caller can observe zero file-read 
I/O after a full scan. Please collect the temporary reader's metrics and carry 
them into the split-level metrics, including the case where no output reader is 
created. An all-filtered regression would cover the easiest case to miss.



##########
src/paimon/core/io/file_index_evaluator.cpp:
##########
@@ -52,64 +54,165 @@ namespace paimon {
 class MemoryPool;
 enum class FieldType;
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
+FileIndexEvaluator::FileIndexEvaluator(std::shared_ptr<arrow::Schema> 
data_schema,
+                                       std::shared_ptr<DataFileMeta> file_meta,
+                                       
std::unique_ptr<FileIndexFormat::Reader> format_reader)
+    : data_schema_(std::move(data_schema)),
+      file_meta_(std::move(file_meta)),
+      format_reader_(std::move(format_reader)) {}
+
+Result<std::unique_ptr<FileIndexEvaluator>> FileIndexEvaluator::Create(
     const std::shared_ptr<arrow::Schema>& data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
+    const std::map<std::string, std::string>& options,
+    const std::shared_ptr<DataFilePathFactory>& path_factory,
     const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<FileSystem>& file_system,
     const std::shared_ptr<MemoryPool>& pool) {
-    return Evaluate(/*only_use_embedding_index=*/false, data_schema, options, 
predicate,
-                    data_file_path_factory, file_meta, file_system, pool);
+    std::unique_ptr<FileIndexFormat::Reader> format_reader;
+    if (file_meta->row_count >= 0 && file_meta->row_count <= 
RoaringBitmap32::MAX_VALUE) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
+                               ExtractIndexInputStream(path_factory, 
file_meta, file_system));
+        if (input_stream) {
+            PAIMON_ASSIGN_OR_RAISE(format_reader,
+                                   FileIndexFormat::CreateReader(input_stream, 
pool, options));
+        }
+    }
+    return std::unique_ptr<FileIndexEvaluator>(
+        new FileIndexEvaluator(data_schema, file_meta, 
std::move(format_reader)));
 }
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
-    const std::shared_ptr<arrow::Schema>& data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<MemoryPool>& pool) {
-    return Evaluate(/*only_use_embedding_index=*/true, data_schema, options, 
predicate,
-                    /*data_file_path_factory=*/nullptr, file_meta, 
/*file_system=*/nullptr, pool);
+Result<std::vector<std::shared_ptr<FileIndexReader>>> 
FileIndexEvaluator::GetReaders(
+    const std::string& field_name) {
+    auto iter = field_readers_.find(field_name);
+    if (iter != field_readers_.end()) {
+        return iter->second;
+    }
+    std::shared_ptr<arrow::Field> field = 
data_schema_->GetFieldByName(field_name);
+    if (!field) {
+        return Status::Invalid(fmt::format("field '{}' does not exist in data 
schema", field_name));
+    }
+    std::vector<std::shared_ptr<FileIndexReader>> readers;
+    if (format_reader_) {
+        ::ArrowSchema c_schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(
+            arrow::ExportSchema(*arrow::schema({std::move(field)}), 
&c_schema));
+        PAIMON_ASSIGN_OR_RAISE(readers, 
format_reader_->ReadColumnIndex(field_name, &c_schema));
+    }
+    field_readers_.emplace(field_name, readers);
+    return readers;
 }
 
-Result<std::shared_ptr<FileIndexResult>> FileIndexEvaluator::Evaluate(
-    bool only_use_embedding_index, const std::shared_ptr<arrow::Schema>& 
data_schema,
-    const std::map<std::string, std::string>& options, const 
std::shared_ptr<Predicate>& predicate,
-    const std::shared_ptr<DataFilePathFactory>& data_file_path_factory,
-    const std::shared_ptr<DataFileMeta>& file_meta, const 
std::shared_ptr<FileSystem>& file_system,
-    const std::shared_ptr<MemoryPool>& pool) {
-    if (predicate == nullptr || file_meta->row_count > 
RoaringBitmap32::MAX_VALUE) {
-        return FileIndexResult::Remain();
+Result<std::shared_ptr<FileIndexReader>> FileIndexEvaluator::GetSearchReader(
+    const std::string& field_name, FileIndexReader::SearchType search_type) {
+    if (!data_schema_->GetFieldByName(field_name)) {
+        return Status::Invalid(
+            fmt::format("Search field '{}' does not exist in data schema", 
field_name));
+    }
+    if (!format_reader_) {
+        return Status::Invalid(fmt::format("Data file '{}' has no File Index 
for search field '{}'",
+                                           file_meta_->file_name, field_name));
+    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<FileIndexReader>> 
readers,
+                           GetReaders(field_name));
+    std::shared_ptr<FileIndexReader> selected;
+    for (const auto& reader : readers) {
+        if (!reader->SupportsSearch(search_type)) {
+            continue;
+        }
+        if (selected) {
+            return Status::Invalid(fmt::format(
+                "Multiple File Index readers support requested search for 
field '{}'", field_name));
+        }
+        selected = reader;
     }
-    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
-                           ExtractIndexInputStream(only_use_embedding_index, 
data_file_path_factory,
-                                                   file_meta, file_system));
-    if (!input_stream) {
-        // no index files, just return REMAIN
+    if (!selected) {
+        return Status::NotImplemented(fmt::format(
+            "No File Index reader supports requested search for field '{}'", 
field_name));

Review Comment:
   Please include `file_meta_->file_name` and the requested search operation in 
this error and the multiple-reader error above. If one file in a multi-file 
split lacks the required backend, the field name alone leaves the caller unable 
to identify the file or distinguish `FULL_TEXT_MATCH` from `FULL_TEXT_SEARCH`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to