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

airborne pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 729b9f85b6a [fix](search) Add searcher cache reuse and DSL result 
cache for search() function (#60790)
729b9f85b6a is described below

commit 729b9f85b6ad3e8b58ea4c3e749020ad4fb66436
Author: Jack <[email protected]>
AuthorDate: Tue Feb 24 21:51:18 2026 +0800

    [fix](search) Add searcher cache reuse and DSL result cache for search() 
function (#60790)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    This PR adds searcher cache reuse and a DSL result cache for the
    `search()` function to improve query performance on repeated search
    queries against the same segments.
    
    **Key changes:**
    
    1. **DSL result cache**: Caches the final roaring bitmap per (segment,
    DSL) pair so repeated identical `search()` queries skip Lucene execution
    entirely. Uses length-prefix key encoding to avoid hash collisions.
    
    2. **Deep-copy bitmap semantics**: Bitmaps are deep-copied on both cache
    read and write to prevent `mask_out_null()` from polluting cached
    entries.
    
    3. **Type-safe cache accessor**: Replaces raw `void*` return with a
    template `get_value<T>()` that uses `static_assert` to ensure T derives
    from `LRUCacheValueBase`.
    
    4. **Session-level cache toggle**: Adds
    `enable_search_function_query_cache` session variable (default: true) to
    allow disabling the cache per query via `SET_VAR`.
    
    5. **Const-correctness fix**: Removes unsafe `const_cast` in
    `build_dsl_signature` by copying TSearchParam before Thrift
    serialization.
    
    6. **Defensive improvements**: Adds null check for `result_bitmap` on
    cache hit, logging for serialization fallback and cache bypass paths.
    
    ### Release note
    
    Add DSL result cache for search() function to skip repeated Lucene
    execution on identical queries.
---
 be/src/olap/rowset/segment_v2/index_file_reader.h  |   1 +
 .../rowset/segment_v2/inverted_index_query_type.h  |   4 +
 .../olap/rowset/segment_v2/inverted_index_reader.h |  62 ++++--
 be/src/runtime/exec_env_init.cpp                   |   2 +-
 be/src/vec/exprs/vsearch.cpp                       |  14 +-
 be/src/vec/exprs/vsearch.h                         |   5 +
 be/src/vec/functions/function_search.cpp           | 202 ++++++++++++++------
 be/src/vec/functions/function_search.h             |  10 +-
 .../search_function_query_cache_test.cpp           | 207 +++++++++++++++++++++
 be/test/vec/function/function_search_test.cpp      | 115 ++----------
 .../search/test_search_boundary_cases.groovy       |   2 +-
 .../suites/search/test_search_cache.groovy         | 138 ++++++++++++++
 .../test_search_default_field_operator.groovy      |   2 +-
 .../suites/search/test_search_dsl_operators.groovy |   2 +-
 .../suites/search/test_search_dsl_syntax.groovy    |   2 +-
 .../suites/search/test_search_escape.groovy        |   2 +-
 .../suites/search/test_search_exact_basic.groovy   |   2 +-
 .../search/test_search_exact_lowercase.groovy      |   2 +-
 .../suites/search/test_search_exact_match.groovy   |   2 +-
 .../search/test_search_exact_multi_index.groovy    |   2 +-
 .../suites/search/test_search_function.groovy      |   2 +-
 .../search/test_search_inverted_index.groovy       |   2 +-
 .../suites/search/test_search_lucene_mode.groovy   |   2 +-
 .../suites/search/test_search_mow_support.groovy   |   2 +-
 .../suites/search/test_search_multi_field.groovy   |   2 +-
 .../search/test_search_null_regression.groovy      |   2 +-
 .../search/test_search_null_semantics.groovy       |   2 +-
 .../search/test_search_regexp_lowercase.groovy     |   2 +-
 .../search/test_search_usage_restrictions.groovy   |   2 +-
 .../test_search_variant_dual_index_reader.groovy   |   2 +-
 .../test_search_variant_subcolumn_analyzer.groovy  |   2 +-
 .../search/test_search_vs_match_consistency.groovy |   2 +-
 32 files changed, 603 insertions(+), 199 deletions(-)

diff --git a/be/src/olap/rowset/segment_v2/index_file_reader.h 
b/be/src/olap/rowset/segment_v2/index_file_reader.h
index 42022224485..2b5f2e183a1 100644
--- a/be/src/olap/rowset/segment_v2/index_file_reader.h
+++ b/be/src/olap/rowset/segment_v2/index_file_reader.h
@@ -71,6 +71,7 @@ public:
     Result<InvertedIndexDirectoryMap> get_all_directories();
     // open file v2, init _stream
     int64_t get_inverted_file_size() const { return _stream == nullptr ? 0 : 
_stream->length(); }
+    const std::string& get_index_path_prefix() const { return 
_index_path_prefix; }
     friend IndexFileWriter;
 
 protected:
diff --git a/be/src/olap/rowset/segment_v2/inverted_index_query_type.h 
b/be/src/olap/rowset/segment_v2/inverted_index_query_type.h
index c6b250bb5c2..8fa1a0f9059 100644
--- a/be/src/olap/rowset/segment_v2/inverted_index_query_type.h
+++ b/be/src/olap/rowset/segment_v2/inverted_index_query_type.h
@@ -82,6 +82,7 @@ enum class InvertedIndexQueryType {
     WILDCARD_QUERY = 12,
     RANGE_QUERY = 13,
     LIST_QUERY = 14,
+    SEARCH_DSL_QUERY = 15,
 };
 
 inline bool is_equal_query(InvertedIndexQueryType query_type) {
@@ -154,6 +155,9 @@ inline std::string 
query_type_to_string(InvertedIndexQueryType query_type) {
     case InvertedIndexQueryType::LIST_QUERY: {
         return "LIST";
     }
+    case InvertedIndexQueryType::SEARCH_DSL_QUERY: {
+        return "SEARCH_DSL";
+    }
     default:
         return "";
     }
diff --git a/be/src/olap/rowset/segment_v2/inverted_index_reader.h 
b/be/src/olap/rowset/segment_v2/inverted_index_reader.h
index 8e93ed87f21..6810d6082e0 100644
--- a/be/src/olap/rowset/segment_v2/inverted_index_reader.h
+++ b/be/src/olap/rowset/segment_v2/inverted_index_reader.h
@@ -94,8 +94,12 @@ public:
 
     // Copy constructor
     InvertedIndexResultBitmap(const InvertedIndexResultBitmap& other)
-            : 
_data_bitmap(std::make_shared<roaring::Roaring>(*other._data_bitmap)),
-              
_null_bitmap(std::make_shared<roaring::Roaring>(*other._null_bitmap)) {}
+            : _data_bitmap(other._data_bitmap
+                                   ? 
std::make_shared<roaring::Roaring>(*other._data_bitmap)
+                                   : nullptr),
+              _null_bitmap(other._null_bitmap
+                                   ? 
std::make_shared<roaring::Roaring>(*other._null_bitmap)
+                                   : nullptr) {}
 
     // Move constructor
     InvertedIndexResultBitmap(InvertedIndexResultBitmap&& other) noexcept
@@ -105,8 +109,12 @@ public:
     // Copy assignment operator
     InvertedIndexResultBitmap& operator=(const InvertedIndexResultBitmap& 
other) {
         if (this != &other) { // Prevent self-assignment
-            _data_bitmap = 
std::make_shared<roaring::Roaring>(*other._data_bitmap);
-            _null_bitmap = 
std::make_shared<roaring::Roaring>(*other._null_bitmap);
+            _data_bitmap = other._data_bitmap
+                                   ? 
std::make_shared<roaring::Roaring>(*other._data_bitmap)
+                                   : nullptr;
+            _null_bitmap = other._null_bitmap
+                                   ? 
std::make_shared<roaring::Roaring>(*other._null_bitmap)
+                                   : nullptr;
         }
         return *this;
     }
@@ -122,11 +130,15 @@ public:
 
     // Operator &=
     InvertedIndexResultBitmap& operator&=(const InvertedIndexResultBitmap& 
other) {
-        if (_data_bitmap && _null_bitmap && other._data_bitmap && 
other._null_bitmap) {
-            auto new_null_bitmap = (*_data_bitmap & *other._null_bitmap) |
-                                   (*_null_bitmap & *other._data_bitmap) |
-                                   (*_null_bitmap & *other._null_bitmap);
+        if (_data_bitmap && other._data_bitmap) {
+            const auto& my_null = _null_bitmap ? *_null_bitmap : 
_empty_bitmap();
+            const auto& ot_null = other._null_bitmap ? *other._null_bitmap : 
_empty_bitmap();
+            auto new_null_bitmap = (*_data_bitmap & ot_null) | (my_null & 
*other._data_bitmap) |
+                                   (my_null & ot_null);
             *_data_bitmap &= *other._data_bitmap;
+            if (!_null_bitmap) {
+                _null_bitmap = std::make_shared<roaring::Roaring>();
+            }
             *_null_bitmap = std::move(new_null_bitmap);
         }
         return *this;
@@ -134,7 +146,9 @@ public:
 
     // Operator |=
     InvertedIndexResultBitmap& operator|=(const InvertedIndexResultBitmap& 
other) {
-        if (_data_bitmap && _null_bitmap && other._data_bitmap && 
other._null_bitmap) {
+        if (_data_bitmap && other._data_bitmap) {
+            const auto& my_null = _null_bitmap ? *_null_bitmap : 
_empty_bitmap();
+            const auto& ot_null = other._null_bitmap ? *other._null_bitmap : 
_empty_bitmap();
             // SQL three-valued logic for OR:
             // - TRUE OR anything = TRUE (not NULL)
             // - FALSE OR NULL = NULL
@@ -142,9 +156,11 @@ public:
             // Result is NULL when the row is NULL on either side while the 
other side
             // is not TRUE. Rows that become TRUE must be removed from the 
NULL bitmap.
             *_data_bitmap |= *other._data_bitmap;
-            auto new_null_bitmap =
-                    (*_null_bitmap - *other._data_bitmap) | 
(*other._null_bitmap - *_data_bitmap);
+            auto new_null_bitmap = (my_null - *other._data_bitmap) | (ot_null 
- *_data_bitmap);
             new_null_bitmap -= *_data_bitmap;
+            if (!_null_bitmap) {
+                _null_bitmap = std::make_shared<roaring::Roaring>();
+            }
             *_null_bitmap = std::move(new_null_bitmap);
         }
         return *this;
@@ -152,8 +168,12 @@ public:
 
     // NOT operation
     const InvertedIndexResultBitmap& op_not(const roaring::Roaring* universe) 
const {
-        if (_data_bitmap && _null_bitmap) {
-            *_data_bitmap = *universe - *_data_bitmap - *_null_bitmap;
+        if (_data_bitmap) {
+            if (_null_bitmap) {
+                *_data_bitmap = *universe - *_data_bitmap - *_null_bitmap;
+            } else {
+                *_data_bitmap = *universe - *_data_bitmap;
+            }
             // The _null_bitmap remains unchanged.
         }
         return *this;
@@ -161,10 +181,14 @@ public:
 
     // Operator -=
     InvertedIndexResultBitmap& operator-=(const InvertedIndexResultBitmap& 
other) {
-        if (_data_bitmap && _null_bitmap && other._data_bitmap && 
other._null_bitmap) {
+        if (_data_bitmap && other._data_bitmap) {
             *_data_bitmap -= *other._data_bitmap;
-            *_data_bitmap -= *other._null_bitmap;
-            *_null_bitmap -= *other._null_bitmap;
+            if (other._null_bitmap) {
+                *_data_bitmap -= *other._null_bitmap;
+            }
+            if (_null_bitmap && other._null_bitmap) {
+                *_null_bitmap -= *other._null_bitmap;
+            }
         }
         return *this;
     }
@@ -181,6 +205,12 @@ public:
 
     // Check if both bitmaps are empty
     bool is_empty() const { return (_data_bitmap == nullptr && _null_bitmap == 
nullptr); }
+
+private:
+    static const roaring::Roaring& _empty_bitmap() {
+        static const roaring::Roaring empty;
+        return empty;
+    }
 };
 
 class InvertedIndexReader : public IndexReader {
diff --git a/be/src/runtime/exec_env_init.cpp b/be/src/runtime/exec_env_init.cpp
index b7a049b587b..4857a9acf43 100644
--- a/be/src/runtime/exec_env_init.cpp
+++ b/be/src/runtime/exec_env_init.cpp
@@ -640,7 +640,7 @@ Status ExecEnv::init_mem_env() {
     _inverted_index_query_cache = InvertedIndexQueryCache::create_global_cache(
             inverted_index_query_cache_limit, 
config::inverted_index_query_cache_shards);
     LOG(INFO) << "Inverted index query match cache memory limit: "
-              << PrettyPrinter::print(inverted_index_cache_limit, TUnit::BYTES)
+              << PrettyPrinter::print(inverted_index_query_cache_limit, 
TUnit::BYTES)
               << ", origin config value: " << 
config::inverted_index_query_cache_limit;
 
     // use memory limit
diff --git a/be/src/vec/exprs/vsearch.cpp b/be/src/vec/exprs/vsearch.cpp
index f2d9894f2a5..5043990172e 100644
--- a/be/src/vec/exprs/vsearch.cpp
+++ b/be/src/vec/exprs/vsearch.cpp
@@ -24,6 +24,7 @@
 #include "common/status.h"
 #include "glog/logging.h"
 #include "olap/rowset/segment_v2/inverted_index_reader.h"
+#include "runtime/runtime_state.h"
 #include "vec/columns/column_const.h"
 #include "vec/exprs/vexpr_context.h"
 #include "vec/exprs/vliteral.h"
@@ -120,6 +121,16 @@ VSearchExpr::VSearchExpr(const TExprNode& node) : 
VExpr(node) {
     }
 }
 
+Status VSearchExpr::prepare(RuntimeState* state, const RowDescriptor& row_desc,
+                            VExprContext* context) {
+    RETURN_IF_ERROR(VExpr::prepare(state, row_desc, context));
+    const auto& query_options = state->query_options();
+    if (query_options.__isset.enable_inverted_index_query_cache) {
+        _enable_cache = query_options.enable_inverted_index_query_cache;
+    }
+    return Status::OK();
+}
+
 const std::string& VSearchExpr::expr_name() const {
     static const std::string name = "VSearchExpr";
     return name;
@@ -164,7 +175,8 @@ Status VSearchExpr::evaluate_inverted_index(VExprContext* 
context, uint32_t segm
     auto function = std::make_shared<FunctionSearch>();
     auto result_bitmap = InvertedIndexResultBitmap();
     auto status = function->evaluate_inverted_index_with_search_param(
-            _search_param, bundle.field_types, bundle.iterators, 
segment_num_rows, result_bitmap);
+            _search_param, bundle.field_types, bundle.iterators, 
segment_num_rows, result_bitmap,
+            _enable_cache);
 
     if (!status.ok()) {
         LOG(WARNING) << "VSearchExpr: Function evaluation failed: " << 
status.to_string();
diff --git a/be/src/vec/exprs/vsearch.h b/be/src/vec/exprs/vsearch.h
index 9f818e50ee4..a7cfaa84ef0 100644
--- a/be/src/vec/exprs/vsearch.h
+++ b/be/src/vec/exprs/vsearch.h
@@ -41,10 +41,15 @@ public:
     bool can_push_down_to_index() const override { return true; }
 
     const TSearchParam& get_search_param() const { return _search_param; }
+    bool enable_cache() const { return _enable_cache; }
+
+    Status prepare(RuntimeState* state, const RowDescriptor& row_desc,
+                   VExprContext* context) override;
 
 private:
     TSearchParam _search_param;
     std::string _original_dsl;
+    bool _enable_cache = true;
 };
 
 } // namespace doris::vectorized
diff --git a/be/src/vec/functions/function_search.cpp 
b/be/src/vec/functions/function_search.cpp
index 2b5e8d9d305..1aa12b658f3 100644
--- a/be/src/vec/functions/function_search.cpp
+++ b/be/src/vec/functions/function_search.cpp
@@ -49,7 +49,9 @@
 #include "olap/rowset/segment_v2/inverted_index/util/string_helper.h"
 #include "olap/rowset/segment_v2/inverted_index_iterator.h"
 #include "olap/rowset/segment_v2/inverted_index_reader.h"
+#include "olap/rowset/segment_v2/inverted_index_searcher.h"
 #include "util/string_util.h"
+#include "util/thrift_util.h"
 #include "vec/columns/column_const.h"
 #include "vec/core/columns_with_type_and_name.h"
 #include "vec/data_types/data_type_string.h"
@@ -57,6 +59,48 @@
 
 namespace doris::vectorized {
 
+// Build canonical DSL signature for cache key.
+// Serializes the entire TSearchParam via Thrift binary protocol so that
+// every field (DSL, AST root, field bindings, default_operator,
+// minimum_should_match, etc.) is included automatically.
+static std::string build_dsl_signature(const TSearchParam& param) {
+    ThriftSerializer ser(false, 1024);
+    TSearchParam copy = param;
+    std::string sig;
+    auto st = ser.serialize(&copy, &sig);
+    if (UNLIKELY(!st.ok())) {
+        LOG(WARNING) << "build_dsl_signature: Thrift serialization failed: " 
<< st.to_string()
+                     << ", caching disabled for this query";
+        return "";
+    }
+    return sig;
+}
+
+// Extract segment path prefix from the first available inverted index 
iterator.
+// All fields in the same segment share the same path prefix.
+static std::string extract_segment_prefix(
+        const std::unordered_map<std::string, IndexIterator*>& iterators) {
+    for (const auto& [field_name, iter] : iterators) {
+        auto* inv_iter = dynamic_cast<InvertedIndexIterator*>(iter);
+        if (!inv_iter) continue;
+        // Try fulltext reader first, then string type
+        for (auto type :
+             {InvertedIndexReaderType::FULLTEXT, 
InvertedIndexReaderType::STRING_TYPE}) {
+            IndexReaderType reader_type = type;
+            auto reader = inv_iter->get_reader(reader_type);
+            if (!reader) continue;
+            auto inv_reader = 
std::dynamic_pointer_cast<InvertedIndexReader>(reader);
+            if (!inv_reader) continue;
+            auto file_reader = inv_reader->get_index_file_reader();
+            if (!file_reader) continue;
+            return file_reader->get_index_path_prefix();
+        }
+    }
+    VLOG_DEBUG << "extract_segment_prefix: no suitable inverted index reader 
found across "
+               << iterators.size() << " iterators, caching disabled for this 
query";
+    return "";
+}
+
 Status FieldReaderResolver::resolve(const std::string& field_name,
                                     InvertedIndexQueryType query_type,
                                     FieldReaderBinding* binding) {
@@ -149,33 +193,58 @@ Status FieldReaderResolver::resolve(const std::string& 
field_name,
                 "index file reader is null for field '{}'", field_name);
     }
 
-    RETURN_IF_ERROR(
-            index_file_reader->init(config::inverted_index_read_buffer_size, 
_context->io_ctx));
-
-    auto directory = DORIS_TRY(
-            index_file_reader->open(&inverted_reader->get_index_meta(), 
_context->io_ctx));
-
-    lucene::index::IndexReader* raw_reader = nullptr;
-    try {
-        raw_reader = lucene::index::IndexReader::open(
-                directory.get(), config::inverted_index_read_buffer_size, 
false);
-    } catch (const CLuceneError& e) {
-        return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>(
-                "failed to open IndexReader for field '{}': {}", field_name, 
e.what());
+    // Use InvertedIndexSearcherCache to avoid re-opening index files 
repeatedly
+    auto index_file_key =
+            
index_file_reader->get_index_file_cache_key(&inverted_reader->get_index_meta());
+    InvertedIndexSearcherCache::CacheKey searcher_cache_key(index_file_key);
+    InvertedIndexCacheHandle searcher_cache_handle;
+    bool cache_hit = 
InvertedIndexSearcherCache::instance()->lookup(searcher_cache_key,
+                                                                    
&searcher_cache_handle);
+
+    std::shared_ptr<lucene::index::IndexReader> reader_holder;
+    if (cache_hit) {
+        auto searcher_variant = searcher_cache_handle.get_index_searcher();
+        auto* searcher_ptr = 
std::get_if<FulltextIndexSearcherPtr>(&searcher_variant);
+        if (searcher_ptr != nullptr && *searcher_ptr != nullptr) {
+            reader_holder = std::shared_ptr<lucene::index::IndexReader>(
+                    (*searcher_ptr)->getReader(),
+                    [](lucene::index::IndexReader*) { /* lifetime managed by 
searcher cache */ });
+        }
     }
 
-    if (raw_reader == nullptr) {
-        return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>(
-                "IndexReader is null for field '{}'", field_name);
+    if (!reader_holder) {
+        // Cache miss: open directory, build IndexSearcher, insert into cache
+        RETURN_IF_ERROR(
+                
index_file_reader->init(config::inverted_index_read_buffer_size, 
_context->io_ctx));
+        auto directory = DORIS_TRY(
+                index_file_reader->open(&inverted_reader->get_index_meta(), 
_context->io_ctx));
+
+        auto index_searcher_builder = DORIS_TRY(
+                
IndexSearcherBuilder::create_index_searcher_builder(inverted_reader->type()));
+        auto searcher_result =
+                
DORIS_TRY(index_searcher_builder->get_index_searcher(directory.get()));
+        auto reader_size = index_searcher_builder->get_reader_size();
+
+        auto* cache_value = new 
InvertedIndexSearcherCache::CacheValue(std::move(searcher_result),
+                                                                       
reader_size, UnixMillis());
+        InvertedIndexSearcherCache::instance()->insert(searcher_cache_key, 
cache_value,
+                                                       &searcher_cache_handle);
+
+        auto new_variant = searcher_cache_handle.get_index_searcher();
+        auto* new_ptr = std::get_if<FulltextIndexSearcherPtr>(&new_variant);
+        if (new_ptr != nullptr && *new_ptr != nullptr) {
+            reader_holder = std::shared_ptr<lucene::index::IndexReader>(
+                    (*new_ptr)->getReader(),
+                    [](lucene::index::IndexReader*) { /* lifetime managed by 
searcher cache */ });
+        }
+
+        if (!reader_holder) {
+            return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>(
+                    "failed to build IndexSearcher for field '{}'", 
field_name);
+        }
     }
 
-    auto reader_holder = std::shared_ptr<lucene::index::IndexReader>(
-            raw_reader, [](lucene::index::IndexReader* reader) {
-                if (reader != nullptr) {
-                    reader->close();
-                    _CLDELETE(reader);
-                }
-            });
+    _searcher_cache_handles.push_back(std::move(searcher_cache_handle));
 
     FieldReaderBinding resolved;
     resolved.logical_field_name = field_name;
@@ -226,7 +295,7 @@ Status 
FunctionSearch::evaluate_inverted_index_with_search_param(
         const std::unordered_map<std::string, 
vectorized::IndexFieldNameAndTypePair>&
                 data_type_with_names,
         std::unordered_map<std::string, IndexIterator*> iterators, uint32_t 
num_rows,
-        InvertedIndexResultBitmap& bitmap_result) const {
+        InvertedIndexResultBitmap& bitmap_result, bool enable_cache) const {
     if (iterators.empty() || data_type_with_names.empty()) {
         LOG(INFO) << "No indexed columns or iterators available, returning 
empty result, dsl:"
                   << search_param.original_dsl;
@@ -235,6 +304,45 @@ Status 
FunctionSearch::evaluate_inverted_index_with_search_param(
         return Status::OK();
     }
 
+    // DSL result cache: reuse InvertedIndexQueryCache with SEARCH_DSL_QUERY 
type
+    auto* dsl_cache = enable_cache ? InvertedIndexQueryCache::instance() : 
nullptr;
+    std::string seg_prefix;
+    std::string dsl_sig;
+    InvertedIndexQueryCache::CacheKey dsl_cache_key;
+    bool cache_usable = false;
+    if (dsl_cache) {
+        seg_prefix = extract_segment_prefix(iterators);
+        dsl_sig = build_dsl_signature(search_param);
+        if (!seg_prefix.empty() && !dsl_sig.empty()) {
+            dsl_cache_key = InvertedIndexQueryCache::CacheKey {
+                    seg_prefix, "__search_dsl__", 
InvertedIndexQueryType::SEARCH_DSL_QUERY,
+                    dsl_sig};
+            cache_usable = true;
+            InvertedIndexQueryCacheHandle dsl_cache_handle;
+            if (dsl_cache->lookup(dsl_cache_key, &dsl_cache_handle)) {
+                auto cached_bitmap = dsl_cache_handle.get_bitmap();
+                if (cached_bitmap) {
+                    // Also retrieve cached null bitmap for three-valued SQL 
logic
+                    // (needed by compound operators NOT, OR, AND in 
VCompoundPred)
+                    auto null_cache_key = InvertedIndexQueryCache::CacheKey {
+                            seg_prefix, "__search_dsl__", 
InvertedIndexQueryType::SEARCH_DSL_QUERY,
+                            dsl_sig + "__null"};
+                    InvertedIndexQueryCacheHandle null_cache_handle;
+                    std::shared_ptr<roaring::Roaring> null_bitmap;
+                    if (dsl_cache->lookup(null_cache_key, &null_cache_handle)) 
{
+                        null_bitmap = null_cache_handle.get_bitmap();
+                    }
+                    if (!null_bitmap) {
+                        null_bitmap = std::make_shared<roaring::Roaring>();
+                    }
+                    bitmap_result =
+                            InvertedIndexResultBitmap(cached_bitmap, 
std::move(null_bitmap));
+                    return Status::OK();
+                }
+            }
+        }
+    }
+
     auto context = std::make_shared<IndexQueryContext>();
     context->collection_statistics = std::make_shared<CollectionStatistics>();
     context->collection_similarity = std::make_shared<CollectionSimilarity>();
@@ -352,6 +460,21 @@ Status 
FunctionSearch::evaluate_inverted_index_with_search_param(
     VLOG_TRACE << "search: After mask - result_bitmap="
                << bitmap_result.get_data_bitmap()->cardinality();
 
+    // Insert post-mask_out_null result into DSL cache for future reuse
+    // Cache both data bitmap and null bitmap so compound operators (NOT, OR, 
AND)
+    // can apply correct three-valued SQL logic on cache hit
+    if (dsl_cache && cache_usable) {
+        InvertedIndexQueryCacheHandle insert_handle;
+        dsl_cache->insert(dsl_cache_key, bitmap_result.get_data_bitmap(), 
&insert_handle);
+        if (bitmap_result.get_null_bitmap()) {
+            auto null_cache_key = InvertedIndexQueryCache::CacheKey {
+                    seg_prefix, "__search_dsl__", 
InvertedIndexQueryType::SEARCH_DSL_QUERY,
+                    dsl_sig + "__null"};
+            InvertedIndexQueryCacheHandle null_insert_handle;
+            dsl_cache->insert(null_cache_key, bitmap_result.get_null_bitmap(), 
&null_insert_handle);
+        }
+    }
+
     return Status::OK();
 }
 
@@ -850,37 +973,6 @@ Status FunctionSearch::build_leaf_query(const 
TSearchClause& clause,
     return Status::OK();
 }
 
-Status FunctionSearch::collect_all_field_nulls(
-        const TSearchClause& clause,
-        const std::unordered_map<std::string, IndexIterator*>& iterators,
-        std::shared_ptr<roaring::Roaring>& null_bitmap) const {
-    // Recursively collect NULL bitmaps from all fields referenced in the query
-    if (clause.__isset.field_name) {
-        const std::string& field_name = clause.field_name;
-        auto it = iterators.find(field_name);
-        if (it != iterators.end() && it->second) {
-            auto has_null_result = it->second->has_null();
-            if (has_null_result.has_value() && has_null_result.value()) {
-                segment_v2::InvertedIndexQueryCacheHandle 
null_bitmap_cache_handle;
-                
RETURN_IF_ERROR(it->second->read_null_bitmap(&null_bitmap_cache_handle));
-                auto field_null_bitmap = null_bitmap_cache_handle.get_bitmap();
-                if (field_null_bitmap) {
-                    *null_bitmap |= *field_null_bitmap;
-                }
-            }
-        }
-    }
-
-    // Recurse into child clauses
-    if (clause.__isset.children) {
-        for (const auto& child_clause : clause.children) {
-            RETURN_IF_ERROR(collect_all_field_nulls(child_clause, iterators, 
null_bitmap));
-        }
-    }
-
-    return Status::OK();
-}
-
 void register_function_search(SimpleFunctionFactory& factory) {
     factory.register_function<FunctionSearch>();
 }
diff --git a/be/src/vec/functions/function_search.h 
b/be/src/vec/functions/function_search.h
index d8b7c08fac6..d86f23605b2 100644
--- a/be/src/vec/functions/function_search.h
+++ b/be/src/vec/functions/function_search.h
@@ -28,6 +28,7 @@
 #include "gen_cpp/Exprs_types.h"
 #include "olap/rowset/segment_v2/index_query_context.h"
 #include 
"olap/rowset/segment_v2/inverted_index/query_v2/boolean_query/operator_boolean_query.h"
+#include "olap/rowset/segment_v2/inverted_index_cache.h"
 #include "vec/core/block.h"
 #include "vec/core/types.h"
 #include "vec/data_types/data_type.h"
@@ -121,6 +122,9 @@ private:
     std::vector<std::shared_ptr<lucene::index::IndexReader>> _readers;
     std::unordered_map<std::string, 
std::shared_ptr<lucene::index::IndexReader>> _binding_readers;
     std::unordered_map<std::wstring, 
std::shared_ptr<lucene::index::IndexReader>> _field_readers;
+    // Keep searcher cache handles alive for the resolver's lifetime.
+    // This pins cached IndexSearcher entries so extracted IndexReaders remain 
valid.
+    std::vector<segment_v2::InvertedIndexCacheHandle> _searcher_cache_handles;
 };
 
 class FunctionSearch : public IFunction {
@@ -163,7 +167,7 @@ public:
             const std::unordered_map<std::string, 
vectorized::IndexFieldNameAndTypePair>&
                     data_type_with_names,
             std::unordered_map<std::string, IndexIterator*> iterators, 
uint32_t num_rows,
-            InvertedIndexResultBitmap& bitmap_result) const;
+            InvertedIndexResultBitmap& bitmap_result, bool enable_cache = 
true) const;
 
     // Public methods for testing
     enum class ClauseTypeCategory {
@@ -193,10 +197,6 @@ public:
                             FieldReaderResolver& resolver, 
inverted_index::query_v2::QueryPtr* out,
                             std::string* binding_key, const std::string& 
default_operator,
                             int32_t minimum_should_match) const;
-
-    Status collect_all_field_nulls(const TSearchClause& clause,
-                                   const std::unordered_map<std::string, 
IndexIterator*>& iterators,
-                                   std::shared_ptr<roaring::Roaring>& 
null_bitmap) const;
 };
 
 } // namespace doris::vectorized
diff --git 
a/be/test/olap/rowset/segment_v2/search_function_query_cache_test.cpp 
b/be/test/olap/rowset/segment_v2/search_function_query_cache_test.cpp
new file mode 100644
index 00000000000..fc257f02fb1
--- /dev/null
+++ b/be/test/olap/rowset/segment_v2/search_function_query_cache_test.cpp
@@ -0,0 +1,207 @@
+// 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 <gtest/gtest.h>
+
+#include <memory>
+#include <roaring/roaring.hh>
+#include <string>
+
+#include "olap/rowset/segment_v2/inverted_index_cache.h"
+#include "olap/rowset/segment_v2/inverted_index_query_type.h"
+
+namespace doris::segment_v2 {
+
+class SearchDslQueryCacheTest : public testing::Test {
+public:
+    static const int kCacheSize = 4096;
+
+    void SetUp() override { _cache = new InvertedIndexQueryCache(kCacheSize, 
1); }
+
+    void TearDown() override { delete _cache; }
+
+    InvertedIndexQueryCache::CacheKey make_key(const std::string& seg_prefix,
+                                               const std::string& dsl_sig) {
+        return InvertedIndexQueryCache::CacheKey {
+                seg_prefix, "__search_dsl__", 
InvertedIndexQueryType::SEARCH_DSL_QUERY, dsl_sig};
+    }
+
+protected:
+    InvertedIndexQueryCache* _cache = nullptr;
+};
+
+TEST_F(SearchDslQueryCacheTest, insert_and_lookup) {
+    auto bm = std::make_shared<roaring::Roaring>();
+    bm->add(1);
+    bm->add(3);
+    bm->add(5);
+
+    auto key = make_key("segment_0001", "thrift_sig_abc");
+
+    InvertedIndexQueryCacheHandle handle;
+    _cache->insert(key, bm, &handle);
+
+    // Lookup should succeed
+    InvertedIndexQueryCacheHandle lookup_handle;
+    EXPECT_TRUE(_cache->lookup(key, &lookup_handle));
+
+    auto cached_bm = lookup_handle.get_bitmap();
+    ASSERT_NE(cached_bm, nullptr);
+    EXPECT_TRUE(cached_bm->contains(1));
+    EXPECT_TRUE(cached_bm->contains(3));
+    EXPECT_TRUE(cached_bm->contains(5));
+    EXPECT_FALSE(cached_bm->contains(2));
+    EXPECT_EQ(cached_bm->cardinality(), 3);
+}
+
+TEST_F(SearchDslQueryCacheTest, lookup_miss) {
+    auto key = make_key("segment_0001", "thrift_sig_abc");
+
+    InvertedIndexQueryCacheHandle handle;
+    EXPECT_FALSE(_cache->lookup(key, &handle));
+}
+
+TEST_F(SearchDslQueryCacheTest, different_keys_independent) {
+    auto bm1 = std::make_shared<roaring::Roaring>();
+    bm1->add(10);
+    auto bm2 = std::make_shared<roaring::Roaring>();
+    bm2->add(20);
+
+    auto key1 = make_key("seg_a", "dsl_1");
+    auto key2 = make_key("seg_a", "dsl_2");
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key1, bm1, &h);
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key2, bm2, &h);
+    }
+
+    // Lookup key1
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(key1, &h));
+        auto cached = h.get_bitmap();
+        ASSERT_NE(cached, nullptr);
+        EXPECT_TRUE(cached->contains(10));
+        EXPECT_FALSE(cached->contains(20));
+    }
+
+    // Lookup key2
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(key2, &h));
+        auto cached = h.get_bitmap();
+        ASSERT_NE(cached, nullptr);
+        EXPECT_TRUE(cached->contains(20));
+        EXPECT_FALSE(cached->contains(10));
+    }
+}
+
+TEST_F(SearchDslQueryCacheTest, different_segments_independent) {
+    auto bm1 = std::make_shared<roaring::Roaring>();
+    bm1->add(1);
+    auto bm2 = std::make_shared<roaring::Roaring>();
+    bm2->add(2);
+
+    auto key1 = make_key("seg_a", "same_dsl");
+    auto key2 = make_key("seg_b", "same_dsl");
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key1, bm1, &h);
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key2, bm2, &h);
+    }
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(key1, &h));
+        EXPECT_TRUE(h.get_bitmap()->contains(1));
+        EXPECT_FALSE(h.get_bitmap()->contains(2));
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(key2, &h));
+        EXPECT_TRUE(h.get_bitmap()->contains(2));
+        EXPECT_FALSE(h.get_bitmap()->contains(1));
+    }
+}
+
+TEST_F(SearchDslQueryCacheTest, no_collision_with_regular_query_cache) {
+    // SEARCH_DSL_QUERY key should not collide with a regular EQUAL_QUERY key
+    // even with same index_path and value
+    auto bm_dsl = std::make_shared<roaring::Roaring>();
+    bm_dsl->add(100);
+    auto bm_eq = std::make_shared<roaring::Roaring>();
+    bm_eq->add(200);
+
+    InvertedIndexQueryCache::CacheKey dsl_key {
+            "seg_a", "__search_dsl__", 
InvertedIndexQueryType::SEARCH_DSL_QUERY, "some_value"};
+    InvertedIndexQueryCache::CacheKey eq_key {"seg_a", "__search_dsl__",
+                                              
InvertedIndexQueryType::EQUAL_QUERY, "some_value"};
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(dsl_key, bm_dsl, &h);
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(eq_key, bm_eq, &h);
+    }
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(dsl_key, &h));
+        EXPECT_TRUE(h.get_bitmap()->contains(100));
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        EXPECT_TRUE(_cache->lookup(eq_key, &h));
+        EXPECT_TRUE(h.get_bitmap()->contains(200));
+    }
+}
+
+TEST_F(SearchDslQueryCacheTest, overwrite_same_key) {
+    auto bm1 = std::make_shared<roaring::Roaring>();
+    bm1->add(1);
+    auto bm2 = std::make_shared<roaring::Roaring>();
+    bm2->add(99);
+
+    auto key = make_key("seg", "dsl");
+
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key, bm1, &h);
+    }
+    {
+        InvertedIndexQueryCacheHandle h;
+        _cache->insert(key, bm2, &h);
+    }
+
+    InvertedIndexQueryCacheHandle h;
+    EXPECT_TRUE(_cache->lookup(key, &h));
+    auto cached = h.get_bitmap();
+    ASSERT_NE(cached, nullptr);
+    EXPECT_TRUE(cached->contains(99));
+}
+
+} // namespace doris::segment_v2
diff --git a/be/test/vec/function/function_search_test.cpp 
b/be/test/vec/function/function_search_test.cpp
index 4daa48f662a..4e5b7cb2e84 100644
--- a/be/test/vec/function/function_search_test.cpp
+++ b/be/test/vec/function/function_search_test.cpp
@@ -59,40 +59,6 @@ public:
     Result<bool> has_null() override { return false; }
 };
 
-class TrackingIndexIterator : public segment_v2::IndexIterator {
-public:
-    explicit TrackingIndexIterator(bool has_null) : _has_null(has_null) {}
-
-    segment_v2::IndexReaderPtr get_reader(
-            segment_v2::IndexReaderType /*reader_type*/) const override {
-        return nullptr;
-    }
-
-    Status read_from_index(const segment_v2::IndexParam& /*param*/) override {
-        return Status::OK();
-    }
-
-    Status read_null_bitmap(segment_v2::InvertedIndexQueryCacheHandle* 
/*cache_handle*/) override {
-        ++_read_null_bitmap_calls;
-        return Status::OK();
-    }
-
-    Result<bool> has_null() override {
-        ++_has_null_checks;
-        return _has_null;
-    }
-
-    int read_null_bitmap_calls() const { return _read_null_bitmap_calls; }
-    int has_null_checks() const { return _has_null_checks; }
-
-    void set_has_null(bool value) { _has_null = value; }
-
-private:
-    bool _has_null = false;
-    int _read_null_bitmap_calls = 0;
-    int _has_null_checks = 0;
-};
-
 TEST_F(FunctionSearchTest, TestGetName) {
     EXPECT_EQ("search", function_search->get_name());
 }
@@ -1561,40 +1527,6 @@ TEST_F(FunctionSearchTest, 
TestEvaluateInvertedIndexWithSearchParamComplexQuery)
 }
 
 TEST_F(FunctionSearchTest, TestOrCrossFieldMatchesMatchAnyRows) {
-    TSearchClause left_clause;
-    left_clause.clause_type = "TERM";
-    left_clause.field_name = "title";
-    left_clause.value = "foo";
-    left_clause.__isset.field_name = true;
-    left_clause.__isset.value = true;
-
-    TSearchClause right_clause;
-    right_clause.clause_type = "TERM";
-    right_clause.field_name = "content";
-    right_clause.value = "bar";
-    right_clause.__isset.field_name = true;
-    right_clause.__isset.value = true;
-
-    TSearchClause root_clause;
-    root_clause.clause_type = "OR";
-    root_clause.children = {left_clause, right_clause};
-    root_clause.__isset.children = true;
-
-    auto left_iterator = std::make_unique<TrackingIndexIterator>(true);
-    auto right_iterator = std::make_unique<TrackingIndexIterator>(true);
-
-    std::unordered_map<std::string, IndexIterator*> iterators_map = {
-            {"title", left_iterator.get()}, {"content", right_iterator.get()}};
-
-    auto null_bitmap = std::make_shared<roaring::Roaring>();
-    auto status = function_search->collect_all_field_nulls(root_clause, 
iterators_map, null_bitmap);
-    EXPECT_TRUE(status.ok());
-    EXPECT_GE(left_iterator->has_null_checks(), 1);
-    EXPECT_GE(right_iterator->has_null_checks(), 1);
-    EXPECT_GE(left_iterator->read_null_bitmap_calls(), 1);
-    EXPECT_GE(right_iterator->read_null_bitmap_calls(), 1);
-    EXPECT_TRUE(null_bitmap->isEmpty());
-
     auto data_bitmap = std::make_shared<roaring::Roaring>();
     data_bitmap->add(1);
     data_bitmap->add(3);
@@ -1622,38 +1554,6 @@ TEST_F(FunctionSearchTest, 
TestOrCrossFieldMatchesMatchAnyRows) {
 }
 
 TEST_F(FunctionSearchTest, TestOrWithNotSameFieldMatchesMatchAllRows) {
-    TSearchClause include_clause;
-    include_clause.clause_type = "TERM";
-    include_clause.field_name = "title";
-    include_clause.value = "foo";
-    include_clause.__isset.field_name = true;
-    include_clause.__isset.value = true;
-
-    TSearchClause exclude_child;
-    exclude_child.clause_type = "TERM";
-    exclude_child.field_name = "title";
-    exclude_child.value = "bar";
-    exclude_child.__isset.field_name = true;
-    exclude_child.__isset.value = true;
-
-    TSearchClause exclude_clause;
-    exclude_clause.clause_type = "NOT";
-    exclude_clause.children = {exclude_child};
-
-    TSearchClause root_clause;
-    root_clause.clause_type = "OR";
-    root_clause.children = {include_clause, exclude_clause};
-    root_clause.__isset.children = true;
-
-    auto iterator = std::make_unique<TrackingIndexIterator>(true);
-    std::unordered_map<std::string, IndexIterator*> iterators_map = {{"title", 
iterator.get()}};
-
-    auto null_bitmap = std::make_shared<roaring::Roaring>();
-    auto status = function_search->collect_all_field_nulls(root_clause, 
iterators_map, null_bitmap);
-    EXPECT_TRUE(status.ok());
-    EXPECT_GE(iterator->has_null_checks(), 1);
-    EXPECT_GE(iterator->read_null_bitmap_calls(), 1);
-
     auto data_bitmap = std::make_shared<roaring::Roaring>();
     data_bitmap->add(1);
     data_bitmap->add(2);
@@ -2201,4 +2101,19 @@ TEST_F(FunctionSearchTest, 
TestEvaluateInvertedIndexWithOccurBoolean) {
     EXPECT_TRUE(status.is<ErrorCode::INVERTED_INDEX_FILE_NOT_FOUND>());
 }
 
+TEST_F(FunctionSearchTest, TestSearcherCacheHandlesLifetime) {
+    // Verify FieldReaderResolver keeps _searcher_cache_handles alive
+    std::unordered_map<std::string, vectorized::IndexFieldNameAndTypePair> 
data_types;
+    std::unordered_map<std::string, IndexIterator*> iterators;
+    auto context = std::make_shared<IndexQueryContext>();
+
+    FieldReaderResolver resolver(data_types, iterators, context);
+
+    // The resolver should have an empty cache handles vector initially
+    // (We can't directly access _searcher_cache_handles, but we can verify
+    // that binding_cache is empty)
+    EXPECT_TRUE(resolver.binding_cache().empty());
+    EXPECT_TRUE(resolver.readers().empty());
+}
+
 } // namespace doris::vectorized
diff --git a/regression-test/suites/search/test_search_boundary_cases.groovy 
b/regression-test/suites/search/test_search_boundary_cases.groovy
index b7c3a931252..5ab2a9386aa 100644
--- a/regression-test/suites/search/test_search_boundary_cases.groovy
+++ b/regression-test/suites/search/test_search_boundary_cases.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_boundary_cases") {
+suite("test_search_boundary_cases", "p0") {
     def tableName = "search_boundary_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_cache.groovy 
b/regression-test/suites/search/test_search_cache.groovy
new file mode 100644
index 00000000000..39af2184bb7
--- /dev/null
+++ b/regression-test/suites/search/test_search_cache.groovy
@@ -0,0 +1,138 @@
+// 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.
+
+suite("test_search_cache", "p0") {
+    def tableName = "search_cache_test"
+
+    sql "DROP TABLE IF EXISTS ${tableName}"
+    sql """
+        CREATE TABLE ${tableName} (
+            id INT,
+            title VARCHAR(200),
+            content VARCHAR(500),
+            INDEX idx_title(title) USING INVERTED PROPERTIES("parser" = 
"english"),
+            INDEX idx_content(content) USING INVERTED PROPERTIES("parser" = 
"english")
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+    """
+
+    sql """INSERT INTO ${tableName} VALUES
+        (1, 'apple banana cherry', 'red fruit sweet'),
+        (2, 'banana grape mango', 'yellow fruit tropical'),
+        (3, 'cherry plum peach', 'stone fruit summer'),
+        (4, 'apple grape kiwi', 'green fruit fresh'),
+        (5, 'mango pineapple coconut', 'tropical fruit exotic'),
+        (6, 'apple cherry plum', 'mixed fruit salad'),
+        (7, 'banana coconut papaya', 'smoothie blend tropical'),
+        (8, 'grape cherry apple', 'wine fruit tart')
+    """
+    sql """sync"""
+
+    // sync ensures data is flushed. Sleep is a best-effort wait for
+    // background index availability; may need to increase under load.
+    Thread.sleep(2000)
+
+    // Test 1: Cache consistency - same query returns same results with cache 
enabled
+    def result1 = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id FROM ${tableName}
+        WHERE search('title:apple')
+        ORDER BY id
+    """
+
+    // Run same query again (should hit cache)
+    def result2 = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id FROM ${tableName}
+        WHERE search('title:apple')
+        ORDER BY id
+    """
+
+    // Results must be identical (cache hit returns same data)
+    assertEquals(result1, result2)
+
+    // Test 2: Cache disabled returns same results as cache enabled
+    def result_no_cache = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=false)
 */
+        id FROM ${tableName}
+        WHERE search('title:apple')
+        ORDER BY id
+    """
+    assertEquals(result1, result_no_cache)
+
+    // Test 3: Multi-field query cache consistency
+    def mf_result1 = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id FROM ${tableName}
+        WHERE search('title:cherry OR content:tropical')
+        ORDER BY id
+    """
+
+    def mf_result2 = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id FROM ${tableName}
+        WHERE search('title:cherry OR content:tropical')
+        ORDER BY id
+    """
+    assertEquals(mf_result1, mf_result2)
+
+    // Test 4: Different queries produce different cache entries
+    def diff_result = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id FROM ${tableName}
+        WHERE search('title:banana')
+        ORDER BY id
+    """
+    // banana result should differ from apple result
+    assertNotEquals(result1, diff_result)
+
+    // Test 5: AND query - cache vs no-cache consistency
+    def and_cached = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id, title FROM ${tableName}
+        WHERE search('title:apple AND title:cherry')
+        ORDER BY id
+    """
+
+    def and_uncached = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=false)
 */
+        id, title FROM ${tableName}
+        WHERE search('title:apple AND title:cherry')
+        ORDER BY id
+    """
+    assertEquals(and_cached, and_uncached)
+
+    // Test 6: Complex boolean query - cache vs no-cache consistency
+    def complex_cached = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=true)
 */
+        id, title FROM ${tableName}
+        WHERE search('(title:apple OR title:banana) AND content:fruit')
+        ORDER BY id
+    """
+
+    def complex_uncached = sql """
+        SELECT 
/*+SET_VAR(enable_common_expr_pushdown=true,enable_inverted_index_query_cache=false)
 */
+        id, title FROM ${tableName}
+        WHERE search('(title:apple OR title:banana) AND content:fruit')
+        ORDER BY id
+    """
+    assertEquals(complex_cached, complex_uncached)
+
+    sql "DROP TABLE IF EXISTS ${tableName}"
+}
diff --git 
a/regression-test/suites/search/test_search_default_field_operator.groovy 
b/regression-test/suites/search/test_search_default_field_operator.groovy
index 89d07a794be..654a9ad2abf 100644
--- a/regression-test/suites/search/test_search_default_field_operator.groovy
+++ b/regression-test/suites/search/test_search_default_field_operator.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_default_field_operator") {
+suite("test_search_default_field_operator", "p0") {
     def tableName = "search_enhanced_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_dsl_operators.groovy 
b/regression-test/suites/search/test_search_dsl_operators.groovy
index 7415dfab10a..08106c65243 100644
--- a/regression-test/suites/search/test_search_dsl_operators.groovy
+++ b/regression-test/suites/search/test_search_dsl_operators.groovy
@@ -35,7 +35,7 @@
  * - NOT marks current term as MUST_NOT (-)
  * - With minimum_should_match=0 and MUST clauses present, SHOULD clauses are 
discarded
  */
-suite("test_search_dsl_operators") {
+suite("test_search_dsl_operators", "p0") {
     def tableName = "search_dsl_operators_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_dsl_syntax.groovy 
b/regression-test/suites/search/test_search_dsl_syntax.groovy
index e0ec9010926..b52a018fa03 100644
--- a/regression-test/suites/search/test_search_dsl_syntax.groovy
+++ b/regression-test/suites/search/test_search_dsl_syntax.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_dsl_syntax") {
+suite("test_search_dsl_syntax", "p0") {
     def tableName = "search_dsl_test_table"
     
     sql "DROP TABLE IF EXISTS ${tableName}"
diff --git a/regression-test/suites/search/test_search_escape.groovy 
b/regression-test/suites/search/test_search_escape.groovy
index 18e999e2767..e7e09ca846c 100644
--- a/regression-test/suites/search/test_search_escape.groovy
+++ b/regression-test/suites/search/test_search_escape.groovy
@@ -29,7 +29,7 @@
  * - Groovy string: \\\\ -> SQL string: \\ -> DSL: \ (escape char)
  * - Groovy string: \\\\\\\\ -> SQL string: \\\\ -> DSL: \\ -> literal: \
  */
-suite("test_search_escape") {
+suite("test_search_escape", "p0") {
     def tableName = "search_escape_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_exact_basic.groovy 
b/regression-test/suites/search/test_search_exact_basic.groovy
index 0c9c368c340..ffad823e451 100644
--- a/regression-test/suites/search/test_search_exact_basic.groovy
+++ b/regression-test/suites/search/test_search_exact_basic.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_exact_basic") {
+suite("test_search_exact_basic", "p0") {
     def tableName = "exact_basic_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_exact_lowercase.groovy 
b/regression-test/suites/search/test_search_exact_lowercase.groovy
index 8389d7e72ed..f4b82d411b6 100644
--- a/regression-test/suites/search/test_search_exact_lowercase.groovy
+++ b/regression-test/suites/search/test_search_exact_lowercase.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_exact_lowercase") {
+suite("test_search_exact_lowercase", "p0") {
     def tableName = "exact_lowercase_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_exact_match.groovy 
b/regression-test/suites/search/test_search_exact_match.groovy
index caf55679375..6f8439b0d22 100644
--- a/regression-test/suites/search/test_search_exact_match.groovy
+++ b/regression-test/suites/search/test_search_exact_match.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_exact_match") {
+suite("test_search_exact_match", "p0") {
     def tableName = "search_exact_test_table"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_exact_multi_index.groovy 
b/regression-test/suites/search/test_search_exact_multi_index.groovy
index 1230b53aa4d..8c11a560970 100644
--- a/regression-test/suites/search/test_search_exact_multi_index.groovy
+++ b/regression-test/suites/search/test_search_exact_multi_index.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_exact_multi_index") {
+suite("test_search_exact_multi_index", "p0") {
     def tableName = "exact_multi_index_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_function.groovy 
b/regression-test/suites/search/test_search_function.groovy
index bf09ca237f6..61ee8e4b026 100644
--- a/regression-test/suites/search/test_search_function.groovy
+++ b/regression-test/suites/search/test_search_function.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_function") {
+suite("test_search_function", "p0") {
     def tableName = "search_test_table"
     def indexTableName = "search_test_index_table"
     
diff --git a/regression-test/suites/search/test_search_inverted_index.groovy 
b/regression-test/suites/search/test_search_inverted_index.groovy
index 6314a291bb3..d4d83384d76 100644
--- a/regression-test/suites/search/test_search_inverted_index.groovy
+++ b/regression-test/suites/search/test_search_inverted_index.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_inverted_index") {
+suite("test_search_inverted_index", "p0") {
     def tableWithIndex = "search_index_test_table"
     def tableWithoutIndex = "search_no_index_test_table"
 
diff --git a/regression-test/suites/search/test_search_lucene_mode.groovy 
b/regression-test/suites/search/test_search_lucene_mode.groovy
index f971d8a9729..4a2a5c7d94c 100644
--- a/regression-test/suites/search/test_search_lucene_mode.groovy
+++ b/regression-test/suites/search/test_search_lucene_mode.groovy
@@ -30,7 +30,7 @@
  * Enable Lucene mode with options parameter (JSON format):
  *   search(dsl, 
'{"default_field":"title","default_operator":"and","mode":"lucene"}')
  */
-suite("test_search_lucene_mode") {
+suite("test_search_lucene_mode", "p0") {
     def tableName = "search_lucene_mode_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_mow_support.groovy 
b/regression-test/suites/search/test_search_mow_support.groovy
index ce759ad99c9..279cc45b1df 100644
--- a/regression-test/suites/search/test_search_mow_support.groovy
+++ b/regression-test/suites/search/test_search_mow_support.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_mow_support") {
+suite("test_search_mow_support", "p0") {
     def tableName = "search_mow_support_tbl"
     sql "DROP TABLE IF EXISTS ${tableName}"
 
diff --git a/regression-test/suites/search/test_search_multi_field.groovy 
b/regression-test/suites/search/test_search_multi_field.groovy
index ca1c97eef5a..44f9d9afad9 100644
--- a/regression-test/suites/search/test_search_multi_field.groovy
+++ b/regression-test/suites/search/test_search_multi_field.groovy
@@ -30,7 +30,7 @@
  *
  * Multi-field search can also be combined with Lucene mode for 
MUST/SHOULD/MUST_NOT semantics.
  */
-suite("test_search_multi_field") {
+suite("test_search_multi_field", "p0") {
     def tableName = "search_multi_field_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_null_regression.groovy 
b/regression-test/suites/search/test_search_null_regression.groovy
index 70666341662..c2acfeb51b2 100644
--- a/regression-test/suites/search/test_search_null_regression.groovy
+++ b/regression-test/suites/search/test_search_null_regression.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_null_regression") {
+suite("test_search_null_regression", "p0") {
     def tableName = "search_null_regression_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_null_semantics.groovy 
b/regression-test/suites/search/test_search_null_semantics.groovy
index 1c49e350bf3..1a16adb03aa 100644
--- a/regression-test/suites/search/test_search_null_semantics.groovy
+++ b/regression-test/suites/search/test_search_null_semantics.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_null_semantics") {
+suite("test_search_null_semantics", "p0") {
     def tableName = "search_null_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git a/regression-test/suites/search/test_search_regexp_lowercase.groovy 
b/regression-test/suites/search/test_search_regexp_lowercase.groovy
index 7151ac1c10b..81b93d0de7d 100644
--- a/regression-test/suites/search/test_search_regexp_lowercase.groovy
+++ b/regression-test/suites/search/test_search_regexp_lowercase.groovy
@@ -19,7 +19,7 @@
 // Regex patterns are NOT lowercased (matching ES query_string behavior).
 // Wildcard patterns ARE lowercased (matching ES query_string normalizer 
behavior).
 
-suite("test_search_regexp_lowercase") {
+suite("test_search_regexp_lowercase", "p0") {
     def tableName = "search_regexp_lowercase_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.
diff --git 
a/regression-test/suites/search/test_search_usage_restrictions.groovy 
b/regression-test/suites/search/test_search_usage_restrictions.groovy
index ea31a4eb998..842fa5454ad 100644
--- a/regression-test/suites/search/test_search_usage_restrictions.groovy
+++ b/regression-test/suites/search/test_search_usage_restrictions.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_usage_restrictions") {
+suite("test_search_usage_restrictions", "p0") {
     def tableName = "search_usage_test_table"
     def tableName2 = "search_usage_test_table2"
 
diff --git 
a/regression-test/suites/search/test_search_variant_dual_index_reader.groovy 
b/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
index 6e53370381f..2463d126ba6 100644
--- a/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
+++ b/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
@@ -33,7 +33,7 @@
  * Before fix: search() returns empty (wrong reader selected)
  * After fix:  search() returns matching rows (correct FULLTEXT reader 
selected)
  */
-suite("test_search_variant_dual_index_reader") {
+suite("test_search_variant_dual_index_reader", "p0") {
     def tableName = "test_variant_dual_index_reader"
 
     sql """ set enable_match_without_inverted_index = false """
diff --git 
a/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy 
b/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
index 61c7c34c6e9..69bc0f29157 100644
--- 
a/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
+++ 
b/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
@@ -28,7 +28,7 @@
  * Fix: FE now looks up the Index for each field in SearchExpression and passes
  * the index_properties via TSearchFieldBinding to BE.
  */
-suite("test_search_variant_subcolumn_analyzer") {
+suite("test_search_variant_subcolumn_analyzer", "p0") {
     def tableName = "test_variant_subcolumn_analyzer"
 
     sql """ set enable_match_without_inverted_index = false """
diff --git 
a/regression-test/suites/search/test_search_vs_match_consistency.groovy 
b/regression-test/suites/search/test_search_vs_match_consistency.groovy
index ac4cd1f802b..5b2c7b4b664 100644
--- a/regression-test/suites/search/test_search_vs_match_consistency.groovy
+++ b/regression-test/suites/search/test_search_vs_match_consistency.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_search_vs_match_consistency") {
+suite("test_search_vs_match_consistency", "p0") {
     def tableName = "search_match_consistency_test"
 
     // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy 
testing.


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to