Browse Source

Rename rule query and add support for multiple rulesets (#108831)

Kathleen DeRusso 1 year ago
parent
commit
74d7010a8f

+ 5 - 0
docs/changelog/108831.yaml

@@ -0,0 +1,5 @@
+pr: 108831
+summary: Rename rule query and add support for multiple rulesets
+area: Application
+type: enhancement
+issues: [ ]

+ 13 - 4
docs/reference/query-dsl/rule-query.asciidoc

@@ -1,12 +1,19 @@
 [role="xpack"]
 [[query-dsl-rule-query]]
 === Rule query
+
 ++++
 <titleabbrev>Rule</titleabbrev>
 ++++
 
 preview::[]
 
+[WARNING]
+====
+`rule_query` was renamed to `rule` in 8.15.0.
+The old syntax using `rule_query` and `ruleset_id` is deprecated and will be removed in a future release, so it is strongly advised to migrate existing rule queries to the new API structure.
+====
+
 Applies <<query-rules-apis,query rules>> to the query before returning results.
 This feature is used to promote documents in the manner of a <<query-dsl-pinned-query>> based on matching defined rules.
 If no matching query rules are defined, the "organic" matches for the query are returned.
@@ -60,11 +67,11 @@ DELETE _query_rules/my-ruleset
 GET /_search
 {
   "query": {
-    "rule_query": {
+    "rule": {
       "match_criteria": {
         "user_query": "pugs"
       },
-      "ruleset_id": "my-ruleset",
+      "ruleset_ids": ["my-ruleset"],
       "organic": {
         "match": {
           "description": "puggles"
@@ -78,8 +85,10 @@ GET /_search
 [[rule-query-top-level-parameters]]
 ==== Top-level parameters for `rule_query`
 
-`ruleset_id`::
-(Required, string) A unique <<query-rules-apis, query ruleset>> ID with query-based rules to match and apply as applicable.
+`ruleset_ids`::
+(Required, array) An array of one or more unique <<query-rules-apis, query ruleset>> ID with query-based rules to match and apply as applicable.
+Rulesets and their associated rules are evaluated in the order in which they are specified in the query and ruleset.
+The maximum number of rulesets to specify is 10.
 `match_criteria`::
 (Required, object) Defines the match criteria to apply to rules in the given query ruleset.
 Match criteria should match the keys defined in the `criteria.metadata` field of the rule.

+ 19 - 16
docs/reference/search/search-your-data/search-using-query-rules.asciidoc

@@ -1,18 +1,18 @@
 [[search-using-query-rules]]
 === Searching with query rules
+
 ++++
 <titleabbrev>Searching with query rules</titleabbrev>
 ++++
 
 [[query-rules]]
-
 preview::[]
 
 _Query rules_ allow customization of search results for queries that match specified criteria metadata.
 This allows for more control over results, for example ensuring that promoted documents that match defined criteria are returned at the top of the result list.
 Metadata is defined in the query rule, and is matched against the query criteria.
 Query rules use metadata to match a query.
-Metadata is provided as part of the `rule_query` as an object and can be anything that helps differentiate the query, for example:
+Metadata is provided as part of the <<query-dsl-rule-query, rule query>> as an object and can be anything that helps differentiate the query, for example:
 
 * A user-entered query string
 * Personalized metadata about users (e.g. country, language, etc)
@@ -20,9 +20,9 @@ Metadata is provided as part of the `rule_query` as an object and can be anythin
 * A referring site
 * etc.
 
-Query rules define a metadata key that will be used to match the metadata provided in the `rule_query` with the criteria specified in the rule.
+Query rules define a metadata key that will be used to match the metadata provided in the <<query-dsl-rule-query, rule query>> with the criteria specified in the rule.
 
-When a query rule matches the `rule_query` metadata according to its defined criteria, the query rule action is applied to the underlying `organic_query`.
+When a query rule matches the <<query-dsl-rule-query, rule query>> metadata according to its defined criteria, the query rule action is applied to the underlying `organic` query.
 
 For example, a query rule could be defined to match a user-entered query string of `pugs` and a country `us` and promote adoptable shelter dogs if the rule query met both criteria.
 
@@ -38,19 +38,20 @@ When defining a rule, consider the following:
 [[query-rule-type]]
 ===== Rule type
 
-The type of rule we want to apply. For the moment there is a single rule type:
+The type of rule we want to apply.
+For the moment there is a single rule type:
 
 * `pinned` will re-write the query into a <<query-dsl-pinned-query, pinned query>>, pinning specified results matching the query rule at the top of the returned result set.
 
-
 [discrete]
 [[query-rule-criteria]]
 ===== Rule criteria
 
-The criteria for which this rule will match. Criteria is defined as `type`, `metadata`, and `values`.
+The criteria for which this rule will match.
+Criteria is defined as `type`, `metadata`, and `values`.
 Allowed criteria types are:
 
-[cols="2*", options="header"]
+[cols="2*",options="header"]
 |===
 |Type
 |Match Requirements
@@ -103,7 +104,8 @@ See <<query-dsl-pinned-query,pinned query>> for details.
 [[add-query-rules]]
 ==== Add query rules
 
-You can add query rules using the <<put-query-ruleset>> call. This adds a ruleset containing one or more query rules that will be applied to queries that match their specified criteria.
+You can add query rules using the <<put-query-ruleset>> call.
+This adds a ruleset containing one or more query rules that will be applied to queries that match their specified criteria.
 
 The following command will create a query ruleset called `my-ruleset` with two pinned document rules:
 
@@ -185,15 +187,15 @@ This can be increased up to 1000 using the `xpack.applications.rules.max_rules_p
 ----
 // TEST[continued]
 
-You can use the <<get-query-ruleset>> call to retrieve the ruleset you just created,
-the <<list-query-rulesets>> call to retrieve a summary of all query rulesets,
-and the <<delete-query-ruleset>> call to delete a query ruleset.
+You can use the <<get-query-ruleset>> call to retrieve the ruleset you just created, the <<list-query-rulesets>> call to retrieve a summary of all query rulesets, and the <<delete-query-ruleset>> call to delete a query ruleset.
 
 [discrete]
 [[rule-query-search]]
 ==== Perform a rule query
 
-Once you have defined a query ruleset, you can search this ruleset using the <<query-dsl-rule-query>> query.
+Once you have defined one or more query rulesets, you can search these rulesets using the <<query-dsl-rule-query>> query.
+Rulesets are evaluated in order, so rules in the first ruleset you specify will be applied before any subsequent rulesets.
+
 An example query for the `my-ruleset` defined above is:
 
 [source,console]
@@ -201,7 +203,7 @@ An example query for the `my-ruleset` defined above is:
 GET /my-index-000001/_search
 {
   "query": {
-    "rule_query": {
+    "rule": {
       "organic": {
         "query_string": {
           "query": "puggles"
@@ -211,7 +213,7 @@ GET /my-index-000001/_search
         "query_string": "puggles",
         "user_country": "us"
       },
-      "ruleset_id": "my-ruleset"
+      "ruleset_ids": ["my-ruleset"]
     }
   }
 }
@@ -221,7 +223,8 @@ GET /my-index-000001/_search
 This rule query will match against `rule1` in the defined query ruleset, and will convert the organic query into a pinned query with `id1` and `id2` pinned as the top hits.
 Any other matches from the organic query will be returned below the pinned results.
 
-It's possible to have multiple rules in a ruleset match a single `rule_query`. In this case, the pinned documents are returned in the following order:
+It's possible to have multiple rules in a ruleset match a single <<query-dsl-rule-query, rule query>>.
+In this case, the pinned documents are returned in the following order:
 
 - Where the matching rule appears in the ruleset
 - If multiple documents are specified in a single rule, in the order they are specified

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -172,6 +172,7 @@ public class TransportVersions {
     public static final TransportVersion SEMANTIC_QUERY = def(8_663_00_0);
     public static final TransportVersion GET_AUTOSCALING_CAPACITY_UNUSED_TIMEOUT = def(8_664_00_0);
     public static final TransportVersion SIMULATE_VALIDATES_MAPPINGS = def(8_665_00_0);
+    public static final TransportVersion RULE_QUERY_RENAME = def(8_666_00_0);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 251 - 41
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/40_rule_query_search.yml

@@ -1,7 +1,7 @@
 setup:
   - requires:
-      cluster_features: ["gte_v8.10.0"]
-      reason: Introduced in 8.10.0
+      cluster_features: [ "gte_v8.15.0" ]
+      reason: Introduced in 8.15.0
 
   - do:
       indices.create:
@@ -32,6 +32,12 @@ setup:
           - index:
               _id: doc5
           - { "text": "beats" }
+          - index:
+              _id: doc6
+          - { "text": "siem" }
+          - index:
+              _id: doc7
+          - { "text": "observability" }
 
   - do:
       query_ruleset.put:
@@ -43,7 +49,7 @@ setup:
               criteria:
                 - type: exact
                   metadata: query_string
-                  values: [search]
+                  values: [ search ]
               actions:
                 ids:
                   - 'doc1'
@@ -52,7 +58,7 @@ setup:
               criteria:
                 - type: exact
                   metadata: query_string
-                  values: [ui]
+                  values: [ ui ]
               actions:
                 docs:
                   - '_index': 'test-index1'
@@ -67,45 +73,94 @@ setup:
                 ids:
                   - 'doc2'
                   - 'doc3'
+            - rule_id: rule4
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ ops ]
+              actions:
+                ids:
+                  - 'doc7'
 
+  - do:
+      query_ruleset.put:
+        ruleset_id: another-test-ruleset
+        body:
+          rules:
+            - rule_id: rule5
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ ops ]
+              actions:
+                ids:
+                  - 'doc6'
 
 ---
 "Perform a rule query specifying a ruleset that does not exist":
-  - requires:
-      cluster_features: ["gte_v8.13.0"]
-      reason: Bugfix that was broken in previous versions
-
   - do:
       catch: /resource_not_found_exception/
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: search
               match_criteria:
                 foo: bar
-              ruleset_id: nonexistent-ruleset
+              ruleset_ids:
+                nonexistent-ruleset
 
 ---
-"Perform a rule query with malformed rule":
-  - requires:
-      cluster_features: ["gte_v8.13.0"]
-      reason: Bugfix that was broken in previous versions
+"Perform a rule query without specifying a ruleset":
+  - do:
+      catch: /ruleset information not provided correctly/
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: search
+              match_criteria:
+                foo: bar
 
+---
+"Perform a rule query that specifies both a ruleset_id and ruleset_ids":
   - do:
-      catch: bad_request
+      catch: /ruleset information not provided correctly/
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: search
+              ruleset_ids: [ test-ruleset ]
               ruleset_id: test-ruleset
+              match_criteria:
+                foo: bar
+
+---
+"Perform a rule query with malformed rule":
+  - do:
+      catch: bad_request
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: search
+              ruleset_ids:
+                test-ruleset
 
 ---
 "Perform a rule query with an ID match":
@@ -114,14 +169,15 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: search
               match_criteria:
                 query_string: search
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                test-ruleset
 
   - match: { hits.total.value: 2 }
   - match: { hits.hits.0._id: 'doc1' }
@@ -137,14 +193,15 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: search
               match_criteria:
                 query_string: search
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                test-ruleset
 
   - match: { hits.total.value: 2 }
   - match: { hits.hits.0._id: 'doc1' }
@@ -160,14 +217,15 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: ui
               match_criteria:
                 query_string: ui
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                - test-ruleset
 
   - match: { hits.total.value: 1 }
   - match: { hits.hits.0._id: 'doc2' }
@@ -179,14 +237,15 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: beats
               match_criteria:
                 query_string: beats
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                - test-ruleset
 
   - match: { hits.total.value: 1 }
   - match: { hits.hits.0._id: 'doc5' }
@@ -198,18 +257,19 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: logstash
               match_criteria:
                 query_string: logstash
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                - test-ruleset
 
   - match: { hits.total.value: 2 }
   - match: { hits.hits.0._id: 'doc2' }
-  - match: { hits.hits.1._id: 'doc3'}
+  - match: { hits.hits.1._id: 'doc3' }
 
 
 ---
@@ -219,14 +279,15 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: elastic and kibana are good for search
               match_criteria:
                 query_string: elastic and kibana are good for search
-              ruleset_id: test-ruleset
+              ruleset_ids:
+                - test-ruleset
 
   - match: { hits.total.value: 4 }
   - match: { hits.hits.0._id: 'doc2' }
@@ -262,24 +323,21 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 query_string:
                   default_field: text
                   query: blah blah blah
               match_criteria:
                 foo: baz
-              ruleset_id: combined-ruleset
+              ruleset_ids:
+                - combined-ruleset
 
   - match: { hits.total.value: 1 }
   - match: { hits.hits.0._id: 'doc1' }
 
 ---
 "Perform a rule query with an organic query that must be rewritten to another query type":
-  - requires:
-      cluster_features: ["gte_v8.12.2"]
-      reason: Bugfix that was broken in previous versions
-
   - do:
       indices.create:
         index: test-index-with-sparse-vector
@@ -378,7 +436,7 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 text_expansion:
                   ml.tokens:
@@ -386,7 +444,8 @@ setup:
                     model_text: "octopus comforter smells"
               match_criteria:
                 foo: bar
-              ruleset_id: combined-ruleset
+              ruleset_ids:
+                - combined-ruleset
 
   - match: { hits.total.value: 5 }
   - match: { hits.hits.0._id: 'pinned_doc1' }
@@ -395,7 +454,7 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 text_expansion:
                   ml.tokens:
@@ -403,7 +462,8 @@ setup:
                     model_text: "octopus comforter smells"
               match_criteria:
                 foo: baz
-              ruleset_id: combined-ruleset
+              ruleset_ids:
+                - combined-ruleset
 
   - match: { hits.total.value: 5 }
   - match: { hits.hits.0._id: 'pinned_doc2' }
@@ -412,7 +472,7 @@ setup:
       search:
         body:
           query:
-            rule_query:
+            rule:
               organic:
                 text_expansion:
                   ml.tokens:
@@ -420,8 +480,158 @@ setup:
                     model_text: "octopus comforter smells"
               match_criteria:
                 foo: puggle
-              ruleset_id: combined-ruleset
+              ruleset_ids:
+                - combined-ruleset
 
   - match: { hits.total.value: 4 }
 
+---
+"Verify rule query still works with legacy ruleset_id":
+  - requires:
+      test_runner_features: [ "allowed_warnings" ]
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: search
+              match_criteria:
+                query_string: search
+              ruleset_id: test-ruleset
+      allowed_warnings:
+        - "Using deprecated field [ruleset_id] in query rules, please use [ruleset_ids] instead"
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._id: 'doc1' }
+  - match: { hits.hits.1._id: 'doc4' }
+
+---
+"Perform a rule query with multiple rulesets that are applied in order of ruleset then rule":
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: ops
+              match_criteria:
+                query_string: ops
+              ruleset_ids:
+                - test-ruleset
+
+  - match: { hits.total.value: 1 }
+  - match: { hits.hits.0._id: 'doc7' }
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: ops
+              match_criteria:
+                query_string: ops
+              ruleset_ids:
+                - another-test-ruleset
+
+  - match: { hits.total.value: 1 }
+  - match: { hits.hits.0._id: 'doc6' }
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: ops
+              match_criteria:
+                query_string: ops
+              ruleset_ids:
+                - test-ruleset
+                - another-test-ruleset
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._id: 'doc7' }
+  - match: { hits.hits.1._id: 'doc6' }
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: ops
+              match_criteria:
+                query_string: ops
+              ruleset_ids:
+                - another-test-ruleset
+                - test-ruleset
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._id: 'doc6' }
+  - match: { hits.hits.1._id: 'doc7' }
+
+---
+"Perform a rule query specifying too many rulesets":
+  - do:
+      catch: /rulesetIds must not contain more than 10 rulesets/
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  default_field: text
+                  query: search
+              match_criteria:
+                query_string: elastic
+              ruleset_ids:
+                - test-ruleset1
+                - test-ruleset2
+                - test-ruleset3
+                - test-ruleset4
+                - test-ruleset5
+                - test-ruleset6
+                - test-ruleset7
+                - test-ruleset8
+                - test-ruleset9
+                - test-ruleset10
+                - test-ruleset11
+
+---
+"Perform a rule query with full legacy syntax":
+  - requires:
+      test_runner_features: [ "allowed_warnings" ]
+
+  - do:
+      search:
+        body:
+          query:
+            rule_query:
+              organic:
+                query_string:
+                  default_field: text
+                  query: search
+              match_criteria:
+                query_string: search
+              ruleset_id: test-ruleset
+      allowed_warnings:
+        - "Deprecated field [rule_query] used, expected [rule] instead"
+        - "Using deprecated field [ruleset_id] in query rules, please use [ruleset_ids] instead"
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._id: 'doc1' }
+  - match: { hits.hits.1._id: 'doc4' }
 

+ 115 - 63
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/RuleQueryBuilder.java

@@ -12,10 +12,11 @@ import org.elasticsearch.ResourceNotFoundException;
 import org.elasticsearch.TransportVersion;
 import org.elasticsearch.TransportVersions;
 import org.elasticsearch.action.ActionListener;
-import org.elasticsearch.action.get.GetRequest;
-import org.elasticsearch.action.get.TransportGetAction;
+import org.elasticsearch.action.get.GetResponse;
+import org.elasticsearch.action.get.MultiGetItemResponse;
+import org.elasticsearch.action.get.MultiGetRequest;
+import org.elasticsearch.action.get.TransportMultiGetAction;
 import org.elasticsearch.common.ParsingException;
-import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.logging.HeaderWarning;
@@ -40,6 +41,7 @@ import java.util.Objects;
 import java.util.function.Supplier;
 
 import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
 import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN;
 import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin;
 import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.MAX_NUM_PINNED_HITS;
@@ -53,19 +55,20 @@ import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.MAX
  */
 public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
-    public static final String NAME = "rule_query";
+    public static final ParseField NAME = new ParseField("rule", "rule_query");
 
     private static final ParseField RULESET_ID_FIELD = new ParseField("ruleset_id");
+    private static final ParseField RULESET_IDS_FIELD = new ParseField("ruleset_ids");
     static final ParseField MATCH_CRITERIA_FIELD = new ParseField("match_criteria");
     private static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic");
 
-    private final String rulesetId;
+    public static final int MAX_NUM_RULESETS = 10;
+
+    private final List<String> rulesetIds;
     private final Map<String, Object> matchCriteria;
     private final QueryBuilder organicQuery;
 
-    private final List<String> pinnedIds;
     private final Supplier<List<String>> pinnedIdsSupplier;
-    private final List<Item> pinnedDocs;
     private final Supplier<List<Item>> pinnedDocsSupplier;
 
     @Override
@@ -73,27 +76,29 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         return TransportVersions.V_8_10_X;
     }
 
-    public RuleQueryBuilder(QueryBuilder organicQuery, Map<String, Object> matchCriteria, String rulesetId) {
-        this(organicQuery, matchCriteria, rulesetId, null, null, null, null);
+    public RuleQueryBuilder(QueryBuilder organicQuery, Map<String, Object> matchCriteria, List<String> rulesetIds) {
+        this(organicQuery, matchCriteria, rulesetIds, null, null);
     }
 
     public RuleQueryBuilder(StreamInput in) throws IOException {
         super(in);
         organicQuery = in.readNamedWriteable(QueryBuilder.class);
         matchCriteria = in.readGenericMap();
-        rulesetId = in.readString();
-        pinnedIds = in.readOptionalStringCollectionAsList();
+        if (in.getTransportVersion().onOrAfter(TransportVersions.RULE_QUERY_RENAME)) {
+            rulesetIds = in.readStringCollectionAsList();
+        } else {
+            rulesetIds = List.of(in.readString());
+            in.readOptionalStringCollectionAsList();
+            in.readOptionalCollectionAsList(Item::new);
+        }
         pinnedIdsSupplier = null;
-        pinnedDocs = in.readOptionalCollectionAsList(Item::new);
         pinnedDocsSupplier = null;
     }
 
     private RuleQueryBuilder(
         QueryBuilder organicQuery,
         Map<String, Object> matchCriteria,
-        String rulesetId,
-        List<String> pinnedIds,
-        List<Item> pinnedDocs,
+        List<String> rulesetIds,
         Supplier<List<String>> pinnedIdsSupplier,
         Supplier<List<Item>> pinnedDocsSupplier
 
@@ -104,16 +109,22 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         if (matchCriteria == null || matchCriteria.isEmpty()) {
             throw new IllegalArgumentException("matchCriteria must not be null or empty");
         }
-        if (Strings.isNullOrEmpty(rulesetId)) {
-            throw new IllegalArgumentException("rulesetId must not be null or empty");
+        if (rulesetIds == null || rulesetIds.isEmpty()) {
+            throw new IllegalArgumentException("rulesetIds must not be null or empty");
+        }
+
+        if (rulesetIds.size() > MAX_NUM_RULESETS) {
+            throw new IllegalArgumentException("rulesetIds must not contain more than " + MAX_NUM_RULESETS + " rulesets");
+        }
+
+        if (rulesetIds.stream().anyMatch(ruleset -> ruleset == null || ruleset.isEmpty())) {
+            throw new IllegalArgumentException("rulesetIds must not contain null or empty values");
         }
 
         this.organicQuery = organicQuery;
         this.matchCriteria = matchCriteria;
-        this.rulesetId = rulesetId;
-        this.pinnedIds = pinnedIds;
+        this.rulesetIds = rulesetIds;
         this.pinnedIdsSupplier = pinnedIdsSupplier;
-        this.pinnedDocs = pinnedDocs;
         this.pinnedDocsSupplier = pinnedDocsSupplier;
     }
 
@@ -128,13 +139,18 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
         out.writeNamedWriteable(organicQuery);
         out.writeGenericMap(matchCriteria);
-        out.writeString(rulesetId);
-        out.writeOptionalStringCollection(pinnedIds);
-        out.writeOptionalCollection(pinnedDocs);
+
+        if (out.getTransportVersion().onOrAfter(TransportVersions.RULE_QUERY_RENAME)) {
+            out.writeStringCollection(rulesetIds);
+        } else {
+            out.writeString(rulesetIds.get(0));
+            out.writeOptionalStringCollection(null);
+            out.writeOptionalCollection(null);
+        }
     }
 
-    public String rulesetId() {
-        return rulesetId;
+    public List<String> rulesetIds() {
+        return rulesetIds;
     }
 
     public Map<String, Object> matchCriteria() {
@@ -147,12 +163,12 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
     @Override
     protected void doXContent(XContentBuilder builder, Params params) throws IOException {
-        builder.startObject(NAME);
+        builder.startObject(NAME.getPreferredName());
         builder.field(ORGANIC_QUERY_FIELD.getPreferredName(), organicQuery);
         builder.startObject(MATCH_CRITERIA_FIELD.getPreferredName());
         builder.mapContents(matchCriteria);
         builder.endObject();
-        builder.field(RULESET_ID_FIELD.getPreferredName(), rulesetId);
+        builder.array(RULESET_IDS_FIELD.getPreferredName(), rulesetIds.toArray());
         boostAndQueryNameToXContent(builder);
         builder.endObject();
     }
@@ -162,10 +178,11 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         // NOTE: this is old query logic, as in 8.12.2+ and 8.13.0+ we will always rewrite this query
         // into a pinned query or the organic query. This logic remains here for backwards compatibility
         // with coordinator nodes running versions 8.10.0 - 8.12.1.
+        List<String> pinnedIds = pinnedIdsSupplier != null ? pinnedIdsSupplier.get() : null;
+        List<Item> pinnedDocs = pinnedDocsSupplier != null ? pinnedDocsSupplier.get() : null;
         if ((pinnedIds != null && pinnedIds.isEmpty() == false) && (pinnedDocs != null && pinnedDocs.isEmpty() == false)) {
             throw new IllegalArgumentException("applied rules contain both pinned ids and pinned docs, only one of ids or docs is allowed");
         }
-
         if (pinnedIds != null && pinnedIds.isEmpty() == false) {
             PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(organicQuery, pinnedIds.toArray(new String[0]));
             return pinnedQueryBuilder.toQuery(context);
@@ -197,34 +214,58 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
             }
         }
 
-        // Identify matching rules and apply them as applicable
-        GetRequest getRequest = new GetRequest(QueryRulesIndexService.QUERY_RULES_ALIAS_NAME, rulesetId);
         SetOnce<List<String>> pinnedIdsSetOnce = new SetOnce<>();
         SetOnce<List<Item>> pinnedDocsSetOnce = new SetOnce<>();
         AppliedQueryRules appliedRules = new AppliedQueryRules();
 
+        // Identify matching rules and apply them as applicable
+        MultiGetRequest multiGetRequest = new MultiGetRequest();
+        for (String rulesetId : rulesetIds) {
+            multiGetRequest.add(QueryRulesIndexService.QUERY_RULES_ALIAS_NAME, rulesetId);
+        }
         queryRewriteContext.registerAsyncAction((client, listener) -> {
-            executeAsyncWithOrigin(client, ENT_SEARCH_ORIGIN, TransportGetAction.TYPE, getRequest, ActionListener.wrap(getResponse -> {
-
-                if (getResponse.isExists() == false) {
-                    listener.onFailure(new ResourceNotFoundException("query ruleset " + rulesetId + " not found"));
-                    return;
-                }
-
-                QueryRuleset queryRuleset = QueryRuleset.fromXContentBytes(rulesetId, getResponse.getSourceAsBytesRef(), XContentType.JSON);
-                for (QueryRule rule : queryRuleset.rules()) {
-                    rule.applyRule(appliedRules, matchCriteria);
-                }
-                pinnedIdsSetOnce.set(appliedRules.pinnedIds().stream().distinct().toList());
-                pinnedDocsSetOnce.set(appliedRules.pinnedDocs().stream().distinct().toList());
-                listener.onResponse(null);
-
-            }, listener::onFailure));
+            executeAsyncWithOrigin(
+                client,
+                ENT_SEARCH_ORIGIN,
+                TransportMultiGetAction.TYPE,
+                multiGetRequest,
+                ActionListener.wrap(multiGetResponse -> {
+
+                    if (multiGetResponse.getResponses() == null || multiGetResponse.getResponses().length == 0) {
+                        listener.onFailure(new ResourceNotFoundException("query rulesets " + String.join(",", rulesetIds) + " not found"));
+                        return;
+                    }
+
+                    for (MultiGetItemResponse item : multiGetResponse) {
+                        String rulesetId = item.getId();
+                        GetResponse getResponse = item.getResponse();
+
+                        if (getResponse.isExists() == false) {
+                            listener.onFailure(new ResourceNotFoundException("query ruleset " + rulesetId + " not found"));
+                            return;
+                        }
+
+                        QueryRuleset queryRuleset = QueryRuleset.fromXContentBytes(
+                            rulesetId,
+                            getResponse.getSourceAsBytesRef(),
+                            XContentType.JSON
+                        );
+                        for (QueryRule rule : queryRuleset.rules()) {
+                            rule.applyRule(appliedRules, matchCriteria);
+                        }
+                    }
+
+                    pinnedIdsSetOnce.set(appliedRules.pinnedIds().stream().distinct().toList());
+                    pinnedDocsSetOnce.set(appliedRules.pinnedDocs().stream().distinct().toList());
+                    listener.onResponse(null);
+
+                }, listener::onFailure)
+            );
         });
 
-        return new RuleQueryBuilder(organicQuery, matchCriteria, this.rulesetId, null, null, pinnedIdsSetOnce::get, pinnedDocsSetOnce::get)
-            .boost(this.boost)
-            .queryName(this.queryName);
+        return new RuleQueryBuilder(organicQuery, matchCriteria, this.rulesetIds, pinnedIdsSetOnce::get, pinnedDocsSetOnce::get).boost(
+            this.boost
+        ).queryName(this.queryName);
     }
 
     private List<?> truncateList(List<?> input) {
@@ -241,37 +282,48 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
     protected boolean doEquals(RuleQueryBuilder other) {
         if (this == other) return true;
         if (other == null || getClass() != other.getClass()) return false;
-        return Objects.equals(rulesetId, other.rulesetId)
+        return Objects.equals(rulesetIds, other.rulesetIds)
             && Objects.equals(matchCriteria, other.matchCriteria)
             && Objects.equals(organicQuery, other.organicQuery)
-            && Objects.equals(pinnedIds, other.pinnedIds)
-            && Objects.equals(pinnedDocs, other.pinnedDocs)
             && Objects.equals(pinnedIdsSupplier, other.pinnedIdsSupplier)
             && Objects.equals(pinnedDocsSupplier, other.pinnedDocsSupplier);
     }
 
     @Override
     protected int doHashCode() {
-        return Objects.hash(rulesetId, matchCriteria, organicQuery, pinnedIds, pinnedDocs, pinnedIdsSupplier, pinnedDocsSupplier);
+        return Objects.hash(rulesetIds, matchCriteria, organicQuery, pinnedIdsSupplier, pinnedDocsSupplier);
     }
 
-    private static final ConstructingObjectParser<RuleQueryBuilder, Void> PARSER = new ConstructingObjectParser<>(NAME, a -> {
-        QueryBuilder organicQuery = (QueryBuilder) a[0];
-        @SuppressWarnings("unchecked")
-        Map<String, Object> matchCriteria = (Map<String, Object>) a[1];
-        String rulesetId = (String) a[2];
-        return new RuleQueryBuilder(organicQuery, matchCriteria, rulesetId);
-    });
+    private static final ConstructingObjectParser<RuleQueryBuilder, Void> PARSER = new ConstructingObjectParser<>(
+        NAME.getPreferredName(),
+        a -> {
+            QueryBuilder organicQuery = (QueryBuilder) a[0];
+            @SuppressWarnings("unchecked")
+            Map<String, Object> matchCriteria = (Map<String, Object>) a[1];
+            String rulesetId = (String) a[2];
+            @SuppressWarnings("unchecked")
+            List<String> rulesetIds = (List<String>) a[3];
+            if (rulesetId == null ^ rulesetIds == null == false) {
+                throw new IllegalArgumentException("ruleset information not provided correctly");
+            }
+            if (rulesetIds == null) {
+                HeaderWarning.addWarning("Using deprecated field [ruleset_id] in query rules, please use [ruleset_ids] instead");
+                rulesetIds = List.of(rulesetId);
+            }
+            return new RuleQueryBuilder(organicQuery, matchCriteria, rulesetIds);
+        }
+    );
     static {
         PARSER.declareObject(constructorArg(), (p, c) -> parseInnerQueryBuilder(p), ORGANIC_QUERY_FIELD);
         PARSER.declareObject(constructorArg(), (p, c) -> p.map(), MATCH_CRITERIA_FIELD);
-        PARSER.declareString(constructorArg(), RULESET_ID_FIELD);
+        PARSER.declareString(optionalConstructorArg(), RULESET_ID_FIELD);
+        PARSER.declareStringArray(optionalConstructorArg(), RULESET_IDS_FIELD);
         declareStandardFields(PARSER);
     }
 
     public static RuleQueryBuilder fromXContent(XContentParser parser, XPackLicenseState licenseState) {
         if (QueryRulesConfig.QUERY_RULES_LICENSE_FEATURE.check(licenseState) == false) {
-            throw LicenseUtils.newComplianceException(NAME);
+            throw LicenseUtils.newComplianceException(NAME.getPreferredName());
         }
         try {
             return PARSER.apply(parser, null);
@@ -282,7 +334,7 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
     @Override
     public String getWriteableName() {
-        return NAME;
+        return NAME.getPreferredName();
     }
 
 }

+ 81 - 39
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/RuleQueryBuilderTests.java

@@ -11,9 +11,11 @@ import org.apache.lucene.search.DisjunctionMaxQuery;
 import org.apache.lucene.search.Query;
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.action.ActionListener;
-import org.elasticsearch.action.get.GetRequest;
 import org.elasticsearch.action.get.GetResponse;
-import org.elasticsearch.action.get.TransportGetAction;
+import org.elasticsearch.action.get.MultiGetItemResponse;
+import org.elasticsearch.action.get.MultiGetRequest;
+import org.elasticsearch.action.get.MultiGetResponse;
+import org.elasticsearch.action.get.TransportMultiGetAction;
 import org.elasticsearch.client.internal.Client;
 import org.elasticsearch.client.internal.ElasticsearchClient;
 import org.elasticsearch.common.ParsingException;
@@ -36,6 +38,7 @@ import org.hamcrest.Matchers;
 
 import java.io.IOException;
 import java.lang.reflect.Method;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -52,7 +55,11 @@ public class RuleQueryBuilderTests extends AbstractQueryTestCase<RuleQueryBuilde
 
     @Override
     protected RuleQueryBuilder doCreateTestQueryBuilder() {
-        return new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, randomAlphaOfLength(10));
+        return new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, randomList(1, 3, this::randomRulesetId));
+    }
+
+    private String randomRulesetId() {
+        return randomAlphaOfLengthBetween(1, 10);
     }
 
     @Override
@@ -67,17 +74,18 @@ public class RuleQueryBuilderTests extends AbstractQueryTestCase<RuleQueryBuilde
     }
 
     public void testIllegalArguments() {
-        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), null, "rulesetId"));
+        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), null, List.of("rulesetId")));
+        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, List.of()));
         expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, null));
-        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, ""));
-        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(null, MATCH_CRITERIA, "rulesetId"));
-        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(null, Collections.emptyMap(), "rulesetId"));
+        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(new MatchAllQueryBuilder(), MATCH_CRITERIA, List.of("")));
+        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(null, MATCH_CRITERIA, List.of("rulesetId")));
+        expectThrows(IllegalArgumentException.class, () -> new RuleQueryBuilder(null, Collections.emptyMap(), List.of("rulesetId")));
     }
 
     public void testFromJson() throws IOException {
         String query = """
             {
-              "rule_query": {
+              "rule": {
                 "organic": {
                   "term": {
                     "tag": {
@@ -88,14 +96,16 @@ public class RuleQueryBuilderTests extends AbstractQueryTestCase<RuleQueryBuilde
                 "match_criteria": {
                   "query_string": "elastic"
                 },
-                "ruleset_id": "ruleset1"
+                "ruleset_ids": [ "ruleset1", "ruleset2" ]
               }
             }""";
 
         RuleQueryBuilder queryBuilder = (RuleQueryBuilder) parseQuery(query);
         checkGeneratedJson(query, queryBuilder);
 
-        assertEquals("ruleset1", queryBuilder.rulesetId());
+        assertEquals(2, queryBuilder.rulesetIds().size());
+        assertEquals("ruleset1", queryBuilder.rulesetIds().get(0));
+        assertEquals("ruleset2", queryBuilder.rulesetIds().get(1));
         assertEquals(query, "elastic", queryBuilder.matchCriteria().get("query_string"));
         assertThat(queryBuilder.organicQuery(), instanceOf(TermQueryBuilder.class));
     }
@@ -104,14 +114,18 @@ public class RuleQueryBuilderTests extends AbstractQueryTestCase<RuleQueryBuilde
     * test that unknown query names in the clauses throw an error
     */
     public void testUnknownQueryName() {
-        String query = "{\"rule_query\" : {\"organic\" : { \"unknown_query\" : { } } } }";
+        String query = "{\"rule\" : {\"organic\" : { \"unknown_query\" : { } } } }";
 
         ParsingException ex = expectThrows(ParsingException.class, () -> parseQuery(query));
-        assertEquals("[1:50] [rule_query] failed to parse field [organic]", ex.getMessage());
+        assertEquals("[1:44] [rule] failed to parse field [organic]", ex.getMessage());
     }
 
     public void testRewrite() throws IOException {
-        RuleQueryBuilder ruleQueryBuilder = new RuleQueryBuilder(new TermQueryBuilder("foo", 1), Map.of("query_string", "bar"), "baz");
+        RuleQueryBuilder ruleQueryBuilder = new RuleQueryBuilder(
+            new TermQueryBuilder("foo", 1),
+            Map.of("query_string", "bar"),
+            List.of("baz", "qux")
+        );
         QueryBuilder rewritten = ruleQueryBuilder.rewrite(createSearchExecutionContext());
         assertThat(rewritten, instanceOf(RuleQueryBuilder.class));
     }
@@ -130,38 +144,66 @@ public class RuleQueryBuilderTests extends AbstractQueryTestCase<RuleQueryBuilde
     @Override
     protected Object simulateMethod(Method method, Object[] args) {
         // Get request, to pull the query ruleset from the system index using clientWithOrigin
+        String declaringClass = method.getDeclaringClass().getName();
+        String methodName = method.getName();
+        Object arg = args[0];
         if (method.getDeclaringClass().equals(ElasticsearchClient.class)
             && method.getName().equals("execute")
-            && args[0] == TransportGetAction.TYPE) {
-
-            GetRequest getRequest = (GetRequest) args[1];
-            assertThat(getRequest.index(), Matchers.equalTo(QueryRulesIndexService.QUERY_RULES_ALIAS_NAME));
-            String rulesetId = getRequest.id();
-
-            List<QueryRule> rules = List.of(
-                new QueryRule(
-                    "my_rule1",
-                    QueryRule.QueryRuleType.PINNED,
-                    List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("elastic"))),
-                    Map.of("ids", List.of("id1", "id2"))
-                )
-            );
-            QueryRuleset queryRuleset = new QueryRuleset(rulesetId, rules);
-
-            String json;
-            try {
-                XContentBuilder builder = queryRuleset.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS);
-                json = Strings.toString(builder);
-            } catch (IOException ex) {
-                throw new ElasticsearchException("boom", ex);
+            && args[0] == TransportMultiGetAction.TYPE) {
+
+            List<QueryRuleset> queryRulesets = new ArrayList<>();
+            MultiGetRequest multiGetRequest = (MultiGetRequest) args[1];
+            multiGetRequest.getItems().forEach(getRequest -> {
+                assertThat(getRequest.index(), Matchers.equalTo(QueryRulesIndexService.QUERY_RULES_ALIAS_NAME));
+                String rulesetId = getRequest.id();
+                List<QueryRule> rules = List.of(
+                    new QueryRule(
+                        "my_rule1",
+                        QueryRule.QueryRuleType.PINNED,
+                        List.of(new QueryRuleCriteria(EXACT, "query_string", List.of("elastic"))),
+                        Map.of("ids", List.of("id1", "id2"))
+                    )
+                );
+                QueryRuleset queryRuleset = new QueryRuleset(rulesetId, rules);
+                queryRulesets.add(queryRuleset);
+            });
+
+            MultiGetItemResponse[] multiGetItemResponses = new MultiGetItemResponse[queryRulesets.size()];
+            for (int i = 0; i < queryRulesets.size(); i++) {
+                QueryRuleset queryRuleset = queryRulesets.get(i);
+                String rulesetId = queryRuleset.id();
+                String json;
+                try {
+                    XContentBuilder builder = queryRuleset.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS);
+                    json = Strings.toString(builder);
+
+                    MultiGetItemResponse multiGetItemResponse = new MultiGetItemResponse(
+                        new GetResponse(
+                            new GetResult(
+                                QueryRulesIndexService.QUERY_RULES_ALIAS_NAME,
+                                rulesetId,
+                                0,
+                                1,
+                                0L,
+                                true,
+                                new BytesArray(json),
+                                null,
+                                null
+                            )
+                        ),
+                        null
+                    );
+                    multiGetItemResponses[i] = multiGetItemResponse;
+
+                } catch (IOException ex) {
+                    throw new ElasticsearchException("boom", ex);
+                }
             }
 
-            GetResponse response = new GetResponse(
-                new GetResult(QueryRulesIndexService.QUERY_RULES_ALIAS_NAME, rulesetId, 0, 1, 0L, true, new BytesArray(json), null, null)
-            );
+            MultiGetResponse response = new MultiGetResponse(multiGetItemResponses);
 
             @SuppressWarnings("unchecked")
-            ActionListener<GetResponse> listener = (ActionListener<GetResponse>) args[2];
+            ActionListener<MultiGetResponse> listener = (ActionListener<MultiGetResponse>) args[2];
             listener.onResponse(response);
 
             return null;