This is an automated email from the ASF dual-hosted git repository.

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new d21122ec feat(predicate): add full-text match and score projection 
semantics (#451)
d21122ec is described below

commit d21122ecb44f8bc0ffa1eea33a018179ba5b993a
Author: lszskye <[email protected]>
AuthorDate: Fri Oct 23 09:44:43 2026 +0800

    feat(predicate): add full-text match and score projection semantics (#451)
---
 docs/source/api/file_index.rst                     |  67 +++++
 include/paimon/file_index/file_index_reader.h      |   5 +
 include/paimon/predicate/full_text_match.h         |  51 ++++
 include/paimon/predicate/function.h                |   3 +-
 include/paimon/predicate/function_visitor.h        |  10 +
 include/paimon/predicate/leaf_predicate.h          |   3 +-
 include/paimon/predicate/predicate_builder.h       |  10 +
 include/paimon/predicate/predicate_utils.h         |  11 +
 include/paimon/read_context.h                      |  34 ++-
 src/paimon/CMakeLists.txt                          |   2 +
 src/paimon/common/file_index/file_index_reader.cpp |   5 +
 .../common/file_index/file_index_reader_test.cpp   |  38 +++
 .../common/predicate/full_text_match_function.h    |  67 +++++
 .../common/predicate/full_text_match_predicate.cpp |  64 ++++
 .../common/predicate/full_text_match_predicate.h   |  53 ++++
 .../predicate/full_text_match_predicate_test.cpp   | 148 +++++++++
 src/paimon/common/predicate/predicate_builder.cpp  |  16 +
 src/paimon/common/predicate/predicate_utils.cpp    |  32 ++
 .../common/predicate/predicate_utils_test.cpp      |  43 +++
 .../common/predicate/predicate_validator_test.cpp  |  43 +++
 src/paimon/common/table/special_fields.h           |  18 +-
 src/paimon/common/table/special_fields_test.cpp    |  13 +
 src/paimon/core/operation/file_store_scan.cpp      |   3 +
 src/paimon/core/operation/file_store_scan_test.cpp |  24 ++
 .../core/operation/internal_read_context.cpp       | 101 ++++++-
 src/paimon/core/operation/internal_read_context.h  |  31 +-
 .../core/operation/internal_read_context_test.cpp  | 329 +++++++++++++++++++++
 src/paimon/core/operation/read_context.cpp         |  18 ++
 src/paimon/core/operation/read_context_test.cpp    |  37 +++
 src/paimon/core/table/source/table_read_test.cpp   |  52 ++++
 src/paimon/core/table/table.cpp                    |  14 +
 src/paimon/core/utils/field_mapping.cpp            |  26 +-
 src/paimon/core/utils/field_mapping_test.cpp       |  43 +++
 test/inte/predicate_abi_inte_test.cpp              |  21 ++
 34 files changed, 1407 insertions(+), 28 deletions(-)

diff --git a/docs/source/api/file_index.rst b/docs/source/api/file_index.rst
index e95cd26f..b6a0eb14 100644
--- a/docs/source/api/file_index.rst
+++ b/docs/source/api/file_index.rst
@@ -21,9 +21,76 @@ File Index
 
 .. _cpp-api-file-index:
 
+Full-text predicates and root TopK
+=================================
+
+``PredicateBuilder::FullTextMatch`` creates a ``LeafPredicate`` for a full-text
+condition without TopK truncation. It holds a shared ``FullTextMatch`` request,
+whose query uses the same JSON DSL as ``FullTextSearch``. Do not modify a 
request
+after passing it to a predicate or read context.
+
+Compose full-text and scalar predicates with the existing
+``PredicateBuilder::And`` and ``PredicateBuilder::Or`` factories. Every field
+referenced by this tree must appear in the read schema; field indices refer to
+positions in that schema. For example, the following constructs
+``body matches "apple" OR price < 10``:
+
+.. code-block:: cpp
+
+   auto request = std::make_shared<paimon::FullTextMatch>(
+       "body", R"({"match":{"query":"apple"}})");
+   PAIMON_ASSIGN_OR_RAISE(
+       std::shared_ptr<paimon::Predicate> text,
+       paimon::PredicateBuilder::FullTextMatch(0, request));
+   auto price = paimon::PredicateBuilder::LessThan(
+       1, "price", paimon::FieldType::INT, paimon::Literal(10));
+   PAIMON_ASSIGN_OR_RAISE(
+       std::shared_ptr<paimon::Predicate> filter,
+       paimon::PredicateBuilder::Or({text, price}));
+   paimon::ReadContextBuilder builder(table_path);
+   builder.SetReadFieldNames({"body", "price"}).SetPredicate(filter);
+
+``SetVectorSearch`` and ``SetFullTextSearch`` configure a separate root TopK
+request. They are mutually exclusive and require a positive limit. The intended
+semantics are to filter by the complete predicate before ranking; the TopK 
search
+field does not need to be projected. A full-text match inside a predicate never
+has a TopK limit.
+
+Score output is requested through an explicit ``_<field>_INDEX_SCORE``
+projection. For example, request the score of a full-text
+predicate by projecting ``_body_INDEX_SCORE``:
+
+.. code-block:: cpp
+
+   auto request = std::make_shared<paimon::FullTextMatch>(
+       "body", R"({"match":{"query":"apple"}})");
+   PAIMON_ASSIGN_OR_RAISE(
+       std::shared_ptr<paimon::Predicate> text,
+       paimon::PredicateBuilder::FullTextMatch(0, request));
+   paimon::ReadContextBuilder builder(table_path);
+   builder.SetReadFieldNames({"body", "_body_INDEX_SCORE"}).SetPredicate(text);
+
+Score fields have type ``float32`` and preserve the source field's exact name,
+including case. Select them with ``SetReadFieldNames`` or ``SetReadSchema``;
+they have no persistent field IDs for ``SetReadFieldIds``. Reading all table
+fields does not implicitly add score fields.
+
+Each projected score must have exactly one search condition as its source.
+The source may be a root TopK request or a full-text predicate. A full-text
+predicate whose score is projected cannot have an OR ancestor, and its field
+cannot occur in another search condition, including the root TopK request.
+An ordinary scalar predicate on the same field is allowed. For example, when
+projecting the score of ``match``, ``match AND (p OR q)`` is valid, while
+``match OR p`` is rejected. Without that score projection, both OR branches and
+repeated full-text search fields are allowed. These checks use the effective
+read projection, after resolving the read schema.
+
 Interface
 =========
 
+.. doxygenstruct:: paimon::FullTextMatch
+   :members:
+
 .. doxygenclass:: paimon::FileIndexer
    :members:
    :undoc-members:
diff --git a/include/paimon/file_index/file_index_reader.h 
b/include/paimon/file_index/file_index_reader.h
index e6cb39c7..94f758ea 100644
--- a/include/paimon/file_index/file_index_reader.h
+++ b/include/paimon/file_index/file_index_reader.h
@@ -31,6 +31,7 @@
 #include "paimon/visibility.h"
 
 namespace paimon {
+struct FullTextMatch;
 /// Evaluates filter predicates against a file-level index to determine file 
eligibility.
 ///
 /// `FileIndexReader` implements the `FunctionVisitor` interface specialized 
to produce
@@ -79,6 +80,10 @@ class PAIMON_EXPORT FileIndexReader : public 
FunctionVisitor<std::shared_ptr<Fil
     /// Execute full-text search and return matching file-local physical row 
positions.
     virtual Result<std::shared_ptr<FileIndexResult>> VisitFullTextSearch(
         const std::shared_ptr<FullTextSearch>& full_text_search);
+
+    /// Return every matching file-local physical row position, without TopK 
truncation.
+    Result<std::shared_ptr<FileIndexResult>> VisitFullTextMatch(
+        const std::shared_ptr<FullTextMatch>& full_text_match) override;
 };
 
 }  // namespace paimon
diff --git a/include/paimon/predicate/full_text_match.h 
b/include/paimon/predicate/full_text_match.h
new file mode 100644
index 00000000..51c37536
--- /dev/null
+++ b/include/paimon/predicate/full_text_match.h
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "paimon/utils/roaring_bitmap64.h"
+#include "paimon/visibility.h"
+
+namespace paimon {
+
+/// A full-text condition returning every matching row without TopK truncation.
+/// Project `_<field>_INDEX_SCORE` in the read schema to return its scores.
+struct PAIMON_EXPORT FullTextMatch {
+    FullTextMatch(const std::string& _field_name, const std::string& _query,
+                  std::optional<RoaringBitmap64> _include_row_ids = 
std::nullopt)
+        : field_name(_field_name), query(_query), 
include_row_ids(std::move(_include_row_ids)) {}
+
+    bool operator==(const FullTextMatch& other) const {
+        return field_name == other.field_name && query == other.query &&
+               include_row_ids == other.include_row_ids;
+    }
+
+    /// Table column containing the indexed text.
+    std::string field_name;
+    /// JSON DSL query with the same syntax and analyzer semantics as 
`FullTextSearch::query`.
+    std::string query;
+    /// Optional eligible row ids. File readers use file-local physical row 
positions;
+    /// An empty set matches no rows. If not set, all rows are searched.
+    std::optional<RoaringBitmap64> include_row_ids;
+};
+
+}  // namespace paimon
diff --git a/include/paimon/predicate/function.h 
b/include/paimon/predicate/function.h
index f383f743..5d84f5c1 100644
--- a/include/paimon/predicate/function.h
+++ b/include/paimon/predicate/function.h
@@ -43,7 +43,8 @@ class PAIMON_EXPORT Function {
         STARTS_WITH = 13,
         ENDS_WITH = 14,
         CONTAINS = 15,
-        LIKE = 16
+        LIKE = 16,
+        FULL_TEXT_MATCH = 17
     };
     virtual ~Function() = default;
     virtual Type GetType() const = 0;
diff --git a/include/paimon/predicate/function_visitor.h 
b/include/paimon/predicate/function_visitor.h
index 8b759ff2..5e8b54b3 100644
--- a/include/paimon/predicate/function_visitor.h
+++ b/include/paimon/predicate/function_visitor.h
@@ -18,15 +18,19 @@
 
 #pragma once
 
+#include <memory>
 #include <string>
 #include <vector>
 
 #include "paimon/predicate/leaf_predicate.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/result.h"
+#include "paimon/status.h"
 #include "paimon/visibility.h"
 
 namespace paimon {
+struct FullTextMatch;
+
 /// A visitor interface for evaluating filter predicates on indexed columns.
 /// @tparam T The result type produced by each visit method (e.g., a file 
index result or global
 /// index result).
@@ -76,5 +80,11 @@ class PAIMON_EXPORT FunctionVisitor {
 
     /// Evaluates whether string values like the given string.
     virtual Result<T> VisitLike(const Literal& literal) = 0;
+
+    /// Evaluates a full-text condition without TopK truncation.
+    /// Visitors without full-text support return NotImplemented.
+    virtual Result<T> VisitFullTextMatch(const std::shared_ptr<FullTextMatch>& 
full_text_match) {
+        return Status::NotImplemented("This visitor does not support complete 
full-text matching");
+    }
 };
 }  // namespace paimon
diff --git a/include/paimon/predicate/leaf_predicate.h 
b/include/paimon/predicate/leaf_predicate.h
index 29698487..b57d8173 100644
--- a/include/paimon/predicate/leaf_predicate.h
+++ b/include/paimon/predicate/leaf_predicate.h
@@ -31,7 +31,8 @@ class LeafFunction;
 class Function;
 enum class FieldType;
 
-/// Leaf node of a `Predicate` tree. Compares a field with literals.
+/// Leaf node of a `Predicate` tree describing a condition on one field.
+/// Scalar leaves compare the field with literals; full-text leaves hold a 
search request.
 class PAIMON_EXPORT LeafPredicate : virtual public Predicate {
  public:
     int32_t FieldIndex() const {
diff --git a/include/paimon/predicate/predicate_builder.h 
b/include/paimon/predicate/predicate_builder.h
index c8cdf13d..4c829408 100644
--- a/include/paimon/predicate/predicate_builder.h
+++ b/include/paimon/predicate/predicate_builder.h
@@ -27,6 +27,7 @@
 #include "paimon/visibility.h"
 
 namespace paimon {
+struct FullTextMatch;
 class Literal;
 enum class FieldType;
 
@@ -39,6 +40,15 @@ class PAIMON_EXPORT PredicateBuilder {
     PredicateBuilder() = delete;
     ~PredicateBuilder() = delete;
 
+    /// Create a full-text predicate returning all matching rows without TopK 
truncation.
+    /// The field must have STRING type and a full-text index.
+    /// Project `_<field>_INDEX_SCORE` in the read schema to request score 
output.
+    /// @param field_index The index of the field in read schema (0-based).
+    /// @param match The full-text request, including the field name, JSON DSL 
query and
+    /// optional eligible row ids.
+    static Result<std::shared_ptr<Predicate>> FullTextMatch(
+        int32_t field_index, const std::shared_ptr<FullTextMatch>& match);
+
     /// Create an equality predicate (field == literal).
     ///
     /// @param field_index The index of the field in read schema (0-based).
diff --git a/include/paimon/predicate/predicate_utils.h 
b/include/paimon/predicate/predicate_utils.h
index cef3b5fa..24cc47c0 100644
--- a/include/paimon/predicate/predicate_utils.h
+++ b/include/paimon/predicate/predicate_utils.h
@@ -61,6 +61,9 @@ class PAIMON_EXPORT PredicateUtils {
     static Status GetAllNames(const std::shared_ptr<Predicate>& predicate,
                               std::set<std::string>* field_names);
 
+    /// Whether the predicate contains a full-text matching condition.
+    static bool ContainsFullTextMatch(const std::shared_ptr<Predicate>& 
predicate);
+
     template <typename T>
     static Result<T> VisitPredicate(const std::shared_ptr<LeafPredicate>& 
predicate,
                                     const std::shared_ptr<FunctionVisitor<T>>& 
visitor) {
@@ -114,6 +117,11 @@ class PAIMON_EXPORT PredicateUtils {
                 assert(predicate->Literals().size() == 1);
                 return visitor->VisitLike(predicate->Literals()[0]);
             }
+            case Function::Type::FULL_TEXT_MATCH: {
+                PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FullTextMatch> 
full_text_match,
+                                       GetFullTextMatch(predicate));
+                return visitor->VisitFullTextMatch(full_text_match);
+            }
             default:
                 return Status::Invalid("invalid " + 
predicate->GetFunction().ToString() +
                                        " function in leaf predicate");
@@ -121,6 +129,9 @@ class PAIMON_EXPORT PredicateUtils {
     }
 
  private:
+    static Result<std::shared_ptr<FullTextMatch>> GetFullTextMatch(
+        const std::shared_ptr<LeafPredicate>& predicate);
+
     static Result<std::optional<std::shared_ptr<Predicate>>> 
ReconstructPredicateWithPickedFields(
         const std::shared_ptr<Predicate>& predicate,
         const std::map<std::string, int32_t>& picked_field_name_to_idx);
diff --git a/include/paimon/read_context.h b/include/paimon/read_context.h
index 28305c94..e62386c6 100644
--- a/include/paimon/read_context.h
+++ b/include/paimon/read_context.h
@@ -108,6 +108,7 @@ class PAIMON_EXPORT ReadContext {
         return full_text_search_;
     }
 
+    /// Whether a root TopK request is configured; full-text predicates are 
not TopK requests.
     bool HasVectorOrFullTextSearch() const {
         return vector_search_ != nullptr || full_text_search_ != nullptr;
     }
@@ -346,24 +347,37 @@ class PAIMON_EXPORT ReadContextBuilder {
     /// It can significantly improve performance by reducing the amount of data
     /// that needs to be read and processed.
     ///
+    /// All referenced fields, including full-text match fields, must be 
projected in the read
+    /// schema.
+    /// TODO(lisizhuo.lsz): The behavior here will be aligned with Java Paimon 
in a follow-up
+    /// change: fields referenced in predicates will no longer be required to 
appear in the
+    /// projection. Full-text matches can be combined with scalar predicates 
using
+    /// PredicateBuilder::And/Or. Full-text predicate execution is not 
implemented yet; reading such
+    /// a predicate returns NotImplemented.
+    ///
     /// @param predicate Shared pointer to the predicate for data filtering.
     /// @return Reference to this builder for method chaining.
     ReadContextBuilder& SetPredicate(const std::shared_ptr<Predicate>& 
predicate);
 
-    /// Set the vector search request used when reading data.
-    ///
-    /// TODO(jinli.zjw): Support combining vector search with predicates and 
full-text search. Merge
-    /// per-file candidates to compute read-level top-k results.
-    ///
+    /// Configure the root vector TopK request, logically applied after the 
predicate filter.
+    /// The search field need not be projected. SetVectorSearch is mutually 
exclusive with
+    /// SetFullTextSearch and must have a positive limit.
+    /// Scores are required internally for TopK. Project 
`_<field>_INDEX_SCORE` to include them
+    /// in the result. Per-field score output is not implemented yet; reading 
such a projection
+    /// returns NotImplemented.
+    /// TODO(lisizhuo.lsz): Execute combined read predicates and merge TopK 
across files.
     /// @param vector_search Vector search request, or nullptr to clear it.
     /// @return Reference to this builder for method chaining.
     ReadContextBuilder& SetVectorSearch(const std::shared_ptr<VectorSearch>& 
vector_search);
 
-    /// Set the full-text search request used when reading data.
-    ///
-    /// TODO(jinli.zjw): Support combining full-text search with predicates 
and vector search. Apply
-    /// the search limit to merged per-file results at read level.
-    ///
+    /// Configure the root full-text TopK request, logically applied after the 
predicate filter.
+    /// The search field need not be projected. Use 
PredicateBuilder::FullTextMatch for a
+    /// full-text filter without TopK truncation. SetFullTextSearch is 
mutually exclusive with
+    /// SetVectorSearch and must have a positive limit.
+    /// Scores are required internally for TopK. Project 
`_<field>_INDEX_SCORE` to include them
+    /// in the result. Per-field score output is not implemented yet; reading 
such a projection
+    /// returns NotImplemented.
+    /// TODO(lisizhuo.lsz): Execute combined read predicates and merge TopK 
across files.
     /// @param full_text_search Full-text search request, or nullptr to clear 
it.
     /// @return Reference to this builder for method chaining.
     ReadContextBuilder& SetFullTextSearch(const 
std::shared_ptr<FullTextSearch>& full_text_search);
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index b3d1a1e2..33075614 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -118,6 +118,7 @@ set(PAIMON_COMMON_SRCS
     common/predicate/contains.cpp
     common/predicate/ends_with.cpp
     common/predicate/equal.cpp
+    common/predicate/full_text_match_predicate.cpp
     common/predicate/greater_or_equal.cpp
     common/predicate/greater_than.cpp
     common/predicate/in.cpp
@@ -673,6 +674,7 @@ if(PAIMON_BUILD_TESTS)
                     common/metrics/histogram_windowing_test.cpp
                     common/options/memory_size_test.cpp
                     common/options/time_duration_test.cpp
+                    common/predicate/full_text_match_predicate_test.cpp
                     common/predicate/full_text_search_test.cpp
                     common/predicate/literal_converter_test.cpp
                     common/predicate/literal_test.cpp
diff --git a/src/paimon/common/file_index/file_index_reader.cpp 
b/src/paimon/common/file_index/file_index_reader.cpp
index 9350e23f..7151c5e5 100644
--- a/src/paimon/common/file_index/file_index_reader.cpp
+++ b/src/paimon/common/file_index/file_index_reader.cpp
@@ -111,4 +111,9 @@ Result<std::shared_ptr<FileIndexResult>> 
FileIndexReader::VisitFullTextSearch(
     const std::shared_ptr<FullTextSearch>& full_text_search) {
     return Status::NotImplemented("This file index does not support full text 
search");
 }
+
+Result<std::shared_ptr<FileIndexResult>> FileIndexReader::VisitFullTextMatch(
+    const std::shared_ptr<FullTextMatch>& full_text_match) {
+    return Status::NotImplemented("This file index does not support complete 
full-text matching");
+}
 }  // namespace paimon
diff --git a/src/paimon/common/file_index/file_index_reader_test.cpp 
b/src/paimon/common/file_index/file_index_reader_test.cpp
index 86745c3e..40a25559 100644
--- a/src/paimon/common/file_index/file_index_reader_test.cpp
+++ b/src/paimon/common/file_index/file_index_reader_test.cpp
@@ -21,8 +21,11 @@
 #include <utility>
 
 #include "gtest/gtest.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/full_text_search.h"
 #include "paimon/predicate/literal.h"
+#include "paimon/predicate/predicate_builder.h"
+#include "paimon/predicate/predicate_utils.h"
 #include "paimon/predicate/vector_search.h"
 #include "paimon/testing/utils/testharness.h"
 
@@ -75,5 +78,40 @@ TEST(FileIndexReaderTest, TestDefaultIndexReader) {
         std::make_shared<FullTextSearch>("body", 
R"({"match":{"query":"word"}})", /*limit=*/1);
     ASSERT_NOK_WITH_MSG(reader.VisitFullTextSearch(full_text_search),
                         "does not support full text search");
+    auto full_text_match = std::make_shared<FullTextMatch>("body", 
R"({"match":{"query":"word"}})");
+    ASSERT_NOK_WITH_MSG(reader.VisitFullTextMatch(full_text_match),
+                        "does not support complete full-text matching");
+}
+
+TEST(FileIndexReaderTest, TestFullTextMatchVisitorDispatch) {
+    class FullTextReader : public FileIndexReader {
+     public:
+        Result<std::shared_ptr<FileIndexResult>> VisitFullTextMatch(
+            const std::shared_ptr<FullTextMatch>& full_text_match) override {
+            received_match_ = full_text_match;
+            return FileIndexResult::Skip();
+        }
+
+        std::shared_ptr<FullTextMatch> received_match_;
+    };
+
+    auto match = std::make_shared<FullTextMatch>("body", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(auto predicate, PredicateBuilder::FullTextMatch(0, 
match));
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    ASSERT_TRUE(leaf);
+
+    auto default_reader = std::make_shared<FileIndexReader>();
+    auto result =
+        PredicateUtils::VisitPredicate<std::shared_ptr<FileIndexResult>>(leaf, 
default_reader);
+    ASSERT_NOK_WITH_MSG(result, "This file index does not support complete 
full-text matching");
+    ASSERT_TRUE(result.status().IsNotImplemented());
+
+    auto reader = std::make_shared<FullTextReader>();
+    ASSERT_OK_AND_ASSIGN(
+        auto matches,
+        PredicateUtils::VisitPredicate<std::shared_ptr<FileIndexResult>>(leaf, 
reader));
+    ASSERT_EQ(reader->received_match_, match);
+    ASSERT_OK_AND_ASSIGN(bool is_remain, matches->IsRemain());
+    ASSERT_FALSE(is_remain);
 }
 }  // namespace paimon::test
diff --git a/src/paimon/common/predicate/full_text_match_function.h 
b/src/paimon/common/predicate/full_text_match_function.h
new file mode 100644
index 00000000..6a36b4ac
--- /dev/null
+++ b/src/paimon/common/predicate/full_text_match_function.h
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include "paimon/common/predicate/leaf_function.h"
+
+namespace paimon {
+
+/// Identifies an index-dependent predicate without implementing scalar 
evaluation.
+class FullTextMatchFunction : public LeafFunction {
+ public:
+    static const FullTextMatchFunction& Instance() {
+        static const FullTextMatchFunction instance;
+        return instance;
+    }
+
+    Result<std::vector<char>> Test(const arrow::Array& array, const 
std::vector<Literal>& literals,
+                                   arrow::MemoryPool* pool) const override {
+        return Status::NotImplemented(
+            "FullTextMatch does not support scalar Test; a full-text index is 
required");
+    }
+
+    Result<bool> Test(const Literal& value, const std::vector<Literal>& 
literals) const override {
+        return Status::NotImplemented(
+            "FullTextMatch does not support scalar Test; a full-text index is 
required");
+    }
+
+    Result<bool> Test(int64_t row_count, const Literal& min_value, const 
Literal& max_value,
+                      const std::optional<int64_t>& null_count,
+                      const std::vector<Literal>& literals) const override {
+        return Status::NotImplemented(
+            "FullTextMatch does not support statistics Test; a full-text index 
is required");
+    }
+
+    const LeafFunction* Negate() const override {
+        return nullptr;
+    }
+
+    Type GetType() const override {
+        return Type::FULL_TEXT_MATCH;
+    }
+
+    std::string ToString() const override {
+        return "FullTextMatch";
+    }
+
+ private:
+    FullTextMatchFunction() = default;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/common/predicate/full_text_match_predicate.cpp 
b/src/paimon/common/predicate/full_text_match_predicate.cpp
new file mode 100644
index 00000000..6f481c0c
--- /dev/null
+++ b/src/paimon/common/predicate/full_text_match_predicate.cpp
@@ -0,0 +1,64 @@
+/*
+ * 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/common/predicate/full_text_match_predicate.h"
+
+#include "fmt/format.h"
+#include "paimon/common/predicate/full_text_match_function.h"
+#include "paimon/defs.h"
+#include "paimon/predicate/full_text_match.h"
+
+namespace paimon {
+
+FullTextMatchPredicate::FullTextMatchPredicate(int32_t field_index,
+                                               const 
std::shared_ptr<FullTextMatch>& match)
+    : LeafPredicate(FullTextMatchFunction::Instance(), field_index, 
match->field_name,
+                    FieldType::STRING, {}),
+      full_text_match_(match) {}
+
+bool FullTextMatchPredicate::operator==(const Predicate& other) const {
+    if (this == &other) {
+        return true;
+    }
+    auto text_predicate = 
dynamic_cast<FullTextMatchPredicate*>(const_cast<Predicate*>(&other));
+    if (!text_predicate) {
+        return false;
+    }
+    return LeafPredicate::operator==(other) &&
+           *full_text_match_ == *text_predicate->full_text_match_;
+}
+
+std::string FullTextMatchPredicate::ToString() const {
+    return fmt::format(
+        "FullTextMatch({}, {}, include_row_ids={})", FieldName(), 
full_text_match_->query,
+        full_text_match_->include_row_ids ? 
full_text_match_->include_row_ids->ToString() : "null");
+}
+
+std::shared_ptr<FullTextMatchPredicate> 
FullTextMatchPredicate::NewLeafPredicate(
+    int32_t new_field_index) const {
+    return std::make_shared<FullTextMatchPredicate>(new_field_index, 
full_text_match_);
+}
+
+std::shared_ptr<FullTextMatchPredicate> 
FullTextMatchPredicate::NewLeafPredicate(
+    const std::string& new_field_name) const {
+    auto request = std::make_shared<FullTextMatch>(*full_text_match_);
+    request->field_name = new_field_name;
+    return std::make_shared<FullTextMatchPredicate>(FieldIndex(), request);
+}
+
+}  // namespace paimon
diff --git a/src/paimon/common/predicate/full_text_match_predicate.h 
b/src/paimon/common/predicate/full_text_match_predicate.h
new file mode 100644
index 00000000..61ae13a7
--- /dev/null
+++ b/src/paimon/common/predicate/full_text_match_predicate.h
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include "paimon/predicate/leaf_predicate.h"
+
+namespace paimon {
+struct FullTextMatch;
+
+/// A full-text leaf evaluated by an index, rather than scalar 
PredicateFilter::Test.
+/// Use PredicateBuilder::FullTextMatch to construct it.
+class FullTextMatchPredicate : public LeafPredicate {
+ public:
+    FullTextMatchPredicate(int32_t field_index, const 
std::shared_ptr<FullTextMatch>& match);
+
+    const std::shared_ptr<FullTextMatch>& GetFullTextMatch() const {
+        return full_text_match_;
+    }
+
+    bool operator==(const Predicate& other) const override;
+    std::string ToString() const override;
+
+    /// Rebind the field position while sharing the original request.
+    std::shared_ptr<FullTextMatchPredicate> NewLeafPredicate(int32_t 
new_field_index) const;
+    /// Rebind the field name using a request copy, preserving the original 
request.
+    std::shared_ptr<FullTextMatchPredicate> NewLeafPredicate(
+        const std::string& new_field_name) const;
+
+ private:
+    std::shared_ptr<FullTextMatch> full_text_match_;
+};
+
+}  // namespace paimon
diff --git a/src/paimon/common/predicate/full_text_match_predicate_test.cpp 
b/src/paimon/common/predicate/full_text_match_predicate_test.cpp
new file mode 100644
index 00000000..cc6ddfc8
--- /dev/null
+++ b/src/paimon/common/predicate/full_text_match_predicate_test.cpp
@@ -0,0 +1,148 @@
+/*
+ * 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/common/predicate/full_text_match_predicate.h"
+
+#include "arrow/api.h"
+#include "gtest/gtest.h"
+#include "paimon/common/predicate/full_text_match_function.h"
+#include "paimon/common/predicate/predicate_filter.h"
+#include "paimon/defs.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_match.h"
+#include "paimon/predicate/predicate_builder.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+namespace {
+
+std::shared_ptr<FullTextMatch> MakeMatch(
+    const std::string& field_name = "title",
+    const std::string& query = R"({"match":{"query":"apple"}})") {
+    return std::make_shared<FullTextMatch>(field_name, query);
+}
+
+}  // namespace
+
+TEST(FullTextMatchPredicateTest, TestBasicAndComposesWithScalarPredicates) {
+    auto match = MakeMatch();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(1, match));
+    auto text = std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+    ASSERT_TRUE(text);
+    ASSERT_EQ(text->GetFullTextMatch(), match);
+    ASSERT_EQ(text->FieldIndex(), 1);
+    ASSERT_EQ(text->FieldName(), "title");
+    ASSERT_EQ(text->GetFieldType(), FieldType::STRING);
+    ASSERT_EQ(text->GetFunction().GetType(), Function::Type::FULL_TEXT_MATCH);
+    ASSERT_TRUE(text->Literals().empty());
+    ASSERT_FALSE(std::dynamic_pointer_cast<PredicateFilter>(predicate));
+    ASSERT_NE(text->ToString().find(match->query), std::string::npos);
+
+    auto scalar = PredicateBuilder::LessThan(0, "price", FieldType::INT, 
Literal(10));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> conjunction,
+                         PredicateBuilder::And({scalar, predicate}));
+    auto compound = std::dynamic_pointer_cast<CompoundPredicate>(conjunction);
+    ASSERT_TRUE(compound);
+    ASSERT_EQ(compound->Children(), 
(std::vector<std::shared_ptr<Predicate>>{scalar, predicate}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> disjunction,
+                         PredicateBuilder::Or({scalar, predicate}));
+    ASSERT_EQ(disjunction->GetFunction().GetType(), Function::Type::OR);
+    ASSERT_FALSE(text->Negate());
+    ASSERT_NOK_WITH_MSG(PredicateBuilder::Not(predicate), "Could not construct 
A NOT predicate");
+    ASSERT_NOK_WITH_MSG(PredicateBuilder::Not(conjunction), "Could not 
construct A NOT predicate");
+}
+
+TEST(FullTextMatchPredicateTest, TestRebind) {
+    auto match = std::make_shared<FullTextMatch>("title", 
R"({"match":{"query":"apple"}})",
+                                                 RoaringBitmap64::From({1, 3, 
9}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(1, match));
+    auto text = std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+    ASSERT_TRUE(text);
+
+    auto picked = text->NewLeafPredicate(0);
+    ASSERT_EQ(picked->FieldIndex(), 0);
+    ASSERT_EQ(picked->GetFullTextMatch(), match);
+    ASSERT_EQ(text->FieldIndex(), 1);
+
+    auto renamed = text->NewLeafPredicate("stored_title");
+    ASSERT_EQ(renamed->FieldIndex(), 1);
+    ASSERT_EQ(renamed->FieldName(), "stored_title");
+    ASSERT_EQ(renamed->GetFullTextMatch()->field_name, "stored_title");
+    ASSERT_EQ(renamed->GetFullTextMatch()->query, match->query);
+    ASSERT_EQ(renamed->GetFullTextMatch()->include_row_ids, 
match->include_row_ids);
+    ASSERT_NE(renamed->GetFullTextMatch(), match);
+    ASSERT_EQ(match->field_name, "title");
+    ASSERT_EQ(text->FieldName(), "title");
+}
+
+TEST(FullTextMatchPredicateTest, TestEquality) {
+    auto match = MakeMatch();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(1, match));
+    auto text = std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+    ASSERT_TRUE(text);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> same,
+                         PredicateBuilder::FullTextMatch(1, MakeMatch()));
+    ASSERT_TRUE(*predicate == *same);
+
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<Predicate> other_query,
+        PredicateBuilder::FullTextMatch(1, MakeMatch("title", 
R"({"match":{"query":"orange"}})")));
+    ASSERT_FALSE(*predicate == *other_query);
+    auto restricted = std::make_shared<FullTextMatch>("title", 
R"({"match":{"query":"apple"}})",
+                                                      
RoaringBitmap64::From({3, 9}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> candidates,
+                         PredicateBuilder::FullTextMatch(1, restricted));
+    ASSERT_FALSE(*predicate == *candidates);
+    
ASSERT_NE(candidates->ToString().find(restricted->include_row_ids->ToString()),
+              std::string::npos);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> other_field,
+                         PredicateBuilder::FullTextMatch(1, 
MakeMatch("body")));
+    ASSERT_FALSE(*predicate == *other_field);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> other_index,
+                         PredicateBuilder::FullTextMatch(0, MakeMatch()));
+    ASSERT_FALSE(*predicate == *other_index);
+}
+
+TEST(FullTextMatchPredicateTest, TestRejectsInvalidRequests) {
+    ASSERT_NOK_WITH_MSG(PredicateBuilder::FullTextMatch(0, nullptr),
+                        "FullTextMatch request must not be null");
+    ASSERT_NOK_WITH_MSG(PredicateBuilder::FullTextMatch(0, MakeMatch("")),
+                        "FullTextMatch field name must not be empty");
+    ASSERT_NOK_WITH_MSG(PredicateBuilder::FullTextMatch(0, MakeMatch("title", 
"")),
+                        "FullTextMatch query must not be empty");
+}
+
+TEST(FullTextMatchPredicateTest, TestFunctionCannotEvaluateRowsOrStatistics) {
+    const auto& function = FullTextMatchFunction::Instance();
+    arrow::NullArray array(2);
+    ASSERT_NOK_WITH_MSG(
+        function.Test(array, {}, arrow::default_memory_pool()),
+        "FullTextMatch does not support scalar Test; a full-text index is 
required");
+    ASSERT_NOK_WITH_MSG(
+        function.Test(Literal(1), {}),
+        "FullTextMatch does not support scalar Test; a full-text index is 
required");
+    ASSERT_NOK_WITH_MSG(
+        function.Test(2, Literal(1), Literal(2), /*null_count=*/0, {}),
+        "FullTextMatch does not support statistics Test; a full-text index is 
required");
+    ASSERT_FALSE(function.Negate());
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/common/predicate/predicate_builder.cpp 
b/src/paimon/common/predicate/predicate_builder.cpp
index 529905e6..545fd7f4 100644
--- a/src/paimon/common/predicate/predicate_builder.cpp
+++ b/src/paimon/common/predicate/predicate_builder.cpp
@@ -25,6 +25,7 @@
 #include "paimon/common/predicate/contains.h"
 #include "paimon/common/predicate/ends_with.h"
 #include "paimon/common/predicate/equal.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/common/predicate/greater_or_equal.h"
 #include "paimon/common/predicate/greater_than.h"
 #include "paimon/common/predicate/in.h"
@@ -38,12 +39,27 @@
 #include "paimon/common/predicate/not_in.h"
 #include "paimon/common/predicate/or.h"
 #include "paimon/common/predicate/starts_with.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/status.h"
 
 namespace paimon {
 enum class FieldType;
 
+Result<std::shared_ptr<Predicate>> PredicateBuilder::FullTextMatch(
+    int32_t field_index, const std::shared_ptr<paimon::FullTextMatch>& match) {
+    if (!match) {
+        return Status::Invalid("FullTextMatch request must not be null");
+    }
+    if (match->field_name.empty()) {
+        return Status::Invalid("FullTextMatch field name must not be empty");
+    }
+    if (match->query.empty()) {
+        return Status::Invalid("FullTextMatch query must not be empty");
+    }
+    return std::make_shared<FullTextMatchPredicate>(field_index, match);
+}
+
 // TODO(xinyu.lxy): predicate field_index use index in read schema now, but 
java paimon use index
 // in file schema
 std::shared_ptr<Predicate> PredicateBuilder::Equal(int32_t field_index,
diff --git a/src/paimon/common/predicate/predicate_utils.cpp 
b/src/paimon/common/predicate/predicate_utils.cpp
index ab9c8f4a..f8dc2500 100644
--- a/src/paimon/common/predicate/predicate_utils.cpp
+++ b/src/paimon/common/predicate/predicate_utils.cpp
@@ -21,12 +21,37 @@
 #include <utility>
 
 #include "paimon/common/predicate/compound_predicate_impl.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/common/predicate/leaf_predicate_impl.h"
 #include "paimon/predicate/compound_predicate.h"
 #include "paimon/predicate/leaf_predicate.h"
 #include "paimon/predicate/predicate_builder.h"
 
 namespace paimon {
+
+Result<std::shared_ptr<FullTextMatch>> PredicateUtils::GetFullTextMatch(
+    const std::shared_ptr<LeafPredicate>& predicate) {
+    auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+    if (!full_text) {
+        return Status::Invalid("FULL_TEXT_MATCH requires a 
FullTextMatchPredicate");
+    }
+    return full_text->GetFullTextMatch();
+}
+
+bool PredicateUtils::ContainsFullTextMatch(const std::shared_ptr<Predicate>& 
predicate) {
+    if (std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate)) {
+        return true;
+    }
+    if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+        for (const auto& child : compound->Children()) {
+            if (ContainsFullTextMatch(child)) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
 Result<bool> PredicateUtils::ContainAnyField(const std::shared_ptr<Predicate>& 
predicate,
                                              const std::set<std::string>& 
field_names) {
     if (auto leaf_predicate = 
std::dynamic_pointer_cast<LeafPredicate>(predicate)) {
@@ -135,6 +160,13 @@ PredicateUtils::ReconstructPredicateWithPickedFields(
         }
         return std::optional<std::shared_ptr<Predicate>>(
             compound_predicate->NewCompoundPredicate(mapped_children));
+    } else if (auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate)) {
+        auto iter = picked_field_name_to_idx.find(full_text->FieldName());
+        if (iter == picked_field_name_to_idx.end()) {
+            return std::optional<std::shared_ptr<Predicate>>();
+        }
+        return std::optional<std::shared_ptr<Predicate>>(
+            full_text->NewLeafPredicate(/*new_field_index=*/iter->second));
     } else if (auto leaf_predicate = 
std::dynamic_pointer_cast<LeafPredicateImpl>(predicate)) {
         auto iter = picked_field_name_to_idx.find(leaf_predicate->FieldName());
         if (iter == picked_field_name_to_idx.end()) {
diff --git a/src/paimon/common/predicate/predicate_utils_test.cpp 
b/src/paimon/common/predicate/predicate_utils_test.cpp
index 3d22628a..8b6a98a8 100644
--- a/src/paimon/common/predicate/predicate_utils_test.cpp
+++ b/src/paimon/common/predicate/predicate_utils_test.cpp
@@ -21,12 +21,55 @@
 #include <utility>
 
 #include "gtest/gtest.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/defs.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
+TEST(PredicateUtilsTest, TestFullTextMatchFieldUtilities) {
+    auto match = std::make_shared<FullTextMatch>("text", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> text,
+                         PredicateBuilder::FullTextMatch(1, match));
+    auto scalar = PredicateBuilder::Equal(0, "id", FieldType::INT, Literal(7));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::And({scalar, text}));
+    ASSERT_TRUE(PredicateUtils::ContainsFullTextMatch(predicate));
+    ASSERT_FALSE(PredicateUtils::ContainsFullTextMatch(scalar));
+    ASSERT_FALSE(PredicateUtils::ContainsFullTextMatch(nullptr));
+    std::set<std::string> names;
+    ASSERT_OK(PredicateUtils::GetAllNames(predicate, &names));
+    ASSERT_EQ(names, (std::set<std::string>{"id", "text"}));
+    ASSERT_OK_AND_ASSIGN(bool contains, 
PredicateUtils::ContainAnyField(predicate, {"text"}));
+    ASSERT_TRUE(contains);
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> picked,
+                         PredicateUtils::CreatePickedFieldFilter(predicate, 
{{"text", 4}}));
+    auto picked_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(picked);
+    ASSERT_TRUE(picked_text);
+    ASSERT_EQ(picked_text->FieldIndex(), 4);
+    ASSERT_EQ(picked_text->GetFullTextMatch(), match);
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> disjunction,
+                         PredicateBuilder::Or({scalar, text}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> missing,
+                         PredicateUtils::CreatePickedFieldFilter(disjunction, 
{{"text", 4}}));
+    ASSERT_FALSE(missing);
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<Predicate> remapped,
+        PredicateUtils::CreatePickedFieldFilter(disjunction, {{"id", 2}, 
{"text", 4}}));
+    auto compound = std::dynamic_pointer_cast<CompoundPredicate>(remapped);
+    ASSERT_TRUE(compound);
+    ASSERT_EQ(compound->GetFunction().GetType(), Function::Type::OR);
+    picked_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(compound->Children()[1]);
+    ASSERT_TRUE(picked_text);
+    ASSERT_EQ(picked_text->FieldIndex(), 4);
+    ASSERT_EQ(picked_text->GetFullTextMatch(), match);
+}
+
 TEST(PredicateUtilsTest, TestSplitAnd) {
     ASSERT_OK_AND_ASSIGN(
         auto child1,
diff --git a/src/paimon/common/predicate/predicate_validator_test.cpp 
b/src/paimon/common/predicate/predicate_validator_test.cpp
index 75ae141c..89432b72 100644
--- a/src/paimon/common/predicate/predicate_validator_test.cpp
+++ b/src/paimon/common/predicate/predicate_validator_test.cpp
@@ -22,6 +22,7 @@
 #include "gtest/gtest.h"
 #include "paimon/data/decimal.h"
 #include "paimon/data/timestamp.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/result.h"
 #include "paimon/testing/utils/testharness.h"
@@ -31,6 +32,48 @@ class Schema;
 }  // namespace arrow
 
 namespace paimon::test {
+TEST(PredicateValidatorTest, TestFullTextMatchRequiresProjectedStringField) {
+    auto schema =
+        arrow::schema({arrow::field("id", arrow::int32()), 
arrow::field("text", arrow::utf8())});
+    auto match = std::make_shared<FullTextMatch>("text", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> text,
+                         PredicateBuilder::FullTextMatch(1, match));
+    ASSERT_OK(PredicateValidator::ValidatePredicateWithSchema(*schema, text, 
true));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> wrong_index,
+                         PredicateBuilder::FullTextMatch(0, match));
+    ASSERT_NOK_WITH_MSG(
+        PredicateValidator::ValidatePredicateWithSchema(*schema, wrong_index, 
true),
+        "field text has field idx 1 in input schema, mismatch field idx 0 in 
predicate");
+    ASSERT_OK(PredicateValidator::ValidatePredicateWithSchema(*schema, 
wrong_index, false));
+    auto missing = arrow::schema({arrow::field("id", arrow::int32())});
+    
ASSERT_NOK_WITH_MSG(PredicateValidator::ValidatePredicateWithSchema(*missing, 
text, true),
+                        "field text does not exist in schema");
+    auto numeric =
+        arrow::schema({arrow::field("id", arrow::int32()), 
arrow::field("text", arrow::int32())});
+    
ASSERT_NOK_WITH_MSG(PredicateValidator::ValidatePredicateWithSchema(*numeric, 
text, true),
+                        "schema type int32 mismatches predicate field type 
STRING");
+}
+
+TEST(PredicateValidatorTest, TestFullTextMatchAllowsOrAndRepeatedFields) {
+    auto schema =
+        arrow::schema({arrow::field("id", arrow::int32()), 
arrow::field("text", arrow::utf8())});
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<Predicate> apple,
+        PredicateBuilder::FullTextMatch(
+            1, std::make_shared<FullTextMatch>("text", 
R"({"match":{"query":"apple"}})")));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<Predicate> orange,
+        PredicateBuilder::FullTextMatch(
+            1, std::make_shared<FullTextMatch>("text", 
R"({"match":{"query":"orange"}})")));
+    auto scalar = PredicateBuilder::Equal(0, "id", FieldType::INT, Literal(7));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> conjunction,
+                         PredicateBuilder::And({apple, orange}));
+    ASSERT_OK(PredicateValidator::ValidatePredicateWithSchema(*schema, 
conjunction, true));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> disjunction,
+                         PredicateBuilder::Or({conjunction, scalar}));
+    ASSERT_OK(PredicateValidator::ValidatePredicateWithSchema(*schema, 
disjunction, true));
+}
+
 TEST(PredicateValidatorTest, TestValidateLiterals) {
     std::string str("apple");
     std::shared_ptr<arrow::Schema> schema = arrow::schema(arrow::FieldVector({
diff --git a/src/paimon/common/table/special_fields.h 
b/src/paimon/common/table/special_fields.h
index f3e64fcc..73562140 100644
--- a/src/paimon/common/table/special_fields.h
+++ b/src/paimon/common/table/special_fields.h
@@ -20,6 +20,7 @@
 
 #include <cstdint>
 #include <limits>
+#include <optional>
 #include <string>
 #include <utility>
 
@@ -67,6 +68,20 @@ struct SpecialFields {
         return data_field;
     }
 
+    static std::string IndexScoreFieldName(const std::string& field_name) {
+        return "_" + field_name + "_INDEX_SCORE";
+    }
+
+    /// Parse a per-field score projection. The global `_INDEX_SCORE` is 
separate.
+    static std::optional<std::string> ParseIndexScoreFieldName(const 
std::string& name) {
+        const std::string suffix = "_INDEX_SCORE";
+        if (name.size() <= suffix.size() + 1 || !StringUtils::StartsWith(name, 
"_") ||
+            !StringUtils::EndsWith(name, suffix)) {
+            return std::nullopt;
+        }
+        return name.substr(1, name.size() - suffix.size() - 1);
+    }
+
     static const DataField& RealtimeOffset() {
         static const DataField data_field =
             DataField(SpecialFieldIds::REALTIME_OFFSET,
@@ -93,7 +108,8 @@ struct SpecialFields {
         }
         return field_name == SequenceNumber().Name() || field_name == 
ValueKind().Name() ||
                field_name == RowKind().Name() || field_name == RowId().Name() 
||
-               field_name == IndexScore().Name();
+               field_name == IndexScore().Name() ||
+               ParseIndexScoreFieldName(field_name).has_value();
     }
 
     // TODO(xinyu.lxy): add a func to complete row-tracking fields
diff --git a/src/paimon/common/table/special_fields_test.cpp 
b/src/paimon/common/table/special_fields_test.cpp
index 05c0808e..94df2167 100644
--- a/src/paimon/common/table/special_fields_test.cpp
+++ b/src/paimon/common/table/special_fields_test.cpp
@@ -55,6 +55,19 @@ TEST(SpecialFieldsTest, TestIndexScore) {
     ASSERT_EQ(SpecialFields::IndexScore().Type()->id(), arrow::Type::FLOAT);
 }
 
+TEST(SpecialFieldsTest, TestPerFieldIndexScoreNames) {
+    for (const auto& field_name : {"F1", "body_text", "_title", "文档"}) {
+        const auto name = SpecialFields::IndexScoreFieldName(field_name);
+        ASSERT_EQ(SpecialFields::ParseIndexScoreFieldName(name), field_name);
+        ASSERT_TRUE(SpecialFields::IsSystemField(name));
+    }
+    ASSERT_EQ(SpecialFields::IndexScoreFieldName("F1"), "_F1_INDEX_SCORE");
+    for (const auto& name : {"_INDEX_SCORE", "__INDEX_SCORE", 
"F1_INDEX_SCORE", "_F1_index_score",
+                             "_F1_INDEX_SCORE_extra"}) {
+        ASSERT_FALSE(SpecialFields::ParseIndexScoreFieldName(name));
+    }
+}
+
 TEST(SpecialFieldsTest, TestRealtimeOffset) {
     ASSERT_EQ(SpecialFields::RealtimeOffset().Id(), 
SpecialFieldIds::REALTIME_OFFSET);
     ASSERT_EQ(SpecialFields::RealtimeOffset().Name(), "_REALTIME_OFFSET");
diff --git a/src/paimon/core/operation/file_store_scan.cpp 
b/src/paimon/core/operation/file_store_scan.cpp
index e5f13233..4e508bab 100644
--- a/src/paimon/core/operation/file_store_scan.cpp
+++ b/src/paimon/core/operation/file_store_scan.cpp
@@ -690,6 +690,9 @@ Result<bool> 
FileStoreScan::FilterNonPartitionManifestEntry(const ManifestEntry&
 Status FileStoreScan::SplitAndSetFilter(const std::vector<std::string>& 
partition_keys,
                                         const std::shared_ptr<arrow::Schema>& 
arrow_schema,
                                         const std::shared_ptr<ScanFilter>& 
scan_filters) {
+    if (PredicateUtils::ContainsFullTextMatch(scan_filters->GetPredicate())) {
+        return Status::NotImplemented("FullTextMatch predicate execution is 
not implemented yet");
+    }
     if (scan_filters->GetPredicate()) {
         PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FieldMappingBuilder> 
mapping_builder,
                                FieldMappingBuilder::Create(arrow_schema, 
partition_keys,
diff --git a/src/paimon/core/operation/file_store_scan_test.cpp 
b/src/paimon/core/operation/file_store_scan_test.cpp
index ef784f76..5c53f138 100644
--- a/src/paimon/core/operation/file_store_scan_test.cpp
+++ b/src/paimon/core/operation/file_store_scan_test.cpp
@@ -29,10 +29,12 @@
 #include "paimon/core/manifest/file_source.h"
 #include "paimon/core/manifest/manifest_entry_serializer.h"
 #include "paimon/core/manifest/snapshot_live_manifest_entries.h"
+#include "paimon/core/operation/append_only_file_store_scan.h"
 #include "paimon/core/stats/simple_stats.h"
 #include "paimon/data/timestamp.h"
 #include "paimon/memory/bytes.h"
 #include "paimon/memory/memory_segment.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/testing/utils/binary_row_generator.h"
 #include "paimon/testing/utils/testharness.h"
 
@@ -140,6 +142,28 @@ TEST_F(FileStoreScanTest, 
TestCreatePartitionPredicateWithInvalidPartitionFilter
                         "field invalid does not exist in partition keys");
 }
 
+TEST_F(FileStoreScanTest, 
TestFullTextMatchDoesNotEnterPartitionOrStatsFilters) {
+    auto match = std::make_shared<FullTextMatch>("f0", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(auto full_text, 
PredicateBuilder::FullTextMatch(/*field_index=*/0, match));
+    auto scalar = PredicateBuilder::Equal(/*field_index=*/1, "f1", 
FieldType::INT, Literal(1));
+    ASSERT_OK_AND_ASSIGN(auto predicate, PredicateBuilder::Or({scalar, 
full_text}));
+    auto filters = std::make_shared<ScanFilter>(
+        predicate, std::vector<std::map<std::string, std::string>>{}, 
std::nullopt);
+    ASSERT_OK_AND_ASSIGN(CoreOptions options, CoreOptions::FromMap({}));
+    for (const std::vector<std::string>& partition_keys :
+         {std::vector<std::string>{}, std::vector<std::string>{"f0"}}) {
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<TableSchema> table_schema,
+            TableSchema::Create(/*schema_id=*/0, schema_, partition_keys, 
/*primary_keys=*/{}, {}));
+        auto result = AppendOnlyFileStoreScan::Create(
+            /*snapshot_manager=*/nullptr, /*schema_manager=*/nullptr, 
/*manifest_list=*/nullptr,
+            /*manifest_file=*/nullptr, table_schema, schema_, filters, options,
+            CreateDefaultExecutor(), GetDefaultPool());
+        ASSERT_NOK_WITH_MSG(result, "FullTextMatch predicate execution is not 
implemented yet");
+        ASSERT_TRUE(result.status().IsNotImplemented());
+    }
+}
+
 TEST_F(FileStoreScanTest, TestFilterManifestByRowRanges) {
     class FakeFileStoreScan : public FileStoreScan {
      public:
diff --git a/src/paimon/core/operation/internal_read_context.cpp 
b/src/paimon/core/operation/internal_read_context.cpp
index 429302cc..a89eb0ae 100644
--- a/src/paimon/core/operation/internal_read_context.cpp
+++ b/src/paimon/core/operation/internal_read_context.cpp
@@ -18,6 +18,8 @@
 
 #include "paimon/core/operation/internal_read_context.h"
 
+#include <algorithm>
+#include <functional>
 #include <optional>
 #include <utility>
 
@@ -27,6 +29,7 @@
 #include "fmt/format.h"
 #include "paimon/common/data/variant/variant_access_utils.h"
 #include "paimon/common/data/variant/variant_type_utils.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/common/predicate/predicate_validator.h"
 #include "paimon/common/table/special_fields.h"
 #include "paimon/common/types/data_field.h"
@@ -36,7 +39,9 @@
 #include "paimon/core/options/map_storage_layout.h"
 #include "paimon/core/schema/arrow_schema_validator.h"
 #include "paimon/core/utils/nested_projection_utils.h"
+#include "paimon/predicate/compound_predicate.h"
 #include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/predicate_utils.h"
 #include "paimon/predicate/vector_search.h"
 #include "paimon/status.h"
 
@@ -175,7 +180,12 @@ std::optional<DataField> 
InternalReadContext::TryResolveSpecialFieldById(
 }
 
 std::optional<DataField> InternalReadContext::TryResolveSpecialFieldByName(
-    const std::string& name, const CoreOptions& core_options, bool 
has_vector_or_full_text_search) {
+    const std::string& name, const CoreOptions& core_options, bool 
has_vector_or_full_text_search,
+    int32_t output_field_idx) {
+    if (SpecialFields::ParseIndexScoreFieldName(name)) {
+        // These virtual columns have read-local negative IDs, not persistent 
table IDs.
+        return DataField(-1 - output_field_idx, arrow::field(name, 
arrow::float32()));
+    }
     if (name == SpecialFields::ValueKind().Name()) {
         return SpecialFields::ValueKind();
     }
@@ -245,7 +255,8 @@ Result<std::unique_ptr<InternalReadContext>> 
InternalReadContext::Create(
         // Align special-field validation with read_field_ids/read_field_names 
branches.
         for (const auto& read_field : read_schema->fields()) {
             if (auto resolved_special_field = TryResolveSpecialFieldByName(
-                    read_field->name(), core_options, 
has_vector_or_full_text_search)) {
+                    read_field->name(), core_options, 
has_vector_or_full_text_search,
+                    static_cast<int32_t>(read_data_fields.size()))) {
                 read_data_fields.push_back(*resolved_special_field);
                 continue;
             }
@@ -281,8 +292,9 @@ Result<std::unique_ptr<InternalReadContext>> 
InternalReadContext::Create(
     } else if (!context->GetReadFieldNames().empty()) {
         read_data_fields.reserve(context->GetReadFieldNames().size());
         for (const auto& name : context->GetReadFieldNames()) {
-            if (auto resolved_special_field = TryResolveSpecialFieldByName(
-                    name, core_options, has_vector_or_full_text_search)) {
+            if (auto resolved_special_field =
+                    TryResolveSpecialFieldByName(name, core_options, 
has_vector_or_full_text_search,
+                                                 
static_cast<int32_t>(read_data_fields.size()))) {
                 read_data_fields.push_back(*resolved_special_field);
                 continue;
             }
@@ -311,13 +323,85 @@ Result<std::unique_ptr<InternalReadContext>> 
InternalReadContext::Create(
         PAIMON_RETURN_NOT_OK(PredicateValidator::ValidatePredicateWithSchema(
             *read_schema, context->GetPredicate(), 
/*validate_field_idx=*/true));
     }
-
     if (!context->GetMemoryPool()) {
         return Status::Invalid("memory pool is null");
     }
     std::shared_ptr<arrow::MemoryPool> arrow_pool = 
GetArrowPool(context->GetMemoryPool());
-    return std::unique_ptr<InternalReadContext>(
+    auto internal_context = std::unique_ptr<InternalReadContext>(
         new InternalReadContext(context, table_schema, read_schema, 
core_options, arrow_pool));
+    PAIMON_RETURN_NOT_OK(internal_context->BindIndexScores());
+    return internal_context;
+}
+
+Status InternalReadContext::BindIndexScores() {
+    std::map<std::string, int32_t> projected_scores;
+    for (int32_t idx = 0; idx < read_schema_->num_fields(); ++idx) {
+        if (auto field_name =
+                
SpecialFields::ParseIndexScoreFieldName(read_schema_->field(idx)->name())) {
+            projected_scores.emplace(*field_name, idx);
+        }
+    }
+    if (projected_scores.empty()) {
+        return Status::OK();
+    }
+    auto bind = [&](const IndexScoreSource& source, bool under_or) -> Status {
+        auto iter = projected_scores.find(source.field_name);
+        if (iter == projected_scores.end()) {
+            return Status::OK();
+        }
+        if (under_or) {
+            return Status::Invalid("Score-producing FullTextMatch cannot 
appear under OR");
+        }
+        if (!index_score_bindings_.emplace(iter->second, source).second) {
+            return Status::Invalid(fmt::format(
+                "Score-producing search field '{}' occurs more than once", 
source.field_name));
+        }
+        return Status::OK();
+    };
+    std::function<Status(const std::shared_ptr<Predicate>&, bool)> visit =
+        [&](const std::shared_ptr<Predicate>& predicate, bool under_or) -> 
Status {
+        if (auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate)) {
+            return bind({IndexScoreSource::Type::FULL_TEXT_MATCH, 
full_text->FieldName()},
+                        under_or);
+        }
+        if (auto compound = 
std::dynamic_pointer_cast<CompoundPredicate>(predicate)) {
+            const bool child_under_or =
+                under_or || compound->GetFunction().GetType() == 
Function::Type::OR;
+            for (const auto& child : compound->Children()) {
+                PAIMON_RETURN_NOT_OK(visit(child, child_under_or));
+            }
+        }
+        return Status::OK();
+    };
+    PAIMON_RETURN_NOT_OK(visit(GetPredicate(), /*under_or=*/false));
+    if (const auto& search = GetVectorSearch()) {
+        PAIMON_RETURN_NOT_OK(
+            bind({IndexScoreSource::Type::VECTOR_SEARCH, search->field_name}, 
/*under_or=*/false));
+    }
+    if (const auto& search = GetFullTextSearch()) {
+        PAIMON_RETURN_NOT_OK(bind({IndexScoreSource::Type::FULL_TEXT_SEARCH, 
search->field_name},
+                                  /*under_or=*/false));
+    }
+    for (const auto& [field_name, output_idx] : projected_scores) {
+        if (index_score_bindings_.find(output_idx) == 
index_score_bindings_.end()) {
+            return Status::Invalid(fmt::format("Index score field '{}' has no 
search source",
+                                               
read_schema_->field(output_idx)->name()));
+        }
+    }
+    return Status::OK();
+}
+
+bool InternalReadContext::NeedsScores(const IndexScoreSource& source) const {
+    if (source.type == IndexScoreSource::Type::VECTOR_SEARCH) {
+        const auto& search = GetVectorSearch();
+        return search && search->field_name == source.field_name;
+    }
+    if (source.type == IndexScoreSource::Type::FULL_TEXT_SEARCH) {
+        const auto& search = GetFullTextSearch();
+        return search && search->field_name == source.field_name;
+    }
+    return std::any_of(index_score_bindings_.begin(), 
index_score_bindings_.end(),
+                       [&source](const auto& binding) { return binding.second 
== source; });
 }
 
 InternalReadContext::InternalReadContext(const std::shared_ptr<ReadContext>& 
read_context,
@@ -334,6 +418,11 @@ InternalReadContext::InternalReadContext(const 
std::shared_ptr<ReadContext>& rea
 Result<std::shared_ptr<InternalReadContext>> 
InternalReadContext::CreateWithSchema(
     const std::shared_ptr<InternalReadContext>& original,
     const std::shared_ptr<arrow::Schema>& new_read_schema) {
+    if (original->HasVectorOrFullTextSearch() ||
+        PredicateUtils::ContainsFullTextMatch(original->GetPredicate())) {
+        return Status::NotImplemented(
+            "CreateWithSchema does not support search requests or index score 
projections");
+    }
     // Create a new InternalReadContext sharing all properties except 
read_schema.
     // The new read_schema is the minimal column set for COUNT(*).
     return std::shared_ptr<InternalReadContext>(
diff --git a/src/paimon/core/operation/internal_read_context.h 
b/src/paimon/core/operation/internal_read_context.h
index 8e34a85b..d3601fdf 100644
--- a/src/paimon/core/operation/internal_read_context.h
+++ b/src/paimon/core/operation/internal_read_context.h
@@ -43,11 +43,31 @@ class RealtimeContext;
 // internal read context, contains ReadContext, CoreOptions and TableSchema
 class InternalReadContext {
  public:
+    struct IndexScoreSource {
+        enum class Type { VECTOR_SEARCH, FULL_TEXT_SEARCH, FULL_TEXT_MATCH };
+
+        bool operator==(const IndexScoreSource& other) const {
+            return type == other.type && field_name == other.field_name;
+        }
+
+        Type type;
+        /// Original table field name, before per-file field mapping.
+        std::string field_name;
+    };
+
     static Result<std::unique_ptr<InternalReadContext>> Create(
         const std::shared_ptr<ReadContext>& read_context,
         const std::shared_ptr<TableSchema>& table_schema,
         const std::map<std::string, std::string>& options);
 
+    /// Maps score output positions in the read schema to their logical search 
sources.
+    const std::map<int32_t, IndexScoreSource>& GetIndexScoreBindings() const {
+        return index_score_bindings_;
+    }
+
+    /// Root TopK needs scores even without a score projection.
+    bool NeedsScores(const IndexScoreSource& source) const;
+
     const CoreOptions& GetCoreOptions() const {
         return options_;
     }
@@ -129,10 +149,8 @@ class InternalReadContext {
         return read_context_->GetWarmupLevel();
     }
 
-    /// Create a new InternalReadContext with a different read schema.
-    /// Useful for creating a context with a minimal column set for 
specialized reads.
-    /// All other settings (predicate, options, table_schema, etc.) are 
inherited
-    /// from the original context.
+    /// Create the minimal read schema for PK COUNT(*). Search requests and 
score
+    /// projections are unsupported; their bindings cannot be inherited across 
schemas.
     static Result<std::shared_ptr<InternalReadContext>> CreateWithSchema(
         const std::shared_ptr<InternalReadContext>& original,
         const std::shared_ptr<arrow::Schema>& new_read_schema);
@@ -149,16 +167,19 @@ class InternalReadContext {
                                                                bool 
has_vector_or_full_text_search);
     static std::optional<DataField> TryResolveSpecialFieldByName(
         const std::string& name, const CoreOptions& core_options,
-        bool has_vector_or_full_text_search);
+        bool has_vector_or_full_text_search, int32_t output_field_idx);
     static Result<std::shared_ptr<arrow::Field>> 
AlignReadFieldWithTableFieldIds(
         const std::shared_ptr<arrow::Field>& read_field,
         const std::shared_ptr<arrow::Field>& table_field);
 
+    Status BindIndexScores();
+
     std::shared_ptr<ReadContext> read_context_;
     std::shared_ptr<TableSchema> table_schema_;
     std::shared_ptr<arrow::Schema> read_schema_;
     std::shared_ptr<arrow::MemoryPool> arrow_pool_;
     CoreOptions options_;
+    std::map<int32_t, IndexScoreSource> index_score_bindings_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/core/operation/internal_read_context_test.cpp 
b/src/paimon/core/operation/internal_read_context_test.cpp
index 086d56d1..ca7b79dc 100644
--- a/src/paimon/core/operation/internal_read_context_test.cpp
+++ b/src/paimon/core/operation/internal_read_context_test.cpp
@@ -27,6 +27,7 @@
 
 #include "arrow/c/bridge.h"
 #include "arrow/type.h"
+#include "fmt/format.h"
 #include "gtest/gtest.h"
 #include "paimon/common/table/special_fields.h"
 #include "paimon/common/types/data_field.h"
@@ -34,7 +35,9 @@
 #include "paimon/data/shredding/map_shared_shredding_schema_utils.h"
 #include "paimon/defs.h"
 #include "paimon/fs/local/local_file_system.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/predicate_builder.h"
 #include "paimon/predicate/vector_search.h"
 #include "paimon/status.h"
 #include "paimon/testing/utils/testharness.h"
@@ -278,6 +281,332 @@ TEST(InternalReadContext, 
TestFileFullTextSearchScoreIsNotSupported) {
                         "does not support score output");
 }
 
+TEST(InternalReadContext, TestFullTextMatchValidatesProjection) {
+    std::string path = paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09";
+    SchemaManager schema_manager(std::make_shared<LocalFileSystem>(), path);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema, 
schema_manager.ReadSchema(0));
+    struct TestCase {
+        int32_t field_index;
+        std::string field_name;
+        std::vector<std::string> projection;
+        std::string expected_error;
+    };
+    const std::vector<TestCase> cases = {
+        {0, "f0", {"f0"}, ""},
+        {0, "f0", {"f1"}, "field f0 does not exist in schema"},
+        {1,
+         "f0",
+         {"f0"},
+         "field f0 has field idx 0 in input schema, mismatch field idx 1 in 
predicate"},
+        {0, "f1", {"f1"}, "schema type int32 mismatches predicate field type 
STRING"}};
+    for (const auto& test_case : cases) {
+        auto match =
+            std::make_shared<FullTextMatch>(test_case.field_name, 
R"({"match":{"query":"apple"}})");
+        ASSERT_OK_AND_ASSIGN(auto predicate,
+                             
PredicateBuilder::FullTextMatch(test_case.field_index, match));
+        ReadContextBuilder builder(path);
+        
builder.SetReadFieldNames(test_case.projection).SetPredicate(predicate);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReadContext> context, 
builder.Finish());
+        if (test_case.expected_error.empty()) {
+            ASSERT_OK_AND_ASSIGN(
+                std::shared_ptr<InternalReadContext> internal,
+                InternalReadContext::Create(context, table_schema, 
table_schema->Options()));
+            ASSERT_TRUE(internal->GetIndexScoreBindings().empty());
+            ASSERT_FALSE(internal->NeedsScores(
+                {InternalReadContext::IndexScoreSource::Type::FULL_TEXT_MATCH, 
match->field_name}));
+            ASSERT_NOK_WITH_MSG(
+                InternalReadContext::CreateWithSchema(internal, 
internal->GetReadSchema()),
+                "CreateWithSchema does not support");
+        } else {
+            ASSERT_NOK_WITH_MSG(
+                InternalReadContext::Create(context, table_schema, 
table_schema->Options()),
+                test_case.expected_error);
+        }
+    }
+}
+
+TEST(InternalReadContext, TestPerFieldTopKScoreBindings) {
+    using SourceType = InternalReadContext::IndexScoreSource::Type;
+    std::string path = paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09";
+    SchemaManager schema_manager(std::make_shared<LocalFileSystem>(), path);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema, 
schema_manager.ReadSchema(0));
+    for (bool vector_search : {false, true}) {
+        for (bool project_score : {false, true}) {
+            ReadContextBuilder builder(path);
+            std::vector<std::string> projection = {"f1"};
+            if (project_score) {
+                projection.push_back(vector_search ? "_f3_INDEX_SCORE" : 
"_f0_INDEX_SCORE");
+            }
+            builder.SetReadFieldNames(projection);
+            const InternalReadContext::IndexScoreSource source = {
+                vector_search ? SourceType::VECTOR_SEARCH : 
SourceType::FULL_TEXT_SEARCH,
+                vector_search ? "f3" : "f0"};
+            if (vector_search) {
+                auto search = std::make_shared<VectorSearch>(
+                    "f3", /*limit=*/1, std::vector<float>{1.0f}, nullptr, 
nullptr, std::nullopt,
+                    std::map<std::string, std::string>{});
+                builder.SetVectorSearch(search);
+            } else {
+                auto search = std::make_shared<FullTextSearch>(
+                    "f0", R"({"match":{"query":"apple"}})", /*limit=*/1);
+                builder.SetFullTextSearch(search);
+            }
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<ReadContext> context, 
builder.Finish());
+            ASSERT_OK_AND_ASSIGN(
+                std::shared_ptr<InternalReadContext> internal,
+                InternalReadContext::Create(context, table_schema, 
table_schema->Options()));
+            ASSERT_EQ(internal->GetReadSchema()->field_names(), projection);
+            ASSERT_TRUE(internal->NeedsScores(source));
+            ASSERT_FALSE(internal->NeedsScores(
+                {vector_search ? SourceType::FULL_TEXT_SEARCH : 
SourceType::VECTOR_SEARCH,
+                 source.field_name}));
+            ASSERT_FALSE(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
source.field_name}));
+            ASSERT_FALSE(internal->NeedsScores({source.type, "missing"}));
+            const auto& bindings = internal->GetIndexScoreBindings();
+            if (project_score) {
+                const std::map<int32_t, InternalReadContext::IndexScoreSource> 
expected = {
+                    {1, source}};
+                ASSERT_EQ(bindings, expected);
+                ASSERT_EQ(internal->GetReadSchema()->field(1)->type()->id(), 
arrow::Type::FLOAT);
+            } else {
+                ASSERT_TRUE(bindings.empty());
+            }
+            ASSERT_NOK_WITH_MSG(
+                InternalReadContext::CreateWithSchema(internal, 
internal->GetReadSchema()),
+                "CreateWithSchema does not support");
+        }
+    }
+}
+
+class InternalReadContextScoreTest : public testing::Test {
+ protected:
+    void SetUp() override {
+        ASSERT_OK_AND_ASSIGN(table_schema_,
+                             TableSchema::Create(0,
+                                                 
arrow::schema({arrow::field("F1", arrow::utf8()),
+                                                                
arrow::field("F2", arrow::utf8())}),
+                                                 {}, {}, {}));
+    }
+
+    Result<std::unique_ptr<InternalReadContext>> Create(ReadContextBuilder* 
builder) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ReadContext> context, 
builder->Finish());
+        return InternalReadContext::Create(context, table_schema_, 
table_schema_->Options());
+    }
+
+    std::shared_ptr<TableSchema> table_schema_;
+};
+
+TEST_F(InternalReadContextScoreTest, BindsFullTextScoresByProjection) {
+    using SourceType = InternalReadContext::IndexScoreSource::Type;
+    auto first = std::make_shared<FullTextMatch>("F1", 
R"({"match":{"query":"apple"}})");
+    auto second = std::make_shared<FullTextMatch>("F2", first->query);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> first_predicate,
+                         PredicateBuilder::FullTextMatch(0, first));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> second_predicate,
+                         PredicateBuilder::FullTextMatch(1, second));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::And({first_predicate, 
second_predicate}));
+    struct TestCase {
+        int32_t score_count;
+        bool explicit_schema;
+        bool reverse_scores;
+    };
+    const std::vector<TestCase> cases = {{0, false, false}, {1, false, false}, 
{2, false, false},
+                                         {2, false, true},  {2, true, false},  
{2, true, true}};
+    for (const auto& test_case : cases) {
+        const int32_t score_count = test_case.score_count;
+        ReadContextBuilder builder("/tmp/unused-table-path");
+        std::vector<std::string> projection = {"F1", "F2"};
+        if (score_count >= 1) {
+            projection.push_back(test_case.reverse_scores ? "_F1_INDEX_SCORE" 
: "_F2_INDEX_SCORE");
+        }
+        if (score_count == 2) {
+            projection.push_back(test_case.reverse_scores ? "_F2_INDEX_SCORE" 
: "_F1_INDEX_SCORE");
+        }
+        builder.SetPredicate(predicate);
+        if (test_case.explicit_schema) {
+            arrow::FieldVector fields = {arrow::field("F1", arrow::utf8()),
+                                         arrow::field("F2", arrow::utf8())};
+            for (int32_t i = 0; i < score_count; ++i) {
+                fields.push_back(arrow::field(projection[i + 2], 
arrow::float32()));
+            }
+            auto c_schema = std::make_unique<ArrowSchema>();
+            ASSERT_TRUE(arrow::ExportSchema(*arrow::schema(fields), 
c_schema.get()).ok());
+            builder.SetReadSchema(std::move(c_schema));
+        } else if (score_count > 0) {
+            builder.SetReadFieldNames(projection);
+        }
+        ASSERT_OK_AND_ASSIGN(auto internal, Create(&builder));
+        ASSERT_EQ(internal->GetReadSchema()->field_names(), projection);
+        ASSERT_EQ(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
first->field_name}),
+                  score_count == 2);
+        ASSERT_EQ(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
second->field_name}),
+                  score_count >= 1);
+        auto copied_match = std::make_shared<FullTextMatch>(*second);
+        ASSERT_EQ(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
copied_match->field_name}),
+                  score_count >= 1);
+        ASSERT_FALSE(internal->NeedsScores({SourceType::FULL_TEXT_SEARCH, 
second->field_name}));
+        ASSERT_FALSE(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
"missing"}));
+        const auto& bindings = internal->GetIndexScoreBindings();
+        ASSERT_EQ(bindings.size(), score_count);
+        ASSERT_OK_AND_ASSIGN(std::vector<DataField> fields,
+                             
DataField::ConvertArrowSchemaToDataFields(internal->GetReadSchema()));
+        for (int32_t i = 0; i < score_count; ++i) {
+            const int32_t output_idx = i + 2;
+            auto iter = bindings.find(output_idx);
+            ASSERT_NE(iter, bindings.end());
+            ASSERT_EQ(iter->second.type, SourceType::FULL_TEXT_MATCH);
+            ASSERT_EQ(iter->second.field_name,
+                      projection[output_idx] == "_F1_INDEX_SCORE" ? "F1" : 
"F2");
+            ASSERT_EQ(fields[output_idx].Type()->id(), arrow::Type::FLOAT);
+            ASSERT_LT(fields[output_idx].Id(), 0);
+            ASSERT_NE(fields[output_idx].Id(), SpecialFieldIds::INDEX_SCORE);
+            for (int32_t previous = 0; previous < output_idx; ++previous) {
+                ASSERT_NE(fields[output_idx].Id(), fields[previous].Id());
+            }
+        }
+    }
+}
+
+TEST_F(InternalReadContextScoreTest, BindsRootAndPredicateScores) {
+    using SourceType = InternalReadContext::IndexScoreSource::Type;
+    const InternalReadContext::IndexScoreSource search_source = 
{SourceType::FULL_TEXT_SEARCH,
+                                                                 "F2"};
+    const InternalReadContext::IndexScoreSource match_source = 
{SourceType::FULL_TEXT_MATCH, "F1"};
+    auto match = std::make_shared<FullTextMatch>("F1", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(0, match));
+    auto search = std::make_shared<FullTextSearch>("F2", match->query, 
/*limit=*/10);
+    ReadContextBuilder builder("/tmp/unused-table-path");
+    
builder.SetPredicate(predicate).SetFullTextSearch(search).SetReadFieldNames(
+        {"F1", "_F2_INDEX_SCORE", "_F1_INDEX_SCORE"});
+    ASSERT_OK_AND_ASSIGN(auto internal, Create(&builder));
+    ASSERT_EQ(internal->GetReadSchema()->GetFieldIndex("F2"), -1);
+    const auto& bindings = internal->GetIndexScoreBindings();
+    const std::map<int32_t, InternalReadContext::IndexScoreSource> expected = 
{{1, search_source},
+                                                                               
{2, match_source}};
+    ASSERT_EQ(bindings, expected);
+    ASSERT_TRUE(internal->NeedsScores(search_source));
+    ASSERT_TRUE(internal->NeedsScores(match_source));
+
+    
builder.SetPredicate(predicate).SetFullTextSearch(search).SetReadFieldNames(
+        {"F1", "_F2_INDEX_SCORE", "F2", "_F1_INDEX_SCORE"});
+    ASSERT_OK_AND_ASSIGN(internal, Create(&builder));
+    const std::map<int32_t, InternalReadContext::IndexScoreSource> 
non_contiguous = {
+        {1, search_source}, {3, match_source}};
+    ASSERT_EQ(internal->GetIndexScoreBindings(), non_contiguous);
+
+    auto scalar = PredicateBuilder::IsNotNull(0, "F1", FieldType::STRING);
+    ASSERT_OK_AND_ASSIGN(predicate, PredicateBuilder::Or({predicate, scalar}));
+    
builder.SetPredicate(predicate).SetFullTextSearch(search).SetReadFieldNames(
+        {"F1", "_F2_INDEX_SCORE"});
+    ASSERT_OK_AND_ASSIGN(internal, Create(&builder));
+    ASSERT_TRUE(internal->NeedsScores(search_source));
+    ASSERT_FALSE(internal->NeedsScores(match_source));
+    ASSERT_EQ(internal->GetIndexScoreBindings().size(), 1);
+}
+
+TEST_F(InternalReadContextScoreTest, 
RejectsAmbiguousOrConditionalScoreProjection) {
+    using SourceType = InternalReadContext::IndexScoreSource::Type;
+    auto match = std::make_shared<FullTextMatch>("F1", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> text,
+                         PredicateBuilder::FullTextMatch(0, match));
+    auto scalar = PredicateBuilder::IsNotNull(0, "F1", FieldType::STRING);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> duplicate, 
PredicateBuilder::And({text, text}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> conditional,
+                         PredicateBuilder::Or({text, scalar}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> scalar_or,
+                         PredicateBuilder::Or({scalar, scalar}));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> valid,
+                         PredicateBuilder::And({text, scalar_or}));
+    struct TestCase {
+        std::shared_ptr<Predicate> predicate;
+        bool root_search;
+        std::string error;
+    };
+    const std::vector<TestCase> cases = {
+        {duplicate, false, "Score-producing search field 'F1' occurs more than 
once"},
+        {text, true, "Score-producing search field 'F1' occurs more than 
once"},
+        {conditional, false, "Score-producing FullTextMatch cannot appear 
under OR"},
+        {valid, false, ""}};
+    for (const auto& test_case : cases) {
+        for (bool project_score : {false, true}) {
+            ReadContextBuilder builder("/tmp/unused-table-path");
+            builder.SetPredicate(test_case.predicate)
+                .SetReadFieldNames(project_score ? 
std::vector<std::string>{"F1", "_F1_INDEX_SCORE"}
+                                                 : 
std::vector<std::string>{"F1"});
+            if (test_case.root_search) {
+                builder.SetFullTextSearch(
+                    std::make_shared<FullTextSearch>("F1", match->query, 
/*limit=*/1));
+            }
+            if (project_score && !test_case.error.empty()) {
+                ASSERT_NOK_WITH_MSG(Create(&builder), test_case.error);
+            } else {
+                ASSERT_OK_AND_ASSIGN(auto internal, Create(&builder));
+                ASSERT_EQ(internal->GetIndexScoreBindings().empty(), 
!project_score);
+                ASSERT_EQ(internal->NeedsScores({SourceType::FULL_TEXT_SEARCH, 
"F1"}),
+                          test_case.root_search);
+                ASSERT_EQ(internal->NeedsScores({SourceType::FULL_TEXT_MATCH, 
"F1"}),
+                          project_score);
+            }
+        }
+    }
+}
+
+TEST_F(InternalReadContextScoreTest, RejectsMissingScoreSource) {
+    for (const auto& name : {"_F1_INDEX_SCORE", "_missing_INDEX_SCORE"}) {
+        ReadContextBuilder builder("/tmp/unused-table-path");
+        builder.SetReadFieldNames({name});
+        ASSERT_NOK_WITH_MSG(Create(&builder),
+                            fmt::format("Index score field '{}' has no search 
source", name));
+    }
+}
+
+TEST_F(InternalReadContextScoreTest, 
ResolvesScoresOnlyFromEffectiveProjection) {
+    const InternalReadContext::IndexScoreSource search_source = {
+        InternalReadContext::IndexScoreSource::Type::FULL_TEXT_SEARCH, "F2"};
+    auto search = std::make_shared<FullTextSearch>("F2", 
R"({"match":{"query":"apple"}})", 1);
+    for (bool explicit_schema : {false, true}) {
+        ReadContextBuilder builder("/tmp/unused-table-path");
+        builder.SetFullTextSearch(search)
+            .SetReadFieldNames({"_missing_INDEX_SCORE"})
+            .SetReadFieldIds({0});
+        if (explicit_schema) {
+            auto c_schema = std::make_unique<ArrowSchema>();
+            ASSERT_TRUE(arrow::ExportSchema(
+                            *arrow::schema({arrow::field("_F2_INDEX_SCORE", 
arrow::float32())}),
+                            c_schema.get())
+                            .ok());
+            builder.SetReadSchema(std::move(c_schema));
+        }
+        ASSERT_OK_AND_ASSIGN(auto internal, Create(&builder));
+        ASSERT_EQ(internal->GetReadSchema()->field_names(),
+                  explicit_schema ? std::vector<std::string>{"_F2_INDEX_SCORE"}
+                                  : std::vector<std::string>{"F1"});
+        ASSERT_TRUE(internal->NeedsScores(search_source));
+        const auto& bindings = internal->GetIndexScoreBindings();
+        ASSERT_EQ(bindings.size(), explicit_schema ? 1 : 0);
+        if (explicit_schema) {
+            const std::map<int32_t, InternalReadContext::IndexScoreSource> 
expected = {
+                {0, search_source}};
+            ASSERT_EQ(bindings, expected);
+        }
+    }
+}
+
+TEST_F(InternalReadContextScoreTest, SchemaReplacementUsesProvidedSchema) {
+    ReadContextBuilder builder("/tmp/unused-table-path");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<InternalReadContext> internal, 
Create(&builder));
+    for (const auto& schema :
+         {arrow::schema({arrow::field("F1", arrow::utf8())}),
+          arrow::schema({arrow::field("_F1_INDEX_SCORE", arrow::float32())})}) 
{
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<InternalReadContext> replaced,
+                             InternalReadContext::CreateWithSchema(internal, 
schema));
+        ASSERT_TRUE(replaced->GetReadSchema()->Equals(schema));
+        ASSERT_TRUE(replaced->GetIndexScoreBindings().empty());
+    }
+}
+
 TEST(InternalReadContext, TestReadWithValueKindField) {
     std::string path = paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09";
     ReadContextBuilder context_builder(path);
diff --git a/src/paimon/core/operation/read_context.cpp 
b/src/paimon/core/operation/read_context.cpp
index 8389ed90..d731c4d3 100644
--- a/src/paimon/core/operation/read_context.cpp
+++ b/src/paimon/core/operation/read_context.cpp
@@ -387,6 +387,24 @@ Result<std::unique_ptr<ReadContext>> 
ReadContextBuilder::Finish() {
     if (impl_->vector_search_ && impl_->full_text_search_) {
         return Status::Invalid("VectorSearch and FullTextSearch cannot be 
configured together");
     }
+    if (impl_->vector_search_) {
+        const auto& search = impl_->vector_search_;
+        if (search->limit <= 0) {
+            return Status::Invalid("VectorSearch TopK limit must be positive");
+        }
+        if (search->field_name.empty() || search->query.empty()) {
+            return Status::Invalid("VectorSearch field name and query must not 
be empty");
+        }
+    }
+    if (impl_->full_text_search_) {
+        const auto& search = impl_->full_text_search_;
+        if (search->limit <= 0) {
+            return Status::Invalid("FullTextSearch TopK limit must be 
positive");
+        }
+        if (search->field_name.empty() || search->query.empty()) {
+            return Status::Invalid("FullTextSearch field name and query must 
not be empty");
+        }
+    }
     if (impl_->built_from_format_table_ && impl_->format_table_ == nullptr) {
         return Status::Invalid("cannot read with null format table");
     }
diff --git a/src/paimon/core/operation/read_context_test.cpp 
b/src/paimon/core/operation/read_context_test.cpp
index 35955807..d9079e70 100644
--- a/src/paimon/core/operation/read_context_test.cpp
+++ b/src/paimon/core/operation/read_context_test.cpp
@@ -29,7 +29,9 @@
 #include "paimon/defs.h"
 #include "paimon/executor.h"
 #include "paimon/memory/memory_pool.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/full_text_search.h"
+#include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/predicate/vector_search.h"
 #include "paimon/status.h"
@@ -432,4 +434,39 @@ TEST(ReadContextTest, 
TestRejectConflictingFileIndexSearch) {
     ASSERT_NOK_WITH_MSG(conflicting.Finish(), "cannot be configured together");
 }
 
+TEST(ReadContextTest, TestFullTextPredicateAndRootTopKConfiguration) {
+    auto match = std::make_shared<FullTextMatch>("body", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> text,
+                         PredicateBuilder::FullTextMatch(0, match));
+    auto scalar = PredicateBuilder::LessThan(1, "price", FieldType::INT, 
Literal(10));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> filter, 
PredicateBuilder::Or({text, scalar}));
+    auto vector =
+        std::make_shared<VectorSearch>("embedding", 3, 
std::vector<float>{1.0f}, nullptr, nullptr,
+                                       std::nullopt, std::map<std::string, 
std::string>{});
+    ReadContextBuilder builder("table_root_path");
+    builder.SetReadFieldNames({"body", 
"price"}).SetPredicate(filter).SetVectorSearch(vector);
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> context, 
builder.Finish());
+    ASSERT_EQ(context->GetPredicate(), filter);
+    ASSERT_EQ(context->GetVectorSearch(), vector);
+
+    auto ranked_text = std::make_shared<FullTextSearch>("title", match->query, 
2);
+    builder.SetPredicate(filter).SetFullTextSearch(ranked_text);
+    ASSERT_OK_AND_ASSIGN(context, builder.Finish());
+    ASSERT_EQ(context->GetPredicate(), filter);
+    ASSERT_EQ(context->GetFullTextSearch(), ranked_text);
+}
+
+TEST(ReadContextTest, TestRootTopKValidation) {
+    auto vector =
+        std::make_shared<VectorSearch>("embedding", 0, 
std::vector<float>{1.0f}, nullptr, nullptr,
+                                       std::nullopt, std::map<std::string, 
std::string>{});
+    ReadContextBuilder vector_builder("table_root_path");
+    vector_builder.SetVectorSearch(vector);
+    ASSERT_NOK_WITH_MSG(vector_builder.Finish(), "VectorSearch TopK limit must 
be positive");
+    auto text = std::make_shared<FullTextSearch>("body", 
R"({"match":{"query":"apple"}})", -1);
+    ReadContextBuilder text_builder("table_root_path");
+    text_builder.SetFullTextSearch(text);
+    ASSERT_NOK_WITH_MSG(text_builder.Finish(), "FullTextSearch TopK limit must 
be positive");
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/table/source/table_read_test.cpp 
b/src/paimon/core/table/source/table_read_test.cpp
index 4f5c03b5..31e3f74f 100644
--- a/src/paimon/core/table/source/table_read_test.cpp
+++ b/src/paimon/core/table/source/table_read_test.cpp
@@ -27,20 +27,26 @@
 #include <vector>
 
 #include "gtest/gtest.h"
+#include "paimon/catalog/table.h"
 #include "paimon/common/data/binary_row.h"
 #include "paimon/core/core_options.h"
 #include "paimon/core/operation/abstract_split_read.h"
 #include "paimon/core/operation/split_read.h"
+#include "paimon/core/schema/schema_manager.h"
+#include "paimon/core/schema/table_schema.h"
 #include "paimon/core/table/source/append_only_table_read.h"
 #include "paimon/core/table/source/data_split_impl.h"
 #include "paimon/core/table/source/key_value_table_read.h"
 #include "paimon/defs.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/full_text_search.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/predicate/vector_search.h"
 #include "paimon/read_context.h"
 #include "paimon/status.h"
+#include "paimon/table/format/format_table.h"
 #include "paimon/testing/utils/binary_row_generator.h"
 #include "paimon/testing/utils/testharness.h"
 
@@ -117,6 +123,52 @@ TEST(TableReadTest, TestReadWithSpecifiedInvalidSchema) {
                         "Get field field_no_exist failed: not exist in table 
schema");
 }
 
+TEST(TableReadTest, TestFullTextMatchExecutionIsNotImplemented) {
+    auto match = std::make_shared<FullTextMatch>("f0", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(auto full_text, 
PredicateBuilder::FullTextMatch(/*field_index=*/0, match));
+    auto scalar = PredicateBuilder::Equal(/*field_index=*/1, "f1", 
FieldType::INT, Literal(1));
+    ASSERT_OK_AND_ASSIGN(auto predicate, PredicateBuilder::Or({scalar, 
full_text}));
+    const std::string table_path = paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09";
+    auto fs = std::make_shared<LocalFileSystem>();
+    SchemaManager schema_manager(fs, table_path);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> schema, 
schema_manager.ReadSchema(0));
+    Table table(fs, table_path, schema, Identifier("append_09.db", 
"append_09"));
+    for (bool table_entry : {false, true}) {
+        ReadContextBuilder builder(table_path);
+        builder.SetReadFieldNames({"f0", "f1"}).SetPredicate(predicate);
+        ASSERT_OK_AND_ASSIGN(auto context, builder.Finish());
+        auto result =
+            table_entry ? table.NewRead(std::move(context)) : 
TableRead::Create(std::move(context));
+        ASSERT_NOK_WITH_MSG(result, "FullTextMatch predicate execution is not 
implemented yet");
+        ASSERT_TRUE(result.status().IsNotImplemented());
+
+        builder.SetReadFieldNames({"f0", "f1", 
"_f0_INDEX_SCORE"}).SetPredicate(predicate);
+        ASSERT_OK_AND_ASSIGN(context, builder.Finish());
+        result =
+            table_entry ? table.NewRead(std::move(context)) : 
TableRead::Create(std::move(context));
+        ASSERT_NOK_WITH_MSG(result, "Score-producing FullTextMatch cannot 
appear under OR");
+    }
+}
+
+TEST(TableReadTest, TestPerFieldScoreExecutionIsNotImplemented) {
+    const std::string path = paimon::test::GetDataDir() + 
"/orc/append_09.db/append_09";
+    auto search =
+        std::make_shared<FullTextSearch>("f0", 
R"({"match":{"query":"apple"}})", /*limit=*/1);
+    auto fs = std::make_shared<LocalFileSystem>();
+    SchemaManager schema_manager(fs, path);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> schema, 
schema_manager.ReadSchema(0));
+    Table table(fs, path, schema, Identifier("append_09.db", "append_09"));
+    for (bool table_entry : {false, true}) {
+        ReadContextBuilder builder(path);
+        builder.SetFullTextSearch(search).SetReadFieldNames({"f1", 
"_f0_INDEX_SCORE"});
+        ASSERT_OK_AND_ASSIGN(auto context, builder.Finish());
+        auto result =
+            table_entry ? table.NewRead(std::move(context)) : 
TableRead::Create(std::move(context));
+        ASSERT_NOK_WITH_MSG(result, "Per-field index score output is not 
implemented yet");
+        ASSERT_TRUE(result.status().IsNotImplemented());
+    }
+}
+
 TEST(TableReadTest, TestCreateKeyValueTableRead) {
     std::string path = paimon::test::GetDataDir() +
                        
"/orc/pk_table_with_dv_cardinality.db/pk_table_with_dv_cardinality/";
diff --git a/src/paimon/core/table/table.cpp b/src/paimon/core/table/table.cpp
index 944ffab2..05751f92 100644
--- a/src/paimon/core/table/table.cpp
+++ b/src/paimon/core/table/table.cpp
@@ -104,6 +104,7 @@
 #include "paimon/file_store_write.h"
 #include "paimon/format/file_format.h"
 #include "paimon/fs/file_system.h"
+#include "paimon/predicate/predicate_utils.h"
 #include "paimon/read_context.h"
 #include "paimon/realtime/realtime_context.h"
 #include "paimon/result.h"
@@ -988,6 +989,12 @@ Result<std::unique_ptr<InternalReadContext>> 
CreateInternalReadContext(
 Result<std::unique_ptr<TableRead>> CreateTableRead(
     const std::shared_ptr<InternalReadContext>& internal_context,
     const std::shared_ptr<MemoryPool>& memory_pool, const 
std::shared_ptr<Executor>& executor) {
+    if 
(PredicateUtils::ContainsFullTextMatch(internal_context->GetPredicate())) {
+        return Status::NotImplemented("FullTextMatch predicate execution is 
not implemented yet");
+    }
+    if (!internal_context->GetIndexScoreBindings().empty()) {
+        return Status::NotImplemented("Per-field index score output is not 
implemented yet");
+    }
     const auto& core_options = internal_context->GetCoreOptions();
     const auto& table_schema = internal_context->GetTableSchema();
     if (internal_context->GetRealtimeContext() && 
!core_options.RealtimeEnabled()) {
@@ -1051,6 +1058,9 @@ Result<std::unique_ptr<TableRead>> NewDataTableRead(
 /// refuses by name what a format table cannot honour.
 Result<std::unique_ptr<TableRead>> NewFormatTableRead(const 
std::shared_ptr<FormatTable>& table,
                                                       const 
std::shared_ptr<ReadContext>& context) {
+    if (PredicateUtils::ContainsFullTextMatch(context->GetPredicate())) {
+        return Status::NotImplemented("FullTextMatch predicate execution is 
not implemented yet");
+    }
     PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatTableRead> read,
                            FormatTableRead::Create(table, context));
     return std::unique_ptr<TableRead>(std::move(read));
@@ -1086,6 +1096,10 @@ Result<std::unique_ptr<TableRead>> 
TableRead::Create(std::unique_ptr<ReadContext
     PAIMON_ASSIGN_OR_RAISE(std::optional<SystemTablePath> system_table_path,
                            
SystemTableLoader::TryParsePath(context->GetPath()));
     if (system_table_path) {
+        if (PredicateUtils::ContainsFullTextMatch(context->GetPredicate())) {
+            return Status::NotImplemented(
+                "FullTextMatch predicate execution is not implemented yet");
+        }
         if (context->HasVectorOrFullTextSearch()) {
             return Status::NotImplemented(
                 "VectorSearch or FullTextSearch is not supported for system 
tables");
diff --git a/src/paimon/core/utils/field_mapping.cpp 
b/src/paimon/core/utils/field_mapping.cpp
index 70668a5b..7155c16f 100644
--- a/src/paimon/core/utils/field_mapping.cpp
+++ b/src/paimon/core/utils/field_mapping.cpp
@@ -25,6 +25,7 @@
 #include "arrow/type.h"
 #include "fmt/format.h"
 #include "paimon/common/predicate/compound_predicate_impl.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/common/predicate/leaf_predicate_impl.h"
 #include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/field_type_utils.h"
@@ -344,9 +345,8 @@ Result<std::optional<std::shared_ptr<Predicate>>> 
FieldMappingBuilder::Reconstru
 
     const auto& [data_field_idx, data_field] = data_field_iter->second;
     return 
std::optional<std::shared_ptr<Predicate>>(std::make_shared<LeafPredicateImpl>(
-        leaf_predicate->GetLeafFunction(), /*data field idx*/ data_field_idx,
-        /*data field name*/ data_field.Name(),
-        /*data field type*/ data_type, new_literals));
+        leaf_predicate->GetLeafFunction(), /*data_field_idx=*/data_field_idx,
+        /*data_field_name=*/data_field.Name(), /*data_field_type=*/data_type, 
new_literals));
 }
 
 Result<std::optional<std::shared_ptr<Predicate>>>
@@ -354,7 +354,25 @@ FieldMappingBuilder::ReconstructPredicateWithDataFields(
     const std::shared_ptr<Predicate>& predicate,
     const std::map<std::string, DataField>& field_name_to_read_fields,
     const std::map<int32_t, std::pair<int32_t, DataField>>& 
field_id_to_data_fields) {
-    if (auto leaf_predicate = 
std::dynamic_pointer_cast<LeafPredicateImpl>(predicate)) {
+    if (auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate)) {
+        auto read_field = 
field_name_to_read_fields.find(full_text->FieldName());
+        if (read_field == field_name_to_read_fields.end()) {
+            return Status::Invalid(
+                fmt::format("FullTextMatch field '{}' is not projected", 
full_text->FieldName()));
+        }
+        auto data_field = 
field_id_to_data_fields.find(read_field->second.Id());
+        if (data_field == field_id_to_data_fields.end()) {
+            return Status::Invalid(fmt::format(
+                "FullTextMatch field '{}' does not exist in file schema", 
full_text->FieldName()));
+        }
+        const auto& [field_index, field] = data_field->second;
+        if (field.Type()->id() != arrow::Type::STRING) {
+            return Status::Invalid(
+                fmt::format("FullTextMatch field '{}' must have STRING type", 
field.Name()));
+        }
+        return std::optional<std::shared_ptr<Predicate>>(
+            
full_text->NewLeafPredicate(field.Name())->NewLeafPredicate(field_index));
+    } else if (auto leaf_predicate = 
std::dynamic_pointer_cast<LeafPredicateImpl>(predicate)) {
         return ReconstructLeafPredicate(leaf_predicate, 
field_name_to_read_fields,
                                         field_id_to_data_fields);
     } else if (auto compound_predicate =
diff --git a/src/paimon/core/utils/field_mapping_test.cpp 
b/src/paimon/core/utils/field_mapping_test.cpp
index dbc1d932..4f66cd10 100644
--- a/src/paimon/core/utils/field_mapping_test.cpp
+++ b/src/paimon/core/utils/field_mapping_test.cpp
@@ -24,10 +24,12 @@
 
 #include "arrow/type_fwd.h"
 #include "gtest/gtest.h"
+#include "paimon/common/predicate/full_text_match_predicate.h"
 #include "paimon/common/predicate/leaf_predicate_impl.h"
 #include "paimon/common/predicate/predicate_filter.h"
 #include "paimon/data/decimal.h"
 #include "paimon/defs.h"
+#include "paimon/predicate/full_text_match.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate_builder.h"
 #include "paimon/status.h"
@@ -99,6 +101,47 @@ TEST_F(FieldMappingTest, 
TestFieldNameMappingUsesIdsAcrossRenameAndReorder) {
     ASSERT_TRUE(FieldMappingBuilder::CreateFieldNameMapping(table_fields, 
{}).empty());
 }
 
+TEST_F(FieldMappingTest, TestFullTextMatchRenameAndReorder) {
+    RoaringBitmap64 candidates;
+    candidates.Add(3);
+    candidates.Add(9);
+    auto match =
+        std::make_shared<FullTextMatch>("text", 
R"({"match":{"query":"apple"}})", candidates);
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(1, match));
+    std::map<std::string, DataField> read_fields = {
+        {"text", DataField(7, arrow::field("text", arrow::utf8()))}};
+    std::map<int32_t, std::pair<int32_t, DataField>> data_fields = {
+        {7, {3, DataField(7, arrow::field("old_text", arrow::utf8()))}}};
+    ASSERT_OK_AND_ASSIGN(std::optional<std::shared_ptr<Predicate>> 
reconstructed,
+                         
FieldMappingBuilder::ReconstructPredicateWithDataFields(
+                             predicate, read_fields, data_fields));
+    ASSERT_TRUE(reconstructed);
+    auto full_text = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(reconstructed.value());
+    ASSERT_TRUE(full_text);
+    ASSERT_EQ(full_text->FieldName(), "old_text");
+    ASSERT_EQ(full_text->FieldIndex(), 3);
+    ASSERT_EQ(full_text->GetFullTextMatch()->field_name, "old_text");
+    ASSERT_EQ(full_text->GetFullTextMatch()->query, match->query);
+    ASSERT_EQ(full_text->GetFullTextMatch()->include_row_ids, 
match->include_row_ids);
+    ASSERT_NE(full_text->GetFullTextMatch(), match);
+    ASSERT_EQ(match->field_name, "text");
+    auto original = 
std::dynamic_pointer_cast<FullTextMatchPredicate>(predicate);
+    ASSERT_EQ(original->FieldName(), "text");
+    ASSERT_EQ(original->FieldIndex(), 1);
+
+    ASSERT_NOK_WITH_MSG(
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(predicate, {}, 
data_fields),
+        "FullTextMatch field 'text' is not projected");
+    ASSERT_NOK_WITH_MSG(
+        FieldMappingBuilder::ReconstructPredicateWithDataFields(predicate, 
read_fields, {}),
+        "FullTextMatch field 'text' does not exist in file schema");
+    data_fields = {{7, {0, DataField(7, arrow::field("old_text", 
arrow::int32()))}}};
+    
ASSERT_NOK_WITH_MSG(FieldMappingBuilder::ReconstructPredicateWithDataFields(
+                            predicate, read_fields, data_fields),
+                        "FullTextMatch field 'old_text' must have STRING 
type");
+}
+
 TEST_F(FieldMappingTest, TestEmptyPartitionKeys) {
     // test no partition keys in schema
     std::string val("hello");
diff --git a/test/inte/predicate_abi_inte_test.cpp 
b/test/inte/predicate_abi_inte_test.cpp
index 3590eb22..02340059 100644
--- a/test/inte/predicate_abi_inte_test.cpp
+++ b/test/inte/predicate_abi_inte_test.cpp
@@ -46,11 +46,15 @@
 
 #include "gtest/gtest.h"
 #include "paimon/defs.h"
+#include "paimon/file_index/file_index_reader.h"
 #include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/full_text_match.h"
+#include "paimon/predicate/function.h"
 #include "paimon/predicate/leaf_predicate.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/predicate/predicate.h"
 #include "paimon/predicate/predicate_builder.h"
+#include "paimon/predicate/predicate_utils.h"
 #include "paimon/testing/utils/testharness.h"
 
 namespace paimon::test {
@@ -74,6 +78,23 @@ TEST(PredicateAbiInteTest, LeafPredicateCastsAcrossModule) {
     EXPECT_EQ(std::dynamic_pointer_cast<CompoundPredicate>(predicate), 
nullptr);
 }
 
+TEST(PredicateAbiInteTest, FullTextMatchCastsToLeafAcrossModule) {
+    auto match = std::make_shared<FullTextMatch>("body", 
R"({"match":{"query":"apple"}})");
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Predicate> predicate,
+                         PredicateBuilder::FullTextMatch(0, match));
+    auto leaf = std::dynamic_pointer_cast<LeafPredicate>(predicate);
+    ASSERT_TRUE(leaf);
+    ASSERT_EQ(leaf->FieldIndex(), 0);
+    ASSERT_EQ(leaf->FieldName(), "body");
+    ASSERT_EQ(leaf->GetFieldType(), FieldType::STRING);
+    ASSERT_TRUE(leaf->Literals().empty());
+    ASSERT_EQ(leaf->GetFunction().GetType(), Function::Type::FULL_TEXT_MATCH);
+    auto reader = std::make_shared<FileIndexReader>();
+    auto result = 
PredicateUtils::VisitPredicate<std::shared_ptr<FileIndexResult>>(leaf, reader);
+    ASSERT_NOK_WITH_MSG(result, "This file index does not support complete 
full-text matching");
+    ASSERT_TRUE(result.status().IsNotImplemented());
+}
+
 // A compound predicate built in libpaimon.so must survive dynamic_pointer_cast
 // to CompoundPredicate, and its children must remain castable to 
LeafPredicate.
 TEST(PredicateAbiInteTest, CompoundPredicateCastsAcrossModule) {

Reply via email to