浏览代码

ESQL - Allow full text functions disjunctions for non-full text functions (#120291) (#121026)

(cherry picked from commit a87bd7ae2689409ce50ca300a62cc31678eacfcd)

# Conflicts:
#	docs/reference/esql/esql-limitations.asciidoc
Carlos Delgado 8 月之前
父节点
当前提交
2db1d4f545
共有 25 个文件被更改,包括 541 次插入177 次删除
  1. 5 0
      docs/changelog/120291.yaml
  2. 94 17
      docs/reference/esql/esql-limitations.asciidoc
  3. 1 1
      x-pack/plugin/build.gradle
  4. 19 4
      x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluator.java
  5. 2 2
      x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java
  6. 37 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec
  7. 37 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/match-function.csv-spec
  8. 37 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/match-operator.csv-spec
  9. 37 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec
  10. 10 19
      x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java
  11. 10 11
      x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java
  12. 5 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  13. 34 11
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java
  14. 7 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/mapper/EvaluatorMapper.java
  15. 4 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/mapper/ExpressionMapper.java
  16. 49 22
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java
  17. 11 16
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEqualsMapper.java
  18. 7 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java
  19. 2 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
  20. 2 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
  21. 52 47
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
  22. 59 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java
  23. 2 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java
  24. 9 3
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java
  25. 9 18
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/180_match_operator.yml

+ 5 - 0
docs/changelog/120291.yaml

@@ -0,0 +1,5 @@
+pr: 120291
+summary: ESQL - Allow full text functions disjunctions for non-full text functions
+area: ES|QL
+type: feature
+issues: []

+ 94 - 17
docs/reference/esql/esql-limitations.asciidoc

@@ -30,11 +30,11 @@ include::processing-commands/limit.asciidoc[tag=limitation]
 ** You can use `to_datetime` to cast to millisecond dates to use unsupported functions
 * `double` (`float`, `half_float`, `scaled_float` are represented as `double`)
 * `ip`
-* `keyword` family including `keyword`, `constant_keyword`, and `wildcard`
+* `keyword` <<keyword, family>> including `keyword`, `constant_keyword`, and `wildcard`
 * `int` (`short` and `byte` are represented as `int`)
 * `long`
 * `null`
-* `text`
+* `text` <<text, family>> including `text`, `semantic_text` and `match_only_text`
 * experimental:[] `unsigned_long`
 * `version`
 * Spatial types
@@ -112,33 +112,57 @@ it is necessary to use the search function, like <<esql-match>>, in a <<esql-whe
 directly after the <<esql-from>> source command, or close enough to it.
 Otherwise, the query will fail with a validation error.
 Another limitation is that any <<esql-where>> command containing a full-text search function
-cannot also use disjunctions (`OR`).
+cannot use disjunctions (`OR`), unless:
 
-Because of <<esql-limitations-text-fields,the way {esql} treats `text` values>>,
-queries on `text` fields are like queries on `keyword` fields: they are
-case-sensitive and need to match the full string.
+* All functions used in the OR clauses are full-text functions themselves, or scoring is not used
+
+For example, this query is valid:
 
-For example, after indexing a field of type `text` with the value `Elasticsearch
-query language`, the following `WHERE` clause does not match because the `LIKE`
-operator is case-sensitive:
 [source,esql]
 ----
-| WHERE field LIKE "elasticsearch query language"
+FROM books
+| WHERE MATCH(author, "Faulkner") AND MATCH(author, "Tolkien")
 ----
 
-The following `WHERE` clause does not match either, because the `LIKE` operator
-tries to match the whole string:
+But this query will fail due to the <<esql-stats-by, STATS>> command:
+
 [source,esql]
 ----
-| WHERE field LIKE "Elasticsearch"
+FROM books
+| STATS AVG(price) BY author
+| WHERE MATCH(author, "Faulkner")
 ----
 
-As a workaround, use wildcards and regular expressions. For example:
+And this query that uses a disjunction will succeed:
+
 [source,esql]
 ----
-| WHERE field RLIKE "[Ee]lasticsearch.*"
+FROM books
+| WHERE MATCH(author, "Faulkner") OR QSTR("author: Hemingway")
 ----
 
+However using scoring will fail because it uses a non full text function as part of the disjunction:
+
+[source,esql]
+----
+FROM books METADATA _score
+| WHERE MATCH(author, "Faulkner") OR author LIKE "Hemingway"
+----
+
+Scoring will work in the following query, as it uses full text functions on both `OR` clauses:
+
+[source,esql]
+----
+FROM books METADATA _score
+| WHERE MATCH(author, "Faulkner") OR QSTR("author: Hemingway")
+----
+
+
+Note that, because of <<esql-limitations-text-fields,the way {esql} treats `text` values>>,
+any queries on `text` fields that do not explicitly use the full-text functions,
+<<esql-match>>, <<esql-qstr>> or <<esql-kql>>, will behave as if the fields are actually `keyword` fields:
+they are case-sensitive and need to match the full string.
+
 [discrete]
 [[esql-limitations-text-fields]]
 === `text` fields behave like `keyword` fields
@@ -151,15 +175,68 @@ that. If it's not possible to retrieve a `keyword` subfield, {esql} will get the
 string from a document's `_source`. If the `_source` cannot be retrieved, for
 example when using synthetic source, `null` is returned.
 
+Once a `text` field is retrieved, if the query touches it in any way, for example passing
+it into a function, the type will be converted to `keyword`. In fact, functions that operate on both
+`text` and `keyword` fields will perform as if the `text` field was a `keyword` field all along.
+
+For example, the following query will return a column `greatest` of type `keyword` no matter
+whether any or all of `field1`, `field2`, and `field3` are of type `text`:
+[source,esql]
+----
+| FROM index
+| EVAL greatest = GREATEST(field1, field2, field3)
+----
+
 Note that {esql}'s retrieval of `keyword` subfields may have unexpected
-consequences. An {esql} query on a `text` field is case-sensitive. Furthermore,
-a subfield may have been mapped with a <<normalizer,normalizer>>, which can
+consequences. Other than when explicitly using the full-text functions, <<esql-match>> and <<esql-qstr>>,
+any {esql} query on a `text` field is case-sensitive.
+
+For example, after indexing a field of type `text` with the value `Elasticsearch
+query language`, the following `WHERE` clause does not match because the `LIKE`
+operator is case-sensitive:
+[source,esql]
+----
+| WHERE field LIKE "elasticsearch query language"
+----
+
+The following `WHERE` clause does not match either, because the `LIKE` operator
+tries to match the whole string:
+[source,esql]
+----
+| WHERE field LIKE "Elasticsearch"
+----
+
+As a workaround, use wildcards and regular expressions. For example:
+[source,esql]
+----
+| WHERE field RLIKE "[Ee]lasticsearch.*"
+----
+
+Furthermore, a subfield may have been mapped with a <<normalizer,normalizer>>, which can
 transform the original string. Or it may have been mapped with <<ignore-above>>,
 which can truncate the string. None of these mapping operations are applied to
 an {esql} query, which may lead to false positives or negatives.
 
 To avoid these issues, a best practice is to be explicit about the field that
 you query, and query `keyword` sub-fields instead of `text` fields.
+Or consider using one of the <<esql-search-functions,full-text search>> functions.
+
+[discrete]
+[[esql-multi-index-limitations]]
+=== Using {esql} to query multiple indices
+
+As discussed in more detail in <<esql-multi-index>>, {esql} can execute a single query across multiple indices,
+data streams, or aliases. However, there are some limitations to be aware of:
+
+* All underlying indexes and shards must be active. Using admin commands or UI,
+  it is possible to pause an index or shard, for example by disabling a frozen tier instance,
+  but then any {esql} query that includes that index or shard will fail, even if the query uses
+  <<esql-where>> to filter out the results from the paused index.
+  If you see an error of type `search_phase_execution_exception`,
+  with the message `Search rejected due to missing shards`, you likely have an index or shard in `UNASSIGNED` state.
+* The same field must have the same type across all indexes. If the same field is mapped to different types
+  it is still possible to query the indexes,
+  but the field must be <<esql-multi-index-union-types,explicitly converted to a single type>>.
 
 [discrete]
 [[esql-tsdb]]

+ 1 - 1
x-pack/plugin/build.gradle

@@ -217,5 +217,5 @@ tasks.named("yamlRestTestV7CompatTransform").configure({ task ->
   task.skipTest("esql/190_lookup_join/alias-repeated-index", "LOOKUP JOIN does not support index aliases for now")
   task.skipTest("esql/190_lookup_join/alias-pattern-multiple", "LOOKUP JOIN does not support index aliases for now")
   task.skipTest("esql/190_lookup_join/alias-pattern-single", "LOOKUP JOIN does not support index aliases for now")
-
+  task.skipTest("esql/180_match_operator/match with disjunctions", "Disjunctions in full text functions work now")
 })

+ 19 - 4
x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluator.java

@@ -25,6 +25,7 @@ import org.elasticsearch.compute.data.DocBlock;
 import org.elasticsearch.compute.data.DocVector;
 import org.elasticsearch.compute.data.IntVector;
 import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
 import org.elasticsearch.compute.operator.EvalOperator;
 import org.elasticsearch.core.Releasable;
 import org.elasticsearch.core.Releasables;
@@ -44,19 +45,20 @@ public class LuceneQueryExpressionEvaluator implements EvalOperator.ExpressionEv
 
     private final BlockFactory blockFactory;
     private final ShardConfig[] shards;
-    private final int docChannel;
 
     private ShardState[] perShardState = EMPTY_SHARD_STATES;
 
-    public LuceneQueryExpressionEvaluator(BlockFactory blockFactory, ShardConfig[] shards, int docChannel) {
+    public LuceneQueryExpressionEvaluator(BlockFactory blockFactory, ShardConfig[] shards) {
         this.blockFactory = blockFactory;
         this.shards = shards;
-        this.docChannel = docChannel;
     }
 
     @Override
     public Block eval(Page page) {
-        DocVector docs = page.<DocBlock>getBlock(docChannel).asVector();
+        // Lucene based operators retrieve DocVectors as first block
+        Block block = page.getBlock(0);
+        assert block instanceof DocBlock : "LuceneQueryExpressionEvaluator expects DocBlock as input";
+        DocVector docs = (DocVector) block.asVector();
         try {
             if (docs.singleSegmentNonDecreasing()) {
                 return evalSingleSegmentNonDecreasing(docs).asBlock();
@@ -341,4 +343,17 @@ public class LuceneQueryExpressionEvaluator implements EvalOperator.ExpressionEv
             Releasables.closeExpectNoException(builder);
         }
     }
+
+    public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+        private final ShardConfig[] shardConfigs;
+
+        public Factory(ShardConfig[] shardConfigs) {
+            this.shardConfigs = shardConfigs;
+        }
+
+        @Override
+        public EvalOperator.ExpressionEvaluator get(DriverContext context) {
+            return new LuceneQueryExpressionEvaluator(context.blockFactory(), shardConfigs);
+        }
+    }
 }

+ 2 - 2
x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/lucene/LuceneQueryExpressionEvaluatorTests.java

@@ -183,8 +183,8 @@ public class LuceneQueryExpressionEvaluatorTests extends ComputeTestCase {
             );
             LuceneQueryExpressionEvaluator luceneQueryEvaluator = new LuceneQueryExpressionEvaluator(
                 blockFactory,
-                new LuceneQueryExpressionEvaluator.ShardConfig[] { shard },
-                0
+                new LuceneQueryExpressionEvaluator.ShardConfig[] { shard }
+
             );
 
             List<Operator> operators = new ArrayList<>();

+ 37 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec

@@ -152,3 +152,40 @@ emp_no:integer | first_name:keyword | last_name:keyword
 10053          | Sanjiv             | Zschoche
 10069          | Margareta          | Bierman
 ;
+
+testKqlWithNonPushableDisjunctions
+required_capability: kql_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where kql("title:lord") or length(title) > 130 
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675   
+2714   
+4023   
+7140   
+8678
+;
+
+testKqlWithNonPushableDisjunctionsOnComplexExpressions
+required_capability: kql_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where (kql("title:lord") and ratings > 4.5) or (kql("author:dostoevsky") and length(title) > 50)
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675
+2924
+4023
+1937
+7140
+2714
+;

+ 37 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/match-function.csv-spec

@@ -718,3 +718,40 @@ from books
 title:text
 The Hobbit or There and Back Again
 ;
+
+testMatchWithNonPushableDisjunctions
+required_capability: match_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where match(title, "lord") or length(title) > 130 
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675   
+2714   
+4023   
+7140   
+8678
+;
+
+testMatchWithNonPushableDisjunctionsOnComplexExpressions
+required_capability: match_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where (match(title, "lord") and ratings > 4.5) or (match(author, "dostoevsky") and length(title) > 50)
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675
+2924
+4023
+1937
+7140
+2714
+;

+ 37 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/match-operator.csv-spec

@@ -684,3 +684,40 @@ from semantic_text
 host:keyword | semantic_text_field:text
 "host1"      | live long and prosper
 ;
+
+testMatchWithNonPushableDisjunctions
+required_capability: match_operator_colon
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where title:"lord" or length(title) > 130 
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675   
+2714   
+4023   
+7140   
+8678
+;
+
+testMatchWithNonPushableDisjunctionsOnComplexExpressions
+required_capability: match_operator_colon
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where (title:"lord" and ratings > 4.5) or (author:"dostoevsky" and length(title) > 50)
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675
+2924
+4023
+1937
+7140
+2714
+;

+ 37 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec

@@ -152,3 +152,40 @@ emp_no:integer | first_name:keyword | last_name:keyword
 10053          | Sanjiv             | Zschoche
 10069          | Margareta          | Bierman
 ;
+
+testQstrWithNonPushableDisjunctions
+required_capability: qstr_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where qstr("title:lord") or length(title) > 130 
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675   
+2714   
+4023   
+7140   
+8678
+;
+
+testQstrWithNonPushableDisjunctionsOnComplexExpressions
+required_capability: qstr_function
+required_capability: full_text_functions_disjunctions_compute_engine
+
+from books 
+| where (qstr("title:lord") and ratings > 4.5) or (qstr("author:dostoevsky") and length(title) > 50)
+| keep book_no
+;
+ignoreOrder: true
+
+book_no:keyword
+2675
+2924
+4023
+1937
+7140
+2714
+;

+ 10 - 19
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchFunctionIT.java

@@ -14,8 +14,6 @@ import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.xpack.esql.VerificationException;
 import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase;
 import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
-import org.elasticsearch.xpack.esql.action.EsqlQueryRequest;
-import org.elasticsearch.xpack.esql.action.EsqlQueryResponse;
 import org.junit.Before;
 
 import java.util.List;
@@ -31,12 +29,6 @@ public class MatchFunctionIT extends AbstractEsqlIntegTestCase {
         createAndPopulateIndex();
     }
 
-    @Override
-    protected EsqlQueryResponse run(EsqlQueryRequest request) {
-        assumeTrue("match function capability not available", EsqlCapabilities.Cap.MATCH_FUNCTION.isEnabled());
-        return super.run(request);
-    }
-
     public void testSimpleWhereMatch() {
         var query = """
             FROM test
@@ -230,20 +222,19 @@ public class MatchFunctionIT extends AbstractEsqlIntegTestCase {
         assertThat(error.getMessage(), containsString("Unknown column [content]"));
     }
 
-    public void testWhereMatchWithFunctions() {
+    public void testWhereMatchNotPushedDown() {
         var query = """
             FROM test
-            | WHERE match(content, "fox") OR to_upper(content) == "FOX"
+            | WHERE match(content, "fox") OR length(content) < 20
+            | KEEP id
+            | SORT id
             """;
-        var error = expectThrows(ElasticsearchException.class, () -> run(query));
-        assertThat(
-            error.getMessage(),
-            containsString(
-                "Invalid condition [match(content, \"fox\") OR to_upper(content) == \"FOX\"]. "
-                    + "Full text functions can be used in an OR condition,"
-                    + " but only if just full text functions are used in the OR condition"
-            )
-        );
+
+        try (var resp = run(query)) {
+            assertColumnNames(resp.columns(), List.of("id"));
+            assertColumnTypes(resp.columns(), List.of("integer"));
+            assertValues(resp.values(), List.of(List.of(1), List.of(2), List.of(6)));
+        }
     }
 
     public void testWhereMatchWithRow() {

+ 10 - 11
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/MatchOperatorIT.java

@@ -206,20 +206,19 @@ public class MatchOperatorIT extends AbstractEsqlIntegTestCase {
         assertThat(error.getMessage(), containsString("Unknown column [content]"));
     }
 
-    public void testWhereMatchWithFunctions() {
+    public void testWhereMatchNotPushedDown() {
         var query = """
             FROM test
-            | WHERE content:"fox" OR to_upper(content) == "FOX"
+            | WHERE content:"fox" OR length(content) < 20
+            | KEEP id
+            | SORT id
             """;
-        var error = expectThrows(ElasticsearchException.class, () -> run(query));
-        assertThat(
-            error.getMessage(),
-            containsString(
-                "Invalid condition [content:\"fox\" OR to_upper(content) == \"FOX\"]. "
-                    + "Full text functions can be used in an OR condition, "
-                    + "but only if just full text functions are used in the OR condition"
-            )
-        );
+
+        try (var resp = run(query)) {
+            assertColumnNames(resp.columns(), List.of("id"));
+            assertColumnTypes(resp.columns(), List.of("integer"));
+            assertValues(resp.values(), List.of(List.of(1), List.of(2), List.of(6)));
+        }
     }
 
     public void testWhereMatchWithRow() {

+ 5 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

@@ -644,6 +644,11 @@ public class EsqlCapabilities {
          */
         LOOKUP_JOIN_NO_ALIASES(JOIN_LOOKUP_V12.isEnabled()),
 
+        /**
+         * Full text functions can be used in disjunctions as they are implemented in compute engine
+         */
+        FULL_TEXT_FUNCTIONS_DISJUNCTIONS_COMPUTE_ENGINE,
+
         /**
          * Support match options in match function
          */

+ 34 - 11
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java

@@ -32,6 +32,7 @@ import org.elasticsearch.xpack.esql.expression.predicate.logical.Not;
 import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull;
 import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNull;
 import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.InsensitiveEqualsMapper;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders.ShardContext;
 import org.elasticsearch.xpack.esql.planner.Layout;
 
 import java.util.List;
@@ -50,24 +51,46 @@ public final class EvalMapper {
 
     private EvalMapper() {}
 
-    @SuppressWarnings({ "rawtypes", "unchecked" })
     public static ExpressionEvaluator.Factory toEvaluator(FoldContext foldCtx, Expression exp, Layout layout) {
+        return toEvaluator(foldCtx, exp, layout, List.of());
+    }
+
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    /**
+     * Provides an ExpressionEvaluator factory to evaluate an expression.
+     *
+     * @param foldCtx the fold context for folding expressions
+     * @param exp the expression to generate an evaluator for
+     * @param layout the mapping from attributes to channels
+     * @param shardContexts the shard contexts, needed to generate queries for expressions that couldn't be pushed down to Lucene
+     */
+    public static ExpressionEvaluator.Factory toEvaluator(
+        FoldContext foldCtx,
+        Expression exp,
+        Layout layout,
+        List<ShardContext> shardContexts
+    ) {
         if (exp instanceof EvaluatorMapper m) {
             return m.toEvaluator(new EvaluatorMapper.ToEvaluator() {
                 @Override
                 public ExpressionEvaluator.Factory apply(Expression expression) {
-                    return toEvaluator(foldCtx, expression, layout);
+                    return toEvaluator(foldCtx, expression, layout, shardContexts);
                 }
 
                 @Override
                 public FoldContext foldCtx() {
                     return foldCtx;
                 }
+
+                @Override
+                public List<ShardContext> shardContexts() {
+                    return shardContexts;
+                }
             });
         }
         for (ExpressionMapper em : MAPPERS) {
             if (em.typeToken.isInstance(exp)) {
-                return em.map(foldCtx, exp, layout);
+                return em.map(foldCtx, exp, layout, shardContexts);
             }
         }
         throw new QlIllegalArgumentException("Unsupported expression [{}]", exp);
@@ -75,9 +98,9 @@ public final class EvalMapper {
 
     static class BooleanLogic extends ExpressionMapper<BinaryLogic> {
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, BinaryLogic bc, Layout layout) {
-            var leftEval = toEvaluator(foldCtx, bc.left(), layout);
-            var rightEval = toEvaluator(foldCtx, bc.right(), layout);
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, BinaryLogic bc, Layout layout, List<ShardContext> shardContexts) {
+            var leftEval = toEvaluator(foldCtx, bc.left(), layout, shardContexts);
+            var rightEval = toEvaluator(foldCtx, bc.right(), layout, shardContexts);
             /**
              * Evaluator for the <href a="https://en.wikipedia.org/wiki/Three-valued_logic">three-valued boolean expressions</href>.
              * We can't generate these with the {@link Evaluator} annotation because that
@@ -153,7 +176,7 @@ public final class EvalMapper {
 
     static class Nots extends ExpressionMapper<Not> {
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Not not, Layout layout) {
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Not not, Layout layout, List<ShardContext> shardContexts) {
             var expEval = toEvaluator(foldCtx, not.field(), layout);
             return dvrCtx -> new org.elasticsearch.xpack.esql.evaluator.predicate.operator.logical.NotEvaluator(
                 not.source(),
@@ -165,7 +188,7 @@ public final class EvalMapper {
 
     static class Attributes extends ExpressionMapper<Attribute> {
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Attribute attr, Layout layout) {
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Attribute attr, Layout layout, List<ShardContext> shardContexts) {
             record Attribute(int channel) implements ExpressionEvaluator {
                 @Override
                 public Block eval(Page page) {
@@ -200,7 +223,7 @@ public final class EvalMapper {
     static class Literals extends ExpressionMapper<Literal> {
 
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Literal lit, Layout layout) {
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, Literal lit, Layout layout, List<ShardContext> shardContexts) {
             record LiteralsEvaluator(DriverContext context, Literal lit) implements ExpressionEvaluator {
                 @Override
                 public Block eval(Page page) {
@@ -257,7 +280,7 @@ public final class EvalMapper {
     static class IsNulls extends ExpressionMapper<IsNull> {
 
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, IsNull isNull, Layout layout) {
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, IsNull isNull, Layout layout, List<ShardContext> shardContexts) {
             var field = toEvaluator(foldCtx, isNull.field(), layout);
             return new IsNullEvaluatorFactory(field);
         }
@@ -305,7 +328,7 @@ public final class EvalMapper {
     static class IsNotNulls extends ExpressionMapper<IsNotNull> {
 
         @Override
-        public ExpressionEvaluator.Factory map(FoldContext foldCtx, IsNotNull isNotNull, Layout layout) {
+        public ExpressionEvaluator.Factory map(FoldContext foldCtx, IsNotNull isNotNull, Layout layout, List<ShardContext> shardContexts) {
             return new IsNotNullEvaluatorFactory(toEvaluator(foldCtx, isNotNull.field(), layout));
         }
 

+ 7 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/mapper/EvaluatorMapper.java

@@ -21,8 +21,11 @@ import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.evaluator.EvalMapper;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders;
 import org.elasticsearch.xpack.esql.planner.Layout;
 
+import java.util.List;
+
 import static org.elasticsearch.compute.data.BlockUtils.fromArrayRow;
 import static org.elasticsearch.compute.data.BlockUtils.toJavaObject;
 
@@ -34,6 +37,10 @@ public interface EvaluatorMapper {
         ExpressionEvaluator.Factory apply(Expression expression);
 
         FoldContext foldCtx();
+
+        default List<EsPhysicalOperationProviders.ShardContext> shardContexts() {
+            throw new UnsupportedOperationException("Shard contexts should only be needed for evaluation operations");
+        }
     }
 
     /**

+ 4 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/mapper/ExpressionMapper.java

@@ -11,8 +11,11 @@ import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.util.ReflectionUtils;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders.ShardContext;
 import org.elasticsearch.xpack.esql.planner.Layout;
 
+import java.util.List;
+
 public abstract class ExpressionMapper<E extends Expression> {
     public final Class<E> typeToken;
 
@@ -20,5 +23,5 @@ public abstract class ExpressionMapper<E extends Expression> {
         typeToken = ReflectionUtils.detectSuperTypeForRuleLike(getClass());
     }
 
-    public abstract ExpressionEvaluator.Factory map(FoldContext foldCtx, E expression, Layout layout);
+    public abstract ExpressionEvaluator.Factory map(FoldContext foldCtx, E expression, Layout layout, List<ShardContext> shardContexts);
 }

+ 49 - 22
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java

@@ -8,12 +8,16 @@
 package org.elasticsearch.xpack.esql.expression.function.fulltext;
 
 import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.lucene.LuceneQueryExpressionEvaluator;
+import org.elasticsearch.compute.lucene.LuceneQueryExpressionEvaluator.ShardConfig;
+import org.elasticsearch.compute.operator.EvalOperator;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.xpack.esql.capabilities.PostAnalysisPlanVerificationAware;
 import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
 import org.elasticsearch.xpack.esql.common.Failures;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FoldContext;
+import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute;
 import org.elasticsearch.xpack.esql.core.expression.Nullability;
 import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
 import org.elasticsearch.xpack.esql.core.expression.function.Function;
@@ -21,6 +25,7 @@ import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.core.util.Holder;
+import org.elasticsearch.xpack.esql.evaluator.mapper.EvaluatorMapper;
 import org.elasticsearch.xpack.esql.expression.predicate.logical.BinaryLogic;
 import org.elasticsearch.xpack.esql.expression.predicate.logical.Not;
 import org.elasticsearch.xpack.esql.expression.predicate.logical.Or;
@@ -31,6 +36,7 @@ import org.elasticsearch.xpack.esql.plan.logical.Filter;
 import org.elasticsearch.xpack.esql.plan.logical.Limit;
 import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
 import org.elasticsearch.xpack.esql.plan.logical.OrderBy;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders;
 import org.elasticsearch.xpack.esql.planner.TranslatorHandler;
 import org.elasticsearch.xpack.esql.querydsl.query.TranslationAwareExpressionQuery;
 
@@ -50,7 +56,7 @@ import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isStr
  * These functions needs to be pushed down to Lucene queries to be executed - there's no Evaluator for them, but depend on
  * {@link org.elasticsearch.xpack.esql.optimizer.LocalPhysicalPlanOptimizer} to rewrite them into Lucene queries.
  */
-public abstract class FullTextFunction extends Function implements TranslationAware, PostAnalysisPlanVerificationAware {
+public abstract class FullTextFunction extends Function implements TranslationAware, PostAnalysisPlanVerificationAware, EvaluatorMapper {
 
     private final Expression query;
     private final QueryBuilder queryBuilder;
@@ -141,6 +147,7 @@ public abstract class FullTextFunction extends Function implements TranslationAw
 
     @Override
     public boolean translatable(LucenePushdownPredicates pushdownPredicates) {
+        // In isolation, full text functions are pushable to source. We check if there are no disjunctions in Or conditions
         return true;
     }
 
@@ -200,8 +207,14 @@ public abstract class FullTextFunction extends Function implements TranslationAw
                 m -> "[" + m.functionName() + "] " + m.functionType(),
                 failures
             );
-            checkFullTextSearchDisjunctions(condition, ftf -> "[" + ftf.functionName() + "] " + ftf.functionType(), failures);
             checkFullTextFunctionsParents(condition, failures);
+
+            boolean usesScore = plan.output()
+                .stream()
+                .anyMatch(attr -> attr instanceof MetadataAttribute ma && ma.name().equals(MetadataAttribute.SCORE));
+            if (usesScore) {
+                checkFullTextSearchDisjunctions(condition, failures);
+            }
         } else {
             plan.forEachExpression(FullTextFunction.class, ftf -> {
                 failures.add(fail(ftf, "[{}] {} is only supported in WHERE commands", ftf.functionName(), ftf.functionType()));
@@ -215,38 +228,41 @@ public abstract class FullTextFunction extends Function implements TranslationAw
      * If not, add a failure to the failures collection.
      *
      * @param condition        condition to check for disjunctions of full text searches
-     * @param typeNameProvider provider for the type name to add in the failure message
      * @param failures         failures collection to add to
      */
-    private static void checkFullTextSearchDisjunctions(
-        Expression condition,
-        java.util.function.Function<FullTextFunction, String> typeNameProvider,
-        Failures failures
-    ) {
+    private static void checkFullTextSearchDisjunctions(Expression condition, Failures failures) {
         Holder<Boolean> isInvalid = new Holder<>(false);
         condition.forEachDown(Or.class, or -> {
             if (isInvalid.get()) {
                 // Exit early if we already have a failures
                 return;
             }
-            boolean hasFullText = or.anyMatch(FullTextFunction.class::isInstance);
-            if (hasFullText) {
-                boolean hasOnlyFullText = onlyFullTextFunctionsInExpression(or);
-                if (hasOnlyFullText == false) {
-                    isInvalid.set(true);
-                    failures.add(
-                        fail(
-                            or,
-                            "Invalid condition [{}]. Full text functions can be used in an OR condition, "
-                                + "but only if just full text functions are used in the OR condition",
-                            or.sourceText()
-                        )
-                    );
-                }
+            if (checkDisjunctionPushable(or) == false) {
+                isInvalid.set(true);
+                failures.add(
+                    fail(
+                        or,
+                        "Invalid condition when using METADATA _score [{}]. Full text functions can be used in an OR condition, "
+                            + "but only if just full text functions are used in the OR condition",
+                        or.sourceText()
+                    )
+                );
             }
         });
     }
 
+    /**
+     * Checks if a disjunction is pushable from the point of view of FullTextFunctions. Either it has no FullTextFunctions or
+     * all it contains are FullTextFunctions.
+     *
+     * @param or disjunction to check
+     * @return true if the disjunction is pushable, false otherwise
+     */
+    private static boolean checkDisjunctionPushable(Or or) {
+        boolean hasFullText = or.anyMatch(FullTextFunction.class::isInstance);
+        return hasFullText == false || onlyFullTextFunctionsInExpression(or);
+    }
+
     /**
      * Checks whether an expression contains just full text functions or negations (NOT) and combinations (AND, OR) of full text functions
      *
@@ -342,4 +358,15 @@ public abstract class FullTextFunction extends Function implements TranslationAw
         }
         return null;
     }
+
+    @Override
+    public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
+        List<EsPhysicalOperationProviders.ShardContext> shardContexts = toEvaluator.shardContexts();
+        ShardConfig[] shardConfigs = new ShardConfig[shardContexts.size()];
+        int i = 0;
+        for (EsPhysicalOperationProviders.ShardContext shardContext : shardContexts) {
+            shardConfigs[i++] = new ShardConfig(shardContext.toQuery(queryBuilder()), shardContext.searcher());
+        }
+        return new LuceneQueryExpressionEvaluator.Factory(shardConfigs);
+    }
 }

+ 11 - 16
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InsensitiveEqualsMapper.java

@@ -18,9 +18,11 @@ import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.evaluator.mapper.ExpressionMapper;
-import org.elasticsearch.xpack.esql.expression.function.scalar.math.Cast;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders.ShardContext;
 import org.elasticsearch.xpack.esql.planner.Layout;
 
+import java.util.List;
+
 import static org.elasticsearch.xpack.esql.evaluator.EvalMapper.toEvaluator;
 
 public class InsensitiveEqualsMapper extends ExpressionMapper<InsensitiveEquals> {
@@ -29,12 +31,17 @@ public class InsensitiveEqualsMapper extends ExpressionMapper<InsensitiveEquals>
         InsensitiveEqualsEvaluator.Factory::new;
 
     @Override
-    public final ExpressionEvaluator.Factory map(FoldContext foldCtx, InsensitiveEquals bc, Layout layout) {
+    public final ExpressionEvaluator.Factory map(
+        FoldContext foldCtx,
+        InsensitiveEquals bc,
+        Layout layout,
+        List<ShardContext> shardContexts
+    ) {
         DataType leftType = bc.left().dataType();
         DataType rightType = bc.right().dataType();
 
-        var leftEval = toEvaluator(foldCtx, bc.left(), layout);
-        var rightEval = toEvaluator(foldCtx, bc.right(), layout);
+        var leftEval = toEvaluator(foldCtx, bc.left(), layout, shardContexts);
+        var rightEval = toEvaluator(foldCtx, bc.right(), layout, shardContexts);
         if (DataType.isString(leftType)) {
             if (bc.right().foldable() && DataType.isString(rightType)) {
                 BytesRef rightVal = BytesRefs.toBytesRef(bc.right().fold(FoldContext.small() /* TODO remove me */));
@@ -50,16 +57,4 @@ public class InsensitiveEqualsMapper extends ExpressionMapper<InsensitiveEquals>
         }
         throw new EsqlIllegalArgumentException("resolved type for [" + bc + "] but didn't implement mapping");
     }
-
-    public static ExpressionEvaluator.Factory castToEvaluator(
-        FoldContext foldCtx,
-        InsensitiveEquals op,
-        Layout layout,
-        DataType required,
-        TriFunction<Source, ExpressionEvaluator.Factory, ExpressionEvaluator.Factory, ExpressionEvaluator.Factory> factory
-    ) {
-        var lhs = Cast.cast(op.source(), op.left().dataType(), required, toEvaluator(foldCtx, op.left(), layout));
-        var rhs = Cast.cast(op.source(), op.right().dataType(), required, toEvaluator(foldCtx, op.right(), layout));
-        return factory.apply(op.source(), lhs, rhs);
-    }
 }

+ 7 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java

@@ -92,6 +92,7 @@ import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan;
 import org.elasticsearch.xpack.esql.plan.physical.ProjectExec;
 import org.elasticsearch.xpack.esql.plan.physical.ShowExec;
 import org.elasticsearch.xpack.esql.plan.physical.TopNExec;
+import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders.ShardContext;
 import org.elasticsearch.xpack.esql.plugin.QueryPragmas;
 import org.elasticsearch.xpack.esql.session.Configuration;
 
@@ -131,6 +132,7 @@ public class LocalExecutionPlanner {
     private final EnrichLookupService enrichLookupService;
     private final LookupFromIndexService lookupFromIndexService;
     private final PhysicalOperationProviders physicalOperationProviders;
+    private final List<ShardContext> shardContexts;
 
     public LocalExecutionPlanner(
         String sessionId,
@@ -144,8 +146,10 @@ public class LocalExecutionPlanner {
         ExchangeSinkHandler exchangeSinkHandler,
         EnrichLookupService enrichLookupService,
         LookupFromIndexService lookupFromIndexService,
-        PhysicalOperationProviders physicalOperationProviders
+        PhysicalOperationProviders physicalOperationProviders,
+        List<ShardContext> shardContexts
     ) {
+
         this.sessionId = sessionId;
         this.clusterAlias = clusterAlias;
         this.parentTask = parentTask;
@@ -158,6 +162,7 @@ public class LocalExecutionPlanner {
         this.lookupFromIndexService = lookupFromIndexService;
         this.physicalOperationProviders = physicalOperationProviders;
         this.configuration = configuration;
+        this.shardContexts = shardContexts;
     }
 
     /**
@@ -671,7 +676,7 @@ public class LocalExecutionPlanner {
         PhysicalOperation source = plan(filter.child(), context);
         // TODO: should this be extracted into a separate eval block?
         return source.with(
-            new FilterOperatorFactory(EvalMapper.toEvaluator(context.foldCtx(), filter.condition(), source.layout)),
+            new FilterOperatorFactory(EvalMapper.toEvaluator(context.foldCtx(), filter.condition(), source.layout, shardContexts)),
             source.layout
         );
     }

+ 2 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java

@@ -376,7 +376,8 @@ public class ComputeService {
                 context.exchangeSink(),
                 enrichLookupService,
                 lookupFromIndexService,
-                new EsPhysicalOperationProviders(context.foldCtx(), contexts, searchService.getIndicesService().getAnalysis())
+                new EsPhysicalOperationProviders(context.foldCtx(), contexts, searchService.getIndicesService().getAnalysis()),
+                contexts
             );
 
             LOGGER.debug("Received physical plan:\n{}", plan);

+ 2 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java

@@ -624,7 +624,8 @@ public class CsvTests extends ESTestCase {
             exchangeSink,
             Mockito.mock(EnrichLookupService.class),
             Mockito.mock(LookupFromIndexService.class),
-            physicalOperationProviders
+            physicalOperationProviders,
+            List.of()
         );
 
         List<Page> collectedPages = Collections.synchronizedList(new ArrayList<>());

+ 52 - 47
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java

@@ -1166,21 +1166,6 @@ public class VerifierTests extends ESTestCase {
         );
     }
 
-    public void testMatchFilter() throws Exception {
-        assertEquals(
-            "1:19: Invalid condition [first_name:\"Anna\" or starts_with(first_name, \"Anne\")]. "
-                + "Full text functions can be used in an OR condition, "
-                + "but only if just full text functions are used in the OR condition",
-            error("from test | where first_name:\"Anna\" or starts_with(first_name, \"Anne\")")
-        );
-
-        assertEquals(
-            "1:51: Invalid condition [first_name:\"Anna\" OR new_salary > 100]. Full text functions can be"
-                + " used in an OR condition, but only if just full text functions are used in the OR condition",
-            error("from test | eval new_salary = salary + 10 | where first_name:\"Anna\" OR new_salary > 100")
-        );
-    }
-
     public void testMatchFunctionNotAllowedAfterCommands() throws Exception {
         assertEquals(
             "1:24: [MATCH] function cannot be used after LIMIT",
@@ -1402,52 +1387,72 @@ public class VerifierTests extends ESTestCase {
     }
 
     private void checkWithDisjunctions(String functionName, String functionInvocation, String functionType) {
-        String expression = functionInvocation + " or length(first_name) > 12";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
-        expression = "(" + functionInvocation + " or first_name is not null) or (length(first_name) > 12 and match(last_name, \"Smith\"))";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
-        expression = functionInvocation + " or (last_name is not null and first_name is null)";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
-    }
-
-    private void checkdisjunctionError(String position, String expression, String functionName, String functionType) {
-        assertEquals(
-            LoggerMessageFormat.format(
-                null,
-                "{}: Invalid condition [{}]. Full text functions can be used in an OR condition, "
-                    + "but only if just full text functions are used in the OR condition",
-                position,
-                expression
-            ),
-            error("from test | where " + expression)
+        query("from test | where " + functionInvocation + " or length(first_name) > 12");
+        query(
+            "from test | where ("
+                + functionInvocation
+                + " or first_name is not null) or (length(first_name) > 12 and match(last_name, \"Smith\"))"
         );
+        query("from test | where " + functionInvocation + " or (last_name is not null and first_name is null)");
     }
 
     public void testFullTextFunctionsDisjunctions() {
-        checkWithFullTextFunctionsDisjunctions("MATCH", "match(last_name, \"Smith\")", "function");
-        checkWithFullTextFunctionsDisjunctions(":", "last_name : \"Smith\"", "operator");
-        checkWithFullTextFunctionsDisjunctions("QSTR", "qstr(\"last_name: Smith\")", "function");
-        checkWithFullTextFunctionsDisjunctions("KQL", "kql(\"last_name: Smith\")", "function");
+        checkWithFullTextFunctionsDisjunctions("match(last_name, \"Smith\")");
+        checkWithFullTextFunctionsDisjunctions("last_name : \"Smith\"");
+        checkWithFullTextFunctionsDisjunctions("qstr(\"last_name: Smith\")");
+        checkWithFullTextFunctionsDisjunctions("kql(\"last_name: Smith\")");
     }
 
-    private void checkWithFullTextFunctionsDisjunctions(String functionName, String functionInvocation, String functionType) {
-
-        String expression = functionInvocation + " or length(first_name) > 10";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
+    private void checkWithFullTextFunctionsDisjunctions(String functionInvocation) {
 
-        expression = "match(last_name, \"Anneke\") or (" + functionInvocation + " and length(first_name) > 10)";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
+        // Disjunctions with non-pushable functions - scoring
+        checkdisjunctionScoringError("1:35", functionInvocation + " or length(first_name) > 10");
+        checkdisjunctionScoringError("1:35", "match(last_name, \"Anneke\") or (" + functionInvocation + " and length(first_name) > 10)");
+        checkdisjunctionScoringError(
+            "1:35",
+            "(" + functionInvocation + " and length(first_name) > 0) or (match(last_name, \"Anneke\") and length(first_name) > 10)"
+        );
 
-        expression = "("
-            + functionInvocation
-            + " and length(first_name) > 0) or (match(last_name, \"Anneke\") and length(first_name) > 10)";
-        checkdisjunctionError("1:19", expression, functionName, functionType);
+        // Disjunctions with non-pushable functions - no scoring
+        query("from test | where " + functionInvocation + " or length(first_name) > 10");
+        query("from test | where match(last_name, \"Anneke\") or (" + functionInvocation + " and length(first_name) > 10)");
+        query(
+            "from test | where ("
+                + functionInvocation
+                + " and length(first_name) > 0) or (match(last_name, \"Anneke\") and length(first_name) > 10)"
+        );
 
+        // Disjunctions with full text functions - no scoring
         query("from test | where " + functionInvocation + " or match(first_name, \"Anna\")");
         query("from test | where " + functionInvocation + " or not match(first_name, \"Anna\")");
         query("from test | where (" + functionInvocation + " or match(first_name, \"Anna\")) and length(first_name) > 10");
         query("from test | where (" + functionInvocation + " or match(first_name, \"Anna\")) and match(last_name, \"Smith\")");
         query("from test | where " + functionInvocation + " or (match(first_name, \"Anna\") and match(last_name, \"Smith\"))");
+
+        // Disjunctions with full text functions - scoring
+        query("from test metadata _score | where " + functionInvocation + " or match(first_name, \"Anna\")");
+        query("from test metadata _score | where " + functionInvocation + " or not match(first_name, \"Anna\")");
+        query("from test metadata _score | where (" + functionInvocation + " or match(first_name, \"Anna\")) and length(first_name) > 10");
+        query(
+            "from test metadata _score | where (" + functionInvocation + " or match(first_name, \"Anna\")) and match(last_name, \"Smith\")"
+        );
+        query(
+            "from test metadata _score | where " + functionInvocation + " or (match(first_name, \"Anna\") and match(last_name, \"Smith\"))"
+        );
+
+    }
+
+    private void checkdisjunctionScoringError(String position, String expression) {
+        assertEquals(
+            LoggerMessageFormat.format(
+                null,
+                "{}: Invalid condition when using METADATA _score [{}]. Full text functions can be used in an OR condition, "
+                    + "but only if just full text functions are used in the OR condition",
+                position,
+                expression
+            ),
+            error("from test metadata _score | where " + expression)
+        );
     }
 
     public void testQueryStringFunctionWithNonBooleanFunctions() {

+ 59 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java

@@ -46,6 +46,8 @@ import org.elasticsearch.xpack.esql.core.util.Holder;
 import org.elasticsearch.xpack.esql.enrich.ResolvedEnrichPolicy;
 import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Match;
+import org.elasticsearch.xpack.esql.expression.predicate.logical.Or;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThan;
 import org.elasticsearch.xpack.esql.index.EsIndex;
 import org.elasticsearch.xpack.esql.index.IndexResolution;
 import org.elasticsearch.xpack.esql.optimizer.rules.logical.ExtractAggregateCommonFilter;
@@ -1615,6 +1617,63 @@ public class LocalPhysicalPlanOptimizerTests extends MapperServiceTestCase {
         assertThat(queryBuilder.value(), is(123456));
     }
 
+    public void testMatchFunctionWithPushableConjunction() {
+        String query = """
+            from test
+            | where match(last_name, "Smith") and length(first_name) > 10
+            """;
+        var plan = plannerOptimizer.plan(query);
+
+        var limit = as(plan, LimitExec.class);
+        var exchange = as(limit.child(), ExchangeExec.class);
+        var project = as(exchange.child(), ProjectExec.class);
+        var fieldExtract = as(project.child(), FieldExtractExec.class);
+        var filterLimit = as(fieldExtract.child(), LimitExec.class);
+        var filter = as(filterLimit.child(), FilterExec.class);
+        assertThat(filter.condition(), instanceOf(GreaterThan.class));
+        var fieldFilterExtract = as(filter.child(), FieldExtractExec.class);
+        var esQuery = as(fieldFilterExtract.child(), EsQueryExec.class);
+        assertThat(esQuery.query(), instanceOf(MatchQueryBuilder.class));
+    }
+
+    public void testMatchFunctionWithNonPushableDisjunction() {
+        String query = """
+            from test
+            | where match(last_name, "Smith") or length(first_name) > 10
+            """;
+        var plan = plannerOptimizer.plan(query);
+
+        var limit = as(plan, LimitExec.class);
+        var exchange = as(limit.child(), ExchangeExec.class);
+        var project = as(exchange.child(), ProjectExec.class);
+        var field = as(project.child(), FieldExtractExec.class);
+        var filterLimit = as(field.child(), LimitExec.class);
+        var filter = as(filterLimit.child(), FilterExec.class);
+        Or or = as(filter.condition(), Or.class);
+        assertThat(or.left(), instanceOf(Match.class));
+        assertThat(or.right(), instanceOf(GreaterThan.class));
+        var fieldExtract = as(filter.child(), FieldExtractExec.class);
+        assertThat(fieldExtract.child(), instanceOf(EsQueryExec.class));
+    }
+
+    public void testMatchFunctionWithPushableDisjunction() {
+        String query = """
+            from test
+            | where match(last_name, "Smith") or emp_no > 10""";
+        var plan = plannerOptimizer.plan(query);
+
+        var limit = as(plan, LimitExec.class);
+        var exchange = as(limit.child(), ExchangeExec.class);
+        var project = as(exchange.child(), ProjectExec.class);
+        var fieldExtract = as(project.child(), FieldExtractExec.class);
+        var esQuery = as(fieldExtract.child(), EsQueryExec.class);
+        var boolQuery = as(esQuery.query(), BoolQueryBuilder.class);
+        Source source = new Source(2, 37, "emp_no > 10");
+        BoolQueryBuilder expected = new BoolQueryBuilder().should(new MatchQueryBuilder("last_name", "Smith").lenient(true))
+            .should(wrapWithSingleQuery(query, QueryBuilders.rangeQuery("emp_no").gt(10), "emp_no", source));
+        assertThat(esQuery.query().toString(), equalTo(expected.toString()));
+    }
+
     private QueryBuilder wrapWithSingleQuery(String query, QueryBuilder inner, String fieldName, Source source) {
         return FilterTests.singleValueQuery(query, inner, fieldName, source);
     }

+ 2 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java

@@ -7472,7 +7472,8 @@ public class PhysicalPlanOptimizerTests extends ESTestCase {
             new ExchangeSinkHandler(null, 10, () -> 10),
             null,
             null,
-            new EsPhysicalOperationProviders(FoldContext.small(), List.of(), null)
+            new EsPhysicalOperationProviders(FoldContext.small(), List.of(), null),
+            List.of()
         );
 
         return planner.plan(FoldContext.small(), plan);

+ 9 - 3
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlannerTests.java

@@ -169,6 +169,7 @@ public class LocalExecutionPlannerTests extends MapperServiceTestCase {
     }
 
     private LocalExecutionPlanner planner() throws IOException {
+        List<EsPhysicalOperationProviders.ShardContext> shardContexts = createShardContexts();
         return new LocalExecutionPlanner(
             "test",
             "",
@@ -181,7 +182,8 @@ public class LocalExecutionPlannerTests extends MapperServiceTestCase {
             null,
             null,
             null,
-            esPhysicalOperationProviders()
+            esPhysicalOperationProviders(shardContexts),
+            shardContexts
         );
     }
 
@@ -201,7 +203,11 @@ public class LocalExecutionPlannerTests extends MapperServiceTestCase {
         );
     }
 
-    private EsPhysicalOperationProviders esPhysicalOperationProviders() throws IOException {
+    private EsPhysicalOperationProviders esPhysicalOperationProviders(List<EsPhysicalOperationProviders.ShardContext> shardContexts) {
+        return new EsPhysicalOperationProviders(FoldContext.small(), shardContexts, null);
+    }
+
+    private List<EsPhysicalOperationProviders.ShardContext> createShardContexts() throws IOException {
         int numShards = randomIntBetween(1, 1000);
         List<EsPhysicalOperationProviders.ShardContext> shardContexts = new ArrayList<>(numShards);
         var searcher = new ContextIndexSearcher(
@@ -221,7 +227,7 @@ public class LocalExecutionPlannerTests extends MapperServiceTestCase {
             );
         }
         releasables.add(searcher);
-        return new EsPhysicalOperationProviders(FoldContext.small(), shardContexts, null);
+        return shardContexts;
     }
 
     private IndexReader reader() {

+ 9 - 18
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/180_match_operator.yml

@@ -171,30 +171,21 @@ setup:
 
 ---
 "match with disjunctions":
+  - requires:
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ method, path, parameters, capabilities ]
+          capabilities: [ full_text_functions_disjunctions_compute_engine ]
+      reason: "Full text functions disjunctions support"
   - do:
-      catch: bad_request
-      allowed_warnings_regex:
-        - "No limit defined, adding default limit of \\[.*\\]"
-      esql.query:
-        body:
-          query: 'FROM test | WHERE content:"fox" OR to_upper(content) == "FOX"'
-
-  - match: { status: 400 }
-  - match: { error.type: verification_exception }
-  - match: { error.reason: "/.+Invalid\\ condition\\ \\[content\\:\"fox\"\\ OR\\ to_upper\\(content\\)\\ ==\\ \"FOX\"\\]\\./" }
-
-  - do:
-      catch: bad_request
       allowed_warnings_regex:
         - "No limit defined, adding default limit of \\[.*\\]"
       esql.query:
         body:
-          query: 'FROM test | WHERE content:"fox" OR to_upper(content) == "FOX"'
-
-  - match: { status: 400 }
-  - match: { error.type: verification_exception }
-  - match: { error.reason: "/.+Invalid\\ condition\\ \\[content\\:\"fox\"\\ OR\\ to_upper\\(content\\)\\ ==\\ \"FOX\"\\]\\./" }
+          query: 'FROM test | WHERE content:"fox" OR length(content) < 20'
 
+  - length: { values: 3 }
 
 ---
 "match within eval":