瀏覽代碼

[Query rules] Add `exclude` query rule type (#111420)

* Cleanup: Remove pinned IDs from applied rules in favor of single applied docs

* Add support for query rules of type exclude, to exclude specified documents from result sets

* Support exluded documents that specify the _index as well as the _id

* Cleanup

* Update docs/changelog/111420.yaml

* Update docs

* Spotless

* PR feedback - docs updates

* Apply PR feedback

* PR feedback

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Kathleen DeRusso 1 年之前
父節點
當前提交
02c494963a
共有 17 個文件被更改,包括 818 次插入297 次删除
  1. 5 0
      docs/changelog/111420.yaml
  2. 2 1
      docs/reference/query-dsl/rule-query.asciidoc
  3. 11 7
      docs/reference/query-rules/apis/put-query-rule.asciidoc
  4. 8 8
      docs/reference/query-rules/apis/put-query-ruleset.asciidoc
  5. 9 8
      docs/reference/search/search-your-data/search-using-query-rules.asciidoc
  6. 24 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/10_query_ruleset_put.yml
  7. 16 6
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/20_query_ruleset_list.yml
  8. 243 2
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/40_rule_query_search.yml
  9. 4 4
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/50_query_rule_put.yml
  10. 10 10
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/AppliedQueryRules.java
  11. 46 38
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java
  12. 78 43
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/RuleQueryBuilder.java
  13. 156 9
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRuleTests.java
  14. 22 18
      x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java
  15. 19 127
      x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java
  16. 136 0
      x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/SpecifiedDocument.java
  17. 29 16
      x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java

+ 5 - 0
docs/changelog/111420.yaml

@@ -0,0 +1,5 @@
+pr: 111420
+summary: "[Query rules] Add `exclude` query rule type"
+area: Relevance
+type: feature
+issues: []

+ 2 - 1
docs/reference/query-dsl/rule-query.asciidoc

@@ -13,9 +13,10 @@ The old syntax using `rule_query` and `ruleset_id` is deprecated and will be rem
 ====
 ====
 
 
 Applies <<query-rules-apis,query rules>> to the query before returning results.
 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.
+Query rules can be used to promote documents in the manner of a <<query-dsl-pinned-query>> based on matching defined rules, or to identify specific documents to exclude from a contextual result set.
 If no matching query rules are defined, the "organic" matches for the query are returned.
 If no matching query rules are defined, the "organic" matches for the query are returned.
 All matching rules are applied in the order in which they appear in the query ruleset.
 All matching rules are applied in the order in which they appear in the query ruleset.
+If the same document matches both an `exclude` rule and a `pinned` rule, the document will be excluded.
 
 
 [NOTE]
 [NOTE]
 ====
 ====

+ 11 - 7
docs/reference/query-rules/apis/put-query-rule.asciidoc

@@ -26,7 +26,10 @@ Requires the `manage_search_query_rules` privilege.
 
 
 `type`::
 `type`::
 (Required, string) The type of rule.
 (Required, string) The type of rule.
-At this time only `pinned` query rule types are allowed.
+At this time the following query rule types are allowed:
+
+- `pinned` will identify and pin specific documents to the top of search results.
+- `exclude` will exclude specific documents from search results.
 
 
 `criteria`::
 `criteria`::
 (Required, array of objects) The criteria that must be met for the rule to be applied.
 (Required, array of objects) The criteria that must be met for the rule to be applied.
@@ -80,17 +83,18 @@ Required for all criteria types except `always`.
 The format of this action depends on the rule type.
 The format of this action depends on the rule type.
 
 
 Actions depend on the rule type.
 Actions depend on the rule type.
-For `pinned` rules, actions follow the format specified by the <<query-dsl-pinned-query,Pinned Query>>.
-The following actions are allowed:
+The following actions are allowed for `pinned` or `exclude` rules:
 
 
-- `ids` (Optional, array of strings) The unique <<mapping-id-field, document IDs>> of the documents to pin.
+- `ids` (Optional, array of strings) The unique <<mapping-id-field, document IDs>> of the documents to apply the rule to.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
-- `docs` (Optional, array of objects) The documents to pin.
+- `docs` (Optional, array of objects) The documents to apply the rule to.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
+There is a maximum value of 100 documents in a rule.
 You can specify the following attributes for each document:
 You can specify the following attributes for each document:
 +
 +
 --
 --
-- `_index` (Required, string) The index of the document to pin.
+- `_index` (Required, string) The index of the document.
+If null, all documents with the specified `_id` will be affected across all searched indices.
 - `_id` (Required, string) The unique <<mapping-id-field, document ID>>.
 - `_id` (Required, string) The unique <<mapping-id-field, document ID>>.
 --
 --
 
 
@@ -104,7 +108,7 @@ If multiple matching rules pin more than 100 documents, only the first 100 docum
 
 
 The following example creates a new query rule with the ID `my-rule1` in a query ruleset called `my-ruleset`.
 The following example creates a new query rule with the ID `my-rule1` in a query ruleset called `my-ruleset`.
 
 
-`my-rule1` will pin documents with IDs `id1` and `id2` when `user_query` contains `pugs` _or_ `puggles` **and** `user_country` exactly matches `us`.
+- `my-rule1` will select documents to promote with IDs `id1` and `id2` when `user_query` contains `pugs` _or_ `puggles` **and** `user_country` exactly matches `us`.
 
 
 [source,console]
 [source,console]
 ----
 ----

+ 8 - 8
docs/reference/query-rules/apis/put-query-ruleset.asciidoc

@@ -34,7 +34,7 @@ Each rule must have the following information:
 
 
 - `rule_id` (Required, string) A unique identifier for this rule.
 - `rule_id` (Required, string) A unique identifier for this rule.
 - `type` (Required, string) The type of rule.
 - `type` (Required, string) The type of rule.
-At this time only `pinned` query rule types are allowed.
+At this time only `pinned` and `exclude` query rule types are allowed.
 - `criteria` (Required, array of objects) The criteria that must be met for the rule to be applied.
 - `criteria` (Required, array of objects) The criteria that must be met for the rule to be applied.
 If multiple criteria are specified for a rule, all criteria must be met for the rule to be applied.
 If multiple criteria are specified for a rule, all criteria must be met for the rule to be applied.
 - `actions` (Required, object) The actions to take when the rule is matched.
 - `actions` (Required, object) The actions to take when the rule is matched.
@@ -84,13 +84,13 @@ Only one value must match for the criteria to be met.
 Required for all criteria types except `always`.
 Required for all criteria types except `always`.
 
 
 Actions depend on the rule type.
 Actions depend on the rule type.
-For `pinned` rules, actions follow the format specified by the <<query-dsl-pinned-query,Pinned Query>>.
-The following actions are allowed:
+The following actions are allowed for `pinned` or `exclude` rules:
 
 
-- `ids` (Optional, array of strings) The unique <<mapping-id-field, document IDs>> of the documents to pin.
+- `ids` (Optional, array of strings) The unique <<mapping-id-field, document IDs>> of the documents to apply the rule to.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
-- `docs` (Optional, array of objects) The documents to pin.
+- `docs` (Optional, array of objects) The documents to apply the rule to.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
 Only one of `ids` or `docs` may be specified, and at least one must be specified.
+There is a maximum value of 100 documents in a rule.
 You can specify the following attributes for each document:
 You can specify the following attributes for each document:
 +
 +
 --
 --
@@ -98,7 +98,7 @@ You can specify the following attributes for each document:
 - `_id` (Required, string) The unique <<mapping-id-field, document ID>>.
 - `_id` (Required, string) The unique <<mapping-id-field, document ID>>.
 --
 --
 
 
-IMPORTANT: Due to limitations within <<query-dsl-pinned-query,Pinned queries>>, you can only pin documents using `ids` or `docs`, but cannot use both in single rule.
+IMPORTANT: Due to limitations within <<query-dsl-pinned-query,Pinned queries>>, you can only select documents using `ids` or `docs`, but cannot use both in single rule.
 It is advised to use one or the other in query rulesets, to avoid errors.
 It is advised to use one or the other in query rulesets, to avoid errors.
 Additionally, pinned queries have a maximum limit of 100 pinned hits.
 Additionally, pinned queries have a maximum limit of 100 pinned hits.
 If multiple matching rules pin more than 100 documents, only the first 100 documents are pinned in the order they are specified in the ruleset.
 If multiple matching rules pin more than 100 documents, only the first 100 documents are pinned in the order they are specified in the ruleset.
@@ -111,7 +111,7 @@ The following example creates a new query ruleset called `my-ruleset`.
 Two rules are associated with `my-ruleset`:
 Two rules are associated with `my-ruleset`:
 
 
 - `my-rule1` will pin documents with IDs `id1` and `id2` when `user_query` contains `pugs` _or_ `puggles` **and** `user_country` exactly matches `us`.
 - `my-rule1` will pin documents with IDs `id1` and `id2` when `user_query` contains `pugs` _or_ `puggles` **and** `user_country` exactly matches `us`.
-- `my-rule2` will pin documents from different, specified indices with IDs `id3` and `id4` when the `query_string` fuzzily matches `rescue dogs`.
+- `my-rule2` will exclude documents from different, specified indices with IDs `id3` and `id4` when the `query_string` fuzzily matches `rescue dogs`.
 
 
 [source,console]
 [source,console]
 ----
 ----
@@ -142,7 +142,7 @@ PUT _query_rules/my-ruleset
         },
         },
         {
         {
             "rule_id": "my-rule2",
             "rule_id": "my-rule2",
-            "type": "pinned",
+            "type": "exclude",
             "criteria": [
             "criteria": [
                 {
                 {
                     "type": "fuzzy",
                     "type": "fuzzy",

+ 9 - 8
docs/reference/search/search-your-data/search-using-query-rules.asciidoc

@@ -37,9 +37,10 @@ When defining a rule, consider the following:
 ===== Rule type
 ===== Rule type
 
 
 The type of rule we want to apply.
 The type of rule we want to apply.
-For the moment there is a single rule type:
+We support the following rule types:
 
 
 * `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.
 * `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.
+* `exclude` will exclude specified results from the returned result set.
 
 
 [discrete]
 [discrete]
 [[query-rule-criteria]]
 [[query-rule-criteria]]
@@ -91,12 +92,11 @@ Allowed criteria types are:
 
 
 The actions to take when the rule matches a query:
 The actions to take when the rule matches a query:
 
 
-* `ids` will pin the specified <<mapping-id-field,`_id`>>s.
-* `docs` will pin the specified documents in the specified indices.
+* `ids` will select the specified <<mapping-id-field,`_id`>>s.
+* `docs` will select the specified documents in the specified indices.
 
 
 Use `ids` when searching over a single index, and `docs` when searching over multiple indices.
 Use `ids` when searching over a single index, and `docs` when searching over multiple indices.
 `ids` and `docs` cannot be combined in the same query.
 `ids` and `docs` cannot be combined in the same query.
-See <<query-dsl-pinned-query,pinned query>> for details.
 
 
 [discrete]
 [discrete]
 [[add-query-rules]]
 [[add-query-rules]]
@@ -105,10 +105,10 @@ See <<query-dsl-pinned-query,pinned query>> for details.
 You can add query rules using the <<put-query-ruleset>> call.
 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.
 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:
+The following command will create a query ruleset called `my-ruleset` with two query rules:
 
 
 * The first rule will generate a <<query-dsl-pinned-query>> pinning the <<mapping-id-field,`_id`>>s `id1` and `id2` when the `query_string` metadata value is a fuzzy match to either `puggles` or `pugs` _and_ the user's location is in the US.
 * The first rule will generate a <<query-dsl-pinned-query>> pinning the <<mapping-id-field,`_id`>>s `id1` and `id2` when the `query_string` metadata value is a fuzzy match to either `puggles` or `pugs` _and_ the user's location is in the US.
-* The second rule will generate a <<query-dsl-pinned-query>> pinning the <<mapping-id-field, `_id`>> of `id3` specifically from the `my-index-000001` index and `id4` from the `my-index-000002` index when the `query_string` metadata value contains `beagles`.
+* The second rule will generate a query that excludes the <<mapping-id-field, `_id`>> `id3` specifically from the `my-index-000001` index and `id4` from the `my-index-000002` index when the `query_string` metadata value contains `beagles`.
 
 
 ////
 ////
 [source,console]
 [source,console]
@@ -147,7 +147,7 @@ PUT /_query_rules/my-ruleset
     },
     },
     {
     {
       "rule_id": "rule2",
       "rule_id": "rule2",
-      "type": "pinned",
+      "type": "exclude",
       "criteria": [
       "criteria": [
         {
         {
           "type": "contains",
           "type": "contains",
@@ -222,7 +222,8 @@ This rule query will match against `rule1` in the defined query ruleset, and wil
 Any other matches from the organic query will be returned below the pinned results.
 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 <<query-dsl-rule-query, rule query>>.
 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:
+In this case, the rules are applied in the following order:
 
 
 - Where the matching rule appears in the ruleset
 - Where the matching rule appears in the ruleset
 - If multiple documents are specified in a single rule, in the order they are specified
 - If multiple documents are specified in a single rule, in the order they are specified
+- If a document is matched by both a `pinned` rule and an `exclude` rule, the `exclude` rule will take precedence

+ 24 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/10_query_ruleset_put.yml

@@ -44,6 +44,18 @@ teardown:
                     '_id': 'id3'
                     '_id': 'id3'
                   - '_index': 'test-index2'
                   - '_index': 'test-index2'
                     '_id': 'id4'
                     '_id': 'id4'
+            - rule_id: query-rule-id3
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ logstash ]
+              actions:
+                docs:
+                  - '_index': 'test-index1'
+                    '_id': 'id4'
+                  - '_index': 'test-index2'
+                    '_id': 'id5'
 
 
   - match: { result: 'created' }
   - match: { result: 'created' }
 
 
@@ -75,6 +87,18 @@ teardown:
                 '_id': 'id3'
                 '_id': 'id3'
               - '_index': 'test-index2'
               - '_index': 'test-index2'
                 '_id': 'id4'
                 '_id': 'id4'
+        - rule_id: query-rule-id3
+          type: exclude
+          criteria:
+            - type: exact
+              metadata: query_string
+              values: [ logstash ]
+          actions:
+            docs:
+              - '_index': 'test-index1'
+                '_id': 'id4'
+              - '_index': 'test-index2'
+                '_id': 'id5'
 
 
 ---
 ---
 'Create Query Ruleset - Resource already exists':
 'Create Query Ruleset - Resource already exists':

+ 16 - 6
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/20_query_ruleset_list.yml

@@ -109,6 +109,16 @@ setup:
                 ids:
                 ids:
                   - 'id7'
                   - 'id7'
                   - 'id8'
                   - 'id8'
+            - rule_id: query-rule-id5
+              type: exclude
+              criteria:
+                - type: fuzzy
+                  metadata: query_string
+                  values: [ inference ]
+              actions:
+                ids:
+                  - 'id9'
+                  - 'id10'
 ---
 ---
 teardown:
 teardown:
   - do:
   - do:
@@ -144,8 +154,8 @@ teardown:
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
 
 
   - match: { results.1.ruleset_id: "test-query-ruleset-2" }
   - match: { results.1.ruleset_id: "test-query-ruleset-2" }
-  - match: { results.1.rule_total_count: 4 }
-  - match: { results.1.rule_criteria_types_counts: { exact: 4 } }
+  - match: { results.1.rule_total_count: 5 }
+  - match: { results.1.rule_criteria_types_counts: { exact: 4, fuzzy: 1 } }
 
 
   - match: { results.2.ruleset_id: "test-query-ruleset-3" }
   - match: { results.2.ruleset_id: "test-query-ruleset-3" }
   - match: { results.2.rule_total_count: 2 }
   - match: { results.2.rule_total_count: 2 }
@@ -161,8 +171,8 @@ teardown:
 
 
   # Alphabetical order by ruleset_id for results
   # Alphabetical order by ruleset_id for results
   - match: { results.0.ruleset_id: "test-query-ruleset-2" }
   - match: { results.0.ruleset_id: "test-query-ruleset-2" }
-  - match: { results.0.rule_total_count: 4 }
-  - match: { results.0.rule_criteria_types_counts: { exact: 4 } }
+  - match: { results.0.rule_total_count: 5 }
+  - match: { results.0.rule_criteria_types_counts: { exact: 4, fuzzy: 1 } }
 
 
   - match: { results.1.ruleset_id: "test-query-ruleset-3" }
   - match: { results.1.ruleset_id: "test-query-ruleset-3" }
   - match: { results.1.rule_total_count: 2 }
   - match: { results.1.rule_total_count: 2 }
@@ -182,8 +192,8 @@ teardown:
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
 
 
   - match: { results.1.ruleset_id: "test-query-ruleset-2" }
   - match: { results.1.ruleset_id: "test-query-ruleset-2" }
-  - match: { results.1.rule_total_count: 4 }
-  - match: { results.1.rule_criteria_types_counts: { exact: 4 } }
+  - match: { results.1.rule_total_count: 5 }
+  - match: { results.1.rule_criteria_types_counts: { exact: 4, fuzzy: 1 } }
 
 
 ---
 ---
 "List Query Rulesets - empty":
 "List Query Rulesets - empty":

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

@@ -11,6 +11,19 @@ setup:
             index:
             index:
               number_of_shards: 1
               number_of_shards: 1
               number_of_replicas: 0
               number_of_replicas: 0
+          aliases:
+            test-alias1: { }
+
+  - do:
+      indices.create:
+        index: test-index2
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+          aliases:
+            test-alias1: { }
 
 
   - do:
   - do:
       bulk:
       bulk:
@@ -38,6 +51,22 @@ setup:
           - index:
           - index:
               _id: doc7
               _id: doc7
           - { "text": "observability" }
           - { "text": "observability" }
+          - index:
+              _id: doc8
+          - { "text": "elasticsearch" }
+
+  - do:
+      bulk:
+        refresh: true
+        index: test-index2
+        body:
+          - index:
+              _id: another-doc
+          - { "text": "you know, for search" }
+          - index:
+              _id: doc8
+          - { "text": "elasticsearch" }
+
 
 
   - do:
   - do:
       query_rules.put_ruleset:
       query_rules.put_ruleset:
@@ -82,6 +111,15 @@ setup:
               actions:
               actions:
                 ids:
                 ids:
                   - 'doc7'
                   - 'doc7'
+            - rule_id: rule5
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'doc8'
 
 
   - do:
   - do:
       query_rules.put_ruleset:
       query_rules.put_ruleset:
@@ -115,6 +153,21 @@ teardown:
         ruleset_id: combined-ruleset
         ruleset_id: combined-ruleset
         ignore: 404
         ignore: 404
 
 
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: alias-ruleset
+        ignore: 404
+
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: double-jeopardy-ruleset
+        ignore: 404
+
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: multiple-exclude-ruleset
+        ignore: 404
+
 ---
 ---
 "Perform a rule query specifying a ruleset that does not exist":
 "Perform a rule query specifying a ruleset that does not exist":
   - do:
   - do:
@@ -165,7 +218,7 @@ teardown:
                 foo: bar
                 foo: bar
 
 
 ---
 ---
-"Perform a rule query with malformed rule":
+"Perform a search with malformed rule query":
   - do:
   - do:
       catch: bad_request
       catch: bad_request
       search:
       search:
@@ -184,6 +237,7 @@ teardown:
 
 
   - do:
   - do:
       search:
       search:
+        index: test-index1
         body:
         body:
           query:
           query:
             rule:
             rule:
@@ -208,6 +262,7 @@ teardown:
   - do:
   - do:
       headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
       headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
       search:
       search:
+        index: test-index1
         body:
         body:
           query:
           query:
             rule:
             rule:
@@ -294,6 +349,7 @@ teardown:
 
 
   - do:
   - do:
       search:
       search:
+        index: test-index1
         body:
         body:
           query:
           query:
             rule:
             rule:
@@ -310,6 +366,190 @@ teardown:
   - match: { hits.hits.0._id: 'doc2' }
   - match: { hits.hits.0._id: 'doc2' }
   - match: { hits.hits.1._id: 'doc3' }
   - match: { hits.hits.1._id: 'doc3' }
 
 
+---
+"Perform a query over an alias, where one document is pinned specifying the index":
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: alias-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                docs:
+                  - '_index': 'test-index1'
+                    '_id': 'doc8'
+            - rule_id: rule2
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ baz ]
+              actions:
+                docs:
+                  - '_index': 'test-index1'
+                    '_id': 'doc8'
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                match_none: { }
+              match_criteria:
+                foo: bar
+              ruleset_ids:
+                - alias-ruleset
+
+  - match: { hits.total.value: 1 }
+  - match: { hits.hits.0._id: 'doc8' }
+  - match: { hits.hits.0._index: 'test-index1' }
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  query: elasticsearch
+              match_criteria:
+                foo: baz
+              ruleset_ids:
+                - alias-ruleset
+
+  - match: { hits.total.value: 1 }
+  - match: { hits.hits.0._id: 'doc8' }
+  - match: { hits.hits.0._index: 'test-index2' }
+
+  - do:
+      search:
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  query: elasticsearch
+              match_criteria:
+                foo: not-a-match
+              ruleset_ids:
+                - alias-ruleset
+
+  - match: { hits.total.value: 2 }
+
+---
+"Perform a query where the same ID is both pinned and excluded, leading it to be excluded":
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: double-jeopardy-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc8'
+            - rule_id: rule2
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc8'
+
+  - do:
+      search:
+        index: test-index2
+        body:
+          query:
+            query_string:
+              query: elasticsearch
+
+  - match: { hits.total.value: 1 }
+
+  - do:
+      search:
+        index: test-index2
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  query: elasticsearch
+              match_criteria:
+                foo: bar
+              ruleset_ids:
+                - double-jeopardy-ruleset
+
+  - match: { hits.total.value: 0 }
+
+---
+"Perform a query that matches multiple exclude rules":
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: multiple-exclude-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc1'
+            - rule_id: rule2
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc8'
+
+  - do:
+      search:
+        index: test-index1
+        body:
+          query:
+            query_string:
+              query: elasticsearch is elastic search
+
+  - match: { hits.total.value: 3 }
+  - match: { hits.hits.0._id: 'doc1' }
+  - match: { hits.hits.1._id: 'doc8' }
+  - match: { hits.hits.2._id: 'doc4' }
+
+  - do:
+      search:
+        index: test-index1
+        body:
+          query:
+            rule:
+              organic:
+                query_string:
+                  query: elasticsearch is elastic search
+              match_criteria:
+                foo: bar
+              ruleset_ids:
+                - multiple-exclude-ruleset
+
+  - match: { hits.total.value: 1 }
+  - match: { hits.hits.0._id: 'doc4' }
+
 ---
 ---
 "Perform a rule query over a ruleset with combined numeric and text rule matching":
 "Perform a rule query over a ruleset with combined numeric and text rule matching":
 
 
@@ -509,6 +749,7 @@ teardown:
 
 
   - do:
   - do:
       search:
       search:
+        index: test-index1
         body:
         body:
           query:
           query:
             rule:
             rule:
@@ -634,6 +875,7 @@ teardown:
 
 
   - do:
   - do:
       search:
       search:
+        index: test-index1
         body:
         body:
           query:
           query:
             rule_query:
             rule_query:
@@ -651,4 +893,3 @@ teardown:
   - match: { hits.total.value: 2 }
   - match: { hits.total.value: 2 }
   - match: { hits.hits.0._id: 'doc1' }
   - match: { hits.hits.0._id: 'doc1' }
   - match: { hits.hits.1._id: 'doc4' }
   - match: { hits.hits.1._id: 'doc4' }
-

+ 4 - 4
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/50_query_rule_put.yml

@@ -101,7 +101,7 @@ teardown:
                   - 'id1'
                   - 'id1'
                   - 'id2'
                   - 'id2'
             - rule_id: query-rule-id2
             - rule_id: query-rule-id2
-              type: pinned
+              type: exclude
               criteria:
               criteria:
                 - type: exact
                 - type: exact
                   metadata: query_string
                   metadata: query_string
@@ -122,7 +122,7 @@ teardown:
   - match:
   - match:
       rules:
       rules:
         - rule_id: query-rule-id2
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
           criteria:
             - type: exact
             - type: exact
               metadata: query_string
               metadata: query_string
@@ -169,7 +169,7 @@ teardown:
   - match:
   - match:
       rules:
       rules:
         - rule_id: query-rule-id2
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
           criteria:
             - type: exact
             - type: exact
               metadata: query_string
               metadata: query_string
@@ -225,7 +225,7 @@ teardown:
   - match:
   - match:
       rules:
       rules:
         - rule_id: query-rule-id2
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
           criteria:
             - type: exact
             - type: exact
               metadata: query_string
               metadata: query_string

+ 10 - 10
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/AppliedQueryRules.java

@@ -7,31 +7,31 @@
 
 
 package org.elasticsearch.xpack.application.rules;
 package org.elasticsearch.xpack.application.rules;
 
 
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
+
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.List;
 
 
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
-
 public class AppliedQueryRules {
 public class AppliedQueryRules {
 
 
-    private final List<String> pinnedIds;
-    private final List<Item> pinnedDocs;
+    private final List<SpecifiedDocument> pinnedDocs;
+    private final List<SpecifiedDocument> excludedDocs;
 
 
     public AppliedQueryRules() {
     public AppliedQueryRules() {
         this(new ArrayList<>(0), new ArrayList<>(0));
         this(new ArrayList<>(0), new ArrayList<>(0));
     }
     }
 
 
-    public AppliedQueryRules(List<String> pinnedIds, List<Item> pinnedDocs) {
-        this.pinnedIds = pinnedIds;
+    public AppliedQueryRules(List<SpecifiedDocument> pinnedDocs, List<SpecifiedDocument> excludedDocs) {
         this.pinnedDocs = pinnedDocs;
         this.pinnedDocs = pinnedDocs;
+        this.excludedDocs = excludedDocs;
     }
     }
 
 
-    public List<String> pinnedIds() {
-        return pinnedIds;
+    public List<SpecifiedDocument> pinnedDocs() {
+        return pinnedDocs;
     }
     }
 
 
-    public List<Item> pinnedDocs() {
-        return pinnedDocs;
+    public List<SpecifiedDocument> excludedDocs() {
+        return excludedDocs;
     }
     }
 
 
 }
 }

+ 46 - 38
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java

@@ -23,7 +23,7 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.XContentType;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder;
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -31,14 +31,11 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
+import java.util.Set;
 
 
 import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
 import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
 import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
 import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
 import static org.elasticsearch.xpack.application.rules.QueryRuleCriteriaType.ALWAYS;
 import static org.elasticsearch.xpack.application.rules.QueryRuleCriteriaType.ALWAYS;
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.DOCS_FIELD;
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.IDS_FIELD;
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item.INDEX_FIELD;
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.MAX_NUM_PINNED_HITS;
 
 
 /**
 /**
  * A query rule consists of:
  * A query rule consists of:
@@ -51,6 +48,11 @@ import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.MAX
  */
  */
 public class QueryRule implements Writeable, ToXContentObject {
 public class QueryRule implements Writeable, ToXContentObject {
 
 
+    public static final int MAX_NUM_DOCS_IN_RULE = 100;
+    public static final ParseField IDS_FIELD = new ParseField("ids");
+    public static final ParseField DOCS_FIELD = new ParseField("docs");
+    public static final ParseField INDEX_FIELD = new ParseField("_index");
+
     private final String id;
     private final String id;
     private final QueryRuleType type;
     private final QueryRuleType type;
     private final List<QueryRuleCriteria> criteria;
     private final List<QueryRuleCriteria> criteria;
@@ -61,6 +63,7 @@ public class QueryRule implements Writeable, ToXContentObject {
     public static final int MAX_PRIORITY = 1000000;
     public static final int MAX_PRIORITY = 1000000;
 
 
     public enum QueryRuleType {
     public enum QueryRuleType {
+        EXCLUDE,
         PINNED;
         PINNED;
 
 
         public static QueryRuleType queryRuleType(String type) {
         public static QueryRuleType queryRuleType(String type) {
@@ -137,32 +140,31 @@ public class QueryRule implements Writeable, ToXContentObject {
     }
     }
 
 
     private void validate() {
     private void validate() {
-        if (type == QueryRuleType.PINNED) {
-            boolean ruleContainsPinnedIds = actions.containsKey(IDS_FIELD.getPreferredName());
-            boolean ruleContainsPinnedDocs = actions.containsKey(DOCS_FIELD.getPreferredName());
-            if (ruleContainsPinnedIds ^ ruleContainsPinnedDocs) {
-                validatePinnedAction(actions.get(IDS_FIELD.getPreferredName()));
-                validatePinnedAction(actions.get(DOCS_FIELD.getPreferredName()));
-            } else {
-                throw new ElasticsearchParseException("pinned query rule actions must contain only one of either ids or docs");
-            }
-        } else {
-            throw new IllegalArgumentException("Unsupported QueryRuleType: " + type);
-        }
 
 
         if (priority != null && (priority < MIN_PRIORITY || priority > MAX_PRIORITY)) {
         if (priority != null && (priority < MIN_PRIORITY || priority > MAX_PRIORITY)) {
             throw new IllegalArgumentException("Priority was " + priority + ", must be between " + MIN_PRIORITY + " and " + MAX_PRIORITY);
             throw new IllegalArgumentException("Priority was " + priority + ", must be between " + MIN_PRIORITY + " and " + MAX_PRIORITY);
         }
         }
+
+        if (Set.of(QueryRuleType.PINNED, QueryRuleType.EXCLUDE).contains(type)) {
+            boolean ruleContainsIds = actions.containsKey(IDS_FIELD.getPreferredName());
+            boolean ruleContainsDocs = actions.containsKey(DOCS_FIELD.getPreferredName());
+            if (ruleContainsIds ^ ruleContainsDocs) {
+                validateIdOrDocAction(actions.get(IDS_FIELD.getPreferredName()));
+                validateIdOrDocAction(actions.get(DOCS_FIELD.getPreferredName()));
+            } else {
+                throw new ElasticsearchParseException(type.toString() + " query rule actions must contain only one of either ids or docs");
+            }
+        }
     }
     }
 
 
-    private void validatePinnedAction(Object action) {
+    private void validateIdOrDocAction(Object action) {
         if (action != null) {
         if (action != null) {
             if (action instanceof List == false) {
             if (action instanceof List == false) {
-                throw new ElasticsearchParseException("pinned query rule actions must be a list");
+                throw new ElasticsearchParseException(type + " query rule actions must be a list");
             } else if (((List<?>) action).isEmpty()) {
             } else if (((List<?>) action).isEmpty()) {
-                throw new ElasticsearchParseException("pinned query rule actions cannot be empty");
-            } else if (((List<?>) action).size() > MAX_NUM_PINNED_HITS) {
-                throw new ElasticsearchParseException("pinned hits cannot exceed " + MAX_NUM_PINNED_HITS);
+                throw new ElasticsearchParseException(type + " query rule actions cannot be empty");
+            } else if (((List<?>) action).size() > MAX_NUM_DOCS_IN_RULE) {
+                throw new ElasticsearchParseException(type + " documents cannot exceed " + MAX_NUM_DOCS_IN_RULE);
             }
             }
         }
         }
     }
     }
@@ -316,14 +318,22 @@ public class QueryRule implements Writeable, ToXContentObject {
         return Strings.toString(this);
         return Strings.toString(this);
     }
     }
 
 
-    @SuppressWarnings("unchecked")
     public AppliedQueryRules applyRule(AppliedQueryRules appliedRules, Map<String, Object> matchCriteria) {
     public AppliedQueryRules applyRule(AppliedQueryRules appliedRules, Map<String, Object> matchCriteria) {
-        if (type != QueryRule.QueryRuleType.PINNED) {
-            throw new UnsupportedOperationException("Only pinned query rules are supported");
+        List<SpecifiedDocument> pinnedDocs = appliedRules.pinnedDocs();
+        List<SpecifiedDocument> excludedDocs = appliedRules.excludedDocs();
+        List<SpecifiedDocument> matchingDocs = identifyMatchingDocs(matchCriteria);
+
+        switch (type) {
+            case PINNED -> pinnedDocs.addAll(matchingDocs);
+            case EXCLUDE -> excludedDocs.addAll(matchingDocs);
+            default -> throw new IllegalStateException("Unsupported query rule type: " + type);
         }
         }
+        return new AppliedQueryRules(pinnedDocs, excludedDocs);
+    }
 
 
-        List<String> matchingPinnedIds = new ArrayList<>();
-        List<PinnedQueryBuilder.Item> matchingPinnedDocs = new ArrayList<>();
+    @SuppressWarnings("unchecked")
+    private List<SpecifiedDocument> identifyMatchingDocs(Map<String, Object> matchCriteria) {
+        List<SpecifiedDocument> matchingDocs = new ArrayList<>();
         Boolean isRuleMatch = null;
         Boolean isRuleMatch = null;
 
 
         // All specified criteria in a rule must match for the rule to be applied
         // All specified criteria in a rule must match for the rule to be applied
@@ -342,25 +352,23 @@ public class QueryRule implements Writeable, ToXContentObject {
 
 
         if (isRuleMatch != null && isRuleMatch) {
         if (isRuleMatch != null && isRuleMatch) {
             if (actions.containsKey(IDS_FIELD.getPreferredName())) {
             if (actions.containsKey(IDS_FIELD.getPreferredName())) {
-                matchingPinnedIds.addAll((List<String>) actions.get(IDS_FIELD.getPreferredName()));
+                matchingDocs.addAll(
+                    ((List<String>) actions.get(IDS_FIELD.getPreferredName())).stream().map(id -> new SpecifiedDocument(null, id)).toList()
+                );
             } else if (actions.containsKey(DOCS_FIELD.getPreferredName())) {
             } else if (actions.containsKey(DOCS_FIELD.getPreferredName())) {
                 List<Map<String, String>> docsToPin = (List<Map<String, String>>) actions.get(DOCS_FIELD.getPreferredName());
                 List<Map<String, String>> docsToPin = (List<Map<String, String>>) actions.get(DOCS_FIELD.getPreferredName());
-                List<PinnedQueryBuilder.Item> items = docsToPin.stream()
+                List<SpecifiedDocument> specifiedDocuments = docsToPin.stream()
                     .map(
                     .map(
-                        map -> new PinnedQueryBuilder.Item(
+                        map -> new SpecifiedDocument(
                             map.get(INDEX_FIELD.getPreferredName()),
                             map.get(INDEX_FIELD.getPreferredName()),
-                            map.get(PinnedQueryBuilder.Item.ID_FIELD.getPreferredName())
+                            map.get(SpecifiedDocument.ID_FIELD.getPreferredName())
                         )
                         )
                     )
                     )
                     .toList();
                     .toList();
-                matchingPinnedDocs.addAll(items);
+                matchingDocs.addAll(specifiedDocuments);
             }
             }
         }
         }
-
-        List<String> pinnedIds = appliedRules.pinnedIds();
-        List<PinnedQueryBuilder.Item> pinnedDocs = appliedRules.pinnedDocs();
-        pinnedIds.addAll(matchingPinnedIds);
-        pinnedDocs.addAll(matchingPinnedDocs);
-        return new AppliedQueryRules(pinnedIds, pinnedDocs);
+        return matchingDocs;
     }
     }
+
 }
 }

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

@@ -20,8 +20,12 @@ import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.logging.HeaderWarning;
 import org.elasticsearch.common.logging.HeaderWarning;
+import org.elasticsearch.index.mapper.IdFieldMapper;
+import org.elasticsearch.index.mapper.IndexFieldMapper;
 import org.elasticsearch.index.query.AbstractQueryBuilder;
 import org.elasticsearch.index.query.AbstractQueryBuilder;
+import org.elasticsearch.index.query.BoolQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.index.query.QueryRewriteContext;
 import org.elasticsearch.index.query.QueryRewriteContext;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.license.LicenseUtils;
 import org.elasticsearch.license.LicenseUtils;
@@ -32,7 +36,7 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder;
 import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.util.List;
 import java.util.List;
@@ -68,8 +72,8 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
     private final Map<String, Object> matchCriteria;
     private final Map<String, Object> matchCriteria;
     private final QueryBuilder organicQuery;
     private final QueryBuilder organicQuery;
 
 
-    private final Supplier<List<String>> pinnedIdsSupplier;
-    private final Supplier<List<Item>> pinnedDocsSupplier;
+    private final Supplier<List<SpecifiedDocument>> pinnedDocsSupplier;
+    private final Supplier<List<SpecifiedDocument>> excludedDocsSupplier;
 
 
     @Override
     @Override
     public TransportVersion getMinimalSupportedVersion() {
     public TransportVersion getMinimalSupportedVersion() {
@@ -89,18 +93,18 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         } else {
         } else {
             rulesetIds = List.of(in.readString());
             rulesetIds = List.of(in.readString());
             in.readOptionalStringCollectionAsList();
             in.readOptionalStringCollectionAsList();
-            in.readOptionalCollectionAsList(Item::new);
+            in.readOptionalCollectionAsList(SpecifiedDocument::new);
         }
         }
-        pinnedIdsSupplier = null;
         pinnedDocsSupplier = null;
         pinnedDocsSupplier = null;
+        excludedDocsSupplier = null;
     }
     }
 
 
     private RuleQueryBuilder(
     private RuleQueryBuilder(
         QueryBuilder organicQuery,
         QueryBuilder organicQuery,
         Map<String, Object> matchCriteria,
         Map<String, Object> matchCriteria,
         List<String> rulesetIds,
         List<String> rulesetIds,
-        Supplier<List<String>> pinnedIdsSupplier,
-        Supplier<List<Item>> pinnedDocsSupplier
+        Supplier<List<SpecifiedDocument>> pinnedDocsSupplier,
+        Supplier<List<SpecifiedDocument>> excludedDocsSupplier
 
 
     ) {
     ) {
         if (organicQuery == null) {
         if (organicQuery == null) {
@@ -124,18 +128,18 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         this.organicQuery = organicQuery;
         this.organicQuery = organicQuery;
         this.matchCriteria = matchCriteria;
         this.matchCriteria = matchCriteria;
         this.rulesetIds = rulesetIds;
         this.rulesetIds = rulesetIds;
-        this.pinnedIdsSupplier = pinnedIdsSupplier;
         this.pinnedDocsSupplier = pinnedDocsSupplier;
         this.pinnedDocsSupplier = pinnedDocsSupplier;
+        this.excludedDocsSupplier = excludedDocsSupplier;
     }
     }
 
 
     @Override
     @Override
     protected void doWriteTo(StreamOutput out) throws IOException {
     protected void doWriteTo(StreamOutput out) throws IOException {
-        if (pinnedIdsSupplier != null) {
-            throw new IllegalStateException("pinnedIdsSupplier must be null, can't serialize suppliers, missing a rewriteAndFetch?");
-        }
         if (pinnedDocsSupplier != null) {
         if (pinnedDocsSupplier != null) {
             throw new IllegalStateException("pinnedDocsSupplier must be null, can't serialize suppliers, missing a rewriteAndFetch?");
             throw new IllegalStateException("pinnedDocsSupplier must be null, can't serialize suppliers, missing a rewriteAndFetch?");
         }
         }
+        if (excludedDocsSupplier != null) {
+            throw new IllegalStateException("excludedDocsSupplier must be null, can't serialize suppliers, missing a rewriteAndFetch?");
+        }
 
 
         out.writeNamedWriteable(organicQuery);
         out.writeNamedWriteable(organicQuery);
         out.writeGenericMap(matchCriteria);
         out.writeGenericMap(matchCriteria);
@@ -176,18 +180,11 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
     @Override
     @Override
     protected Query doToQuery(SearchExecutionContext context) throws IOException {
     protected Query doToQuery(SearchExecutionContext context) throws IOException {
         // NOTE: this is old query logic, as in 8.12.2+ and 8.13.0+ we will always rewrite this query
         // 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
+        // into a pinned/boolean query or the organic query. This logic remains here for backwards compatibility
         // with coordinator nodes running versions 8.10.0 - 8.12.1.
         // 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);
-        } else if (pinnedDocs != null && pinnedDocs.isEmpty() == false) {
-            PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(organicQuery, pinnedDocs.toArray(new Item[0]));
+        List<SpecifiedDocument> pinnedDocs = pinnedDocsSupplier != null ? pinnedDocsSupplier.get() : null;
+        if (pinnedDocs != null && pinnedDocs.isEmpty() == false) {
+            PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(organicQuery, pinnedDocs.toArray(new SpecifiedDocument[0]));
             return pinnedQueryBuilder.toQuery(context);
             return pinnedQueryBuilder.toQuery(context);
         } else {
         } else {
             return organicQuery.toQuery(context);
             return organicQuery.toQuery(context);
@@ -196,26 +193,43 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
 
     @Override
     @Override
     protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) {
     protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) {
-        if (pinnedIdsSupplier != null && pinnedDocsSupplier != null) {
-            List<String> identifiedPinnedIds = pinnedIdsSupplier.get();
-            List<Item> identifiedPinnedDocs = pinnedDocsSupplier.get();
-            if (identifiedPinnedIds == null || identifiedPinnedDocs == null) {
-                return this; // Not executed yet
-            } else if (identifiedPinnedIds.isEmpty() && identifiedPinnedDocs.isEmpty()) {
-                return organicQuery; // Nothing to pin here
-            } else if (identifiedPinnedIds.isEmpty() == false && identifiedPinnedDocs.isEmpty() == false) {
-                throw new IllegalArgumentException(
-                    "applied rules contain both pinned ids and pinned docs, only one of ids or docs is allowed"
-                );
-            } else if (identifiedPinnedIds.isEmpty() == false) {
-                return new PinnedQueryBuilder(organicQuery, truncateList(identifiedPinnedIds).toArray(new String[0]));
+
+        if (pinnedDocsSupplier != null && excludedDocsSupplier != null) {
+            List<SpecifiedDocument> identifiedPinnedDocs = pinnedDocsSupplier.get();
+            List<SpecifiedDocument> identifiedExcludedDocs = excludedDocsSupplier.get();
+
+            if (identifiedPinnedDocs == null || identifiedExcludedDocs == null) {
+                // Not executed yet
+                return this;
+            }
+
+            if (identifiedPinnedDocs.isEmpty() && identifiedExcludedDocs.isEmpty()) {
+                // Nothing to do, just return the organic query
+                return organicQuery;
+            }
+
+            if (identifiedPinnedDocs.isEmpty() == false && identifiedExcludedDocs.isEmpty()) {
+                // We have pinned IDs but nothing to exclude
+                return new PinnedQueryBuilder(organicQuery, truncateList(identifiedPinnedDocs).toArray(new SpecifiedDocument[0]));
+            }
+
+            if (identifiedPinnedDocs.isEmpty()) {
+                // We have excluded IDs but nothing to pin
+                QueryBuilder excludedDocsQueryBuilder = buildExcludedDocsQuery(identifiedExcludedDocs);
+                return new BoolQueryBuilder().must(organicQuery).mustNot(excludedDocsQueryBuilder);
             } else {
             } else {
-                return new PinnedQueryBuilder(organicQuery, truncateList(identifiedPinnedDocs).toArray(new Item[0]));
+                // We have documents to both pin and exclude
+                QueryBuilder pinnedQuery = new PinnedQueryBuilder(
+                    organicQuery,
+                    truncateList(identifiedPinnedDocs).toArray(new SpecifiedDocument[0])
+                );
+                QueryBuilder excludedDocsQueryBuilder = buildExcludedDocsQuery(identifiedExcludedDocs);
+                return new BoolQueryBuilder().must(pinnedQuery).mustNot(excludedDocsQueryBuilder);
             }
             }
         }
         }
 
 
-        SetOnce<List<String>> pinnedIdsSetOnce = new SetOnce<>();
-        SetOnce<List<Item>> pinnedDocsSetOnce = new SetOnce<>();
+        SetOnce<List<SpecifiedDocument>> pinnedDocsSetOnce = new SetOnce<>();
+        SetOnce<List<SpecifiedDocument>> excludedDocsSetOnce = new SetOnce<>();
         AppliedQueryRules appliedRules = new AppliedQueryRules();
         AppliedQueryRules appliedRules = new AppliedQueryRules();
 
 
         // Identify matching rules and apply them as applicable
         // Identify matching rules and apply them as applicable
@@ -255,19 +269,40 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
                         }
                         }
                     }
                     }
 
 
-                    pinnedIdsSetOnce.set(appliedRules.pinnedIds().stream().distinct().toList());
                     pinnedDocsSetOnce.set(appliedRules.pinnedDocs().stream().distinct().toList());
                     pinnedDocsSetOnce.set(appliedRules.pinnedDocs().stream().distinct().toList());
+                    excludedDocsSetOnce.set(appliedRules.excludedDocs().stream().distinct().toList());
                     listener.onResponse(null);
                     listener.onResponse(null);
 
 
                 }, listener::onFailure)
                 }, listener::onFailure)
             );
             );
         });
         });
 
 
-        return new RuleQueryBuilder(organicQuery, matchCriteria, this.rulesetIds, pinnedIdsSetOnce::get, pinnedDocsSetOnce::get).boost(
+        return new RuleQueryBuilder(organicQuery, matchCriteria, this.rulesetIds, pinnedDocsSetOnce::get, excludedDocsSetOnce::get).boost(
             this.boost
             this.boost
         ).queryName(this.queryName);
         ).queryName(this.queryName);
     }
     }
 
 
+    private QueryBuilder buildExcludedDocsQuery(List<SpecifiedDocument> identifiedExcludedDocs) {
+        QueryBuilder excludedDocsQueryBuilder;
+        if (identifiedExcludedDocs.stream().allMatch(item -> item.index() == null)) {
+            // Easy case - just add an ids query
+            excludedDocsQueryBuilder = QueryBuilders.idsQuery()
+                .addIds(identifiedExcludedDocs.stream().map(SpecifiedDocument::id).toArray(String[]::new));
+        } else {
+            // Here, we have to create Boolean queries for the _id and _index fields
+            excludedDocsQueryBuilder = QueryBuilders.boolQuery();
+            identifiedExcludedDocs.stream().map(item -> {
+                BoolQueryBuilder excludeQueryBuilder = QueryBuilders.boolQuery()
+                    .must(QueryBuilders.termQuery(IdFieldMapper.NAME, item.id()));
+                if (item.index() != null) {
+                    excludeQueryBuilder.must(QueryBuilders.termQuery(IndexFieldMapper.NAME, item.index()));
+                }
+                return excludeQueryBuilder;
+            }).forEach(excludeQueryBuilder -> ((BoolQueryBuilder) excludedDocsQueryBuilder).must(excludeQueryBuilder));
+        }
+        return excludedDocsQueryBuilder;
+    }
+
     private List<?> truncateList(List<?> input) {
     private List<?> truncateList(List<?> input) {
         // PinnedQueryBuilder will return an error if we attempt to return more than the maximum number of
         // PinnedQueryBuilder will return an error if we attempt to return more than the maximum number of
         // pinned hits. Here, we truncate matching rules rather than return an error.
         // pinned hits. Here, we truncate matching rules rather than return an error.
@@ -285,13 +320,13 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         return Objects.equals(rulesetIds, other.rulesetIds)
         return Objects.equals(rulesetIds, other.rulesetIds)
             && Objects.equals(matchCriteria, other.matchCriteria)
             && Objects.equals(matchCriteria, other.matchCriteria)
             && Objects.equals(organicQuery, other.organicQuery)
             && Objects.equals(organicQuery, other.organicQuery)
-            && Objects.equals(pinnedIdsSupplier, other.pinnedIdsSupplier)
-            && Objects.equals(pinnedDocsSupplier, other.pinnedDocsSupplier);
+            && Objects.equals(pinnedDocsSupplier, other.pinnedDocsSupplier)
+            && Objects.equals(excludedDocsSupplier, other.excludedDocsSupplier);
     }
     }
 
 
     @Override
     @Override
     protected int doHashCode() {
     protected int doHashCode() {
-        return Objects.hash(rulesetIds, matchCriteria, organicQuery, pinnedIdsSupplier, pinnedDocsSupplier);
+        return Objects.hash(rulesetIds, matchCriteria, organicQuery, pinnedDocsSupplier, excludedDocsSupplier);
     }
     }
 
 
     private static final ConstructingObjectParser<RuleQueryBuilder, Void> PARSER = new ConstructingObjectParser<>(
     private static final ConstructingObjectParser<RuleQueryBuilder, Void> PARSER = new ConstructingObjectParser<>(

+ 156 - 9
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/QueryRuleTests.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils;
 import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils;
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
 import org.junit.Before;
 import org.junit.Before;
 
 
 import java.io.IOException;
 import java.io.IOException;
@@ -99,7 +100,22 @@ public class QueryRuleTests extends ESTestCase {
                 "ids": ["id1", "id2"]
                 "ids": ["id1", "id2"]
               }
               }
             }""");
             }""");
-        testToXContentPinnedRules(content);
+        testToXContentRules(content);
+    }
+
+    public void testToXContentValidExcludedRulesWithIds() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "rule_id": "my_query_rule",
+              "type": "exclude",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                "ids": ["id1", "id2"]
+              }
+            }""");
+        testToXContentRules(content);
     }
     }
 
 
     public void testToXContentValidPinnedRulesWithDocs() throws IOException {
     public void testToXContentValidPinnedRulesWithDocs() throws IOException {
@@ -123,10 +139,102 @@ public class QueryRuleTests extends ESTestCase {
                 ]
                 ]
               }
               }
             }""");
             }""");
-        testToXContentPinnedRules(content);
+        testToXContentRules(content);
+    }
+
+    public void testToXContentValidExcludedRulesWithDocs() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "rule_id": "my_query_rule",
+              "type": "exclude",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                "docs": [
+                  {
+                    "_index": "foo",
+                    "_id": "id1"
+                  },
+                  {
+                    "_index": "bar",
+                    "_id": "id2"
+                  }
+                ]
+              }
+            }""");
+        testToXContentRules(content);
+    }
+
+    public void testToXContentValidPinnedAndExcludedRulesWithIds() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "rule_id": "my_pinned_query_rule",
+              "type": "pinned",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                "ids": ["id1", "id2"]
+              }
+            },
+            {
+              "rule_id": "my_exclude_query_rule",
+              "type": "exlude",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["baz"] }
+              ],
+              "actions": {
+                "ids": ["id3", "id4"]
+              }
+            }""");
+        testToXContentRules(content);
     }
     }
 
 
-    private void testToXContentPinnedRules(String content) throws IOException {
+    public void testToXContentValidPinnedAndExcludedRulesWithDocs() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "rule_id": "my_pinned_query_rule",
+              "type": "pinned",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                "docs": [
+                  {
+                    "_index": "foo",
+                    "_id": "id1"
+                  },
+                  {
+                    "_index": "bar",
+                    "_id": "id2"
+                  }
+                ]
+              }
+            },
+            {
+              "rule_id": "my_exclude_query_rule",
+              "type": "exclude",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                "docs": [
+                  {
+                    "_index": "foo",
+                    "_id": "id3"
+                  },
+                  {
+                    "_index": "bar",
+                    "_id": "id4"
+                  }
+                ]
+              }
+            }""");
+        testToXContentRules(content);
+    }
+
+    private void testToXContentRules(String content) throws IOException {
         QueryRule queryRule = QueryRule.fromXContentBytes(new BytesArray(content), XContentType.JSON);
         QueryRule queryRule = QueryRule.fromXContentBytes(new BytesArray(content), XContentType.JSON);
         boolean humanReadable = true;
         boolean humanReadable = true;
         BytesReference originalBytes = toShuffledXContent(queryRule, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
         BytesReference originalBytes = toShuffledXContent(queryRule, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
@@ -152,7 +260,22 @@ public class QueryRuleTests extends ESTestCase {
         expectThrows(IllegalArgumentException.class, () -> QueryRule.fromXContentBytes(new BytesArray(content), XContentType.JSON));
         expectThrows(IllegalArgumentException.class, () -> QueryRule.fromXContentBytes(new BytesArray(content), XContentType.JSON));
     }
     }
 
 
-    public void testApplyRuleWithOneCriteria() {
+    public void testToXContentExcludeRuleWithInvalidActions() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "rule_id": "my_query_rule",
+              "type": "exclude",
+              "criteria": [
+                { "type": "exact", "metadata": "query_string", "values": ["foo", "bar"] }
+              ],
+              "actions": {
+                  "foo": "bar"
+                }
+            }""");
+        expectThrows(IllegalArgumentException.class, () -> QueryRule.fromXContentBytes(new BytesArray(content), XContentType.JSON));
+    }
+
+    public void testApplyPinnedRuleWithOneCriteria() {
         QueryRule rule = new QueryRule(
         QueryRule rule = new QueryRule(
             randomAlphaOfLength(10),
             randomAlphaOfLength(10),
             QueryRule.QueryRuleType.PINNED,
             QueryRule.QueryRuleType.PINNED,
@@ -162,14 +285,35 @@ public class QueryRuleTests extends ESTestCase {
         );
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
-        assertEquals(List.of("id1", "id2"), appliedQueryRules.pinnedIds());
+        assertEquals(List.of(new SpecifiedDocument(null, "id1"), new SpecifiedDocument(null, "id2")), appliedQueryRules.pinnedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
+
+        appliedQueryRules = new AppliedQueryRules();
+        rule.applyRule(appliedQueryRules, Map.of("query", "elastic1"));
+        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
+    }
+
+    public void testApplyExcludeRuleWithOneCriteria() {
+        QueryRule rule = new QueryRule(
+            randomAlphaOfLength(10),
+            QueryRule.QueryRuleType.EXCLUDE,
+            List.of(new QueryRuleCriteria(EXACT, "query", List.of("elastic"))),
+            Map.of("ids", List.of("id1", "id2")),
+            EnterpriseSearchModuleTestUtils.randomQueryRulePriority()
+        );
+        AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
+        rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
+        assertEquals(List.of(new SpecifiedDocument(null, "id1"), new SpecifiedDocument(null, "id2")), appliedQueryRules.excludedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedDocs());
 
 
         appliedQueryRules = new AppliedQueryRules();
         appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic1"));
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic1"));
-        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedIds());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedDocs());
     }
     }
 
 
-    public void testApplyRuleWithMultipleCriteria() {
+    public void testApplyRuleWithMultipleCriteria() throws IOException {
         QueryRule rule = new QueryRule(
         QueryRule rule = new QueryRule(
             randomAlphaOfLength(10),
             randomAlphaOfLength(10),
             QueryRule.QueryRuleType.PINNED,
             QueryRule.QueryRuleType.PINNED,
@@ -179,11 +323,14 @@ public class QueryRuleTests extends ESTestCase {
         );
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic - you know, for search"));
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic - you know, for search"));
-        assertEquals(List.of("id1", "id2"), appliedQueryRules.pinnedIds());
+        assertEquals(List.of(new SpecifiedDocument(null, "id1"), new SpecifiedDocument(null, "id2")), appliedQueryRules.pinnedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
 
 
         appliedQueryRules = new AppliedQueryRules();
         appliedQueryRules = new AppliedQueryRules();
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
         rule.applyRule(appliedQueryRules, Map.of("query", "elastic"));
-        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedIds());
+        assertEquals(Collections.emptyList(), appliedQueryRules.pinnedDocs());
+        assertEquals(Collections.emptyList(), appliedQueryRules.excludedDocs());
     }
     }
 
 
     private void assertXContent(QueryRule queryRule, boolean humanReadable) throws IOException {
     private void assertXContent(QueryRule queryRule, boolean humanReadable) throws IOException {

+ 22 - 18
x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java

@@ -19,7 +19,6 @@ import org.elasticsearch.search.SearchHit;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
 import org.elasticsearch.test.ESIntegTestCase;
 import org.elasticsearch.test.ESIntegTestCase;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
 
 
 import java.util.Collection;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
@@ -90,11 +89,11 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
             int numPromotions = randomIntBetween(0, totalDocs);
             int numPromotions = randomIntBetween(0, totalDocs);
 
 
             LinkedHashSet<String> idPins = new LinkedHashSet<>();
             LinkedHashSet<String> idPins = new LinkedHashSet<>();
-            LinkedHashSet<Item> docPins = new LinkedHashSet<>();
+            LinkedHashSet<SpecifiedDocument> docPins = new LinkedHashSet<>();
             for (int j = 0; j < numPromotions; j++) {
             for (int j = 0; j < numPromotions; j++) {
                 String id = Integer.toString(randomIntBetween(0, totalDocs));
                 String id = Integer.toString(randomIntBetween(0, totalDocs));
                 idPins.add(id);
                 idPins.add(id);
-                docPins.add(new Item("test", id));
+                docPins.add(new SpecifiedDocument("test", id));
             }
             }
             QueryBuilder organicQuery = null;
             QueryBuilder organicQuery = null;
             if (i % 5 == 0) {
             if (i % 5 == 0) {
@@ -105,7 +104,12 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
             }
             }
 
 
             assertPinnedPromotions(new PinnedQueryBuilder(organicQuery, idPins.toArray(new String[0])), idPins, i, numRelevantDocs);
             assertPinnedPromotions(new PinnedQueryBuilder(organicQuery, idPins.toArray(new String[0])), idPins, i, numRelevantDocs);
-            assertPinnedPromotions(new PinnedQueryBuilder(organicQuery, docPins.toArray(new Item[0])), idPins, i, numRelevantDocs);
+            assertPinnedPromotions(
+                new PinnedQueryBuilder(organicQuery, docPins.toArray(new SpecifiedDocument[0])),
+                idPins,
+                i,
+                numRelevantDocs
+            );
         }
         }
 
 
     }
     }
@@ -184,7 +188,7 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         QueryBuilder organicQuery = QueryBuilders.queryStringQuery("foo");
         QueryBuilder organicQuery = QueryBuilders.queryStringQuery("foo");
         assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, "2"));
         assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, "2"));
-        assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, new Item("test", "2")));
+        assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, new SpecifiedDocument("test", "2")));
     }
     }
 
 
     private void assertExhaustiveScoring(PinnedQueryBuilder pqb) {
     private void assertExhaustiveScoring(PinnedQueryBuilder pqb) {
@@ -218,7 +222,7 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         assertExplain(new PinnedQueryBuilder(organicQuery, "2"));
         assertExplain(new PinnedQueryBuilder(organicQuery, "2"));
-        assertExplain(new PinnedQueryBuilder(organicQuery, new Item("test", "2")));
+        assertExplain(new PinnedQueryBuilder(organicQuery, new SpecifiedDocument("test", "2")));
     }
     }
 
 
     private void assertExplain(PinnedQueryBuilder pqb) {
     private void assertExplain(PinnedQueryBuilder pqb) {
@@ -259,7 +263,7 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         assertHighlight(new PinnedQueryBuilder(organicQuery, "2"));
         assertHighlight(new PinnedQueryBuilder(organicQuery, "2"));
-        assertHighlight(new PinnedQueryBuilder(organicQuery, new Item("test", "2")));
+        assertHighlight(new PinnedQueryBuilder(organicQuery, new SpecifiedDocument("test", "2")));
     }
     }
 
 
     private void assertHighlight(PinnedQueryBuilder pqb) {
     private void assertHighlight(PinnedQueryBuilder pqb) {
@@ -320,9 +324,9 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             QueryBuilders.queryStringQuery("foo"),
             QueryBuilders.queryStringQuery("foo"),
-            new Item("test2", "a"),
-            new Item("test1", "a"),
-            new Item("test1", "b")
+            new SpecifiedDocument("test2", "a"),
+            new SpecifiedDocument("test1", "a"),
+            new SpecifiedDocument("test1", "b")
         );
         );
 
 
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {
@@ -360,9 +364,9 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             QueryBuilders.queryStringQuery("document"),
             QueryBuilders.queryStringQuery("document"),
-            new Item("test", "b"),
-            new Item("test-alias", "a"),
-            new Item("test", "a")
+            new SpecifiedDocument("test", "b"),
+            new SpecifiedDocument("test-alias", "a"),
+            new SpecifiedDocument("test", "a")
         );
         );
 
 
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {
@@ -416,11 +420,11 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             QueryBuilders.queryStringQuery("document"),
             QueryBuilders.queryStringQuery("document"),
-            new Item("test1", "b"),
-            new Item(null, "a"),
-            new Item("test1", "c"),
-            new Item("test1", "a"),
-            new Item("test-alias", "a")
+            new SpecifiedDocument("test1", "b"),
+            new SpecifiedDocument(null, "a"),
+            new SpecifiedDocument("test1", "c"),
+            new SpecifiedDocument("test1", "a"),
+            new SpecifiedDocument("test-alias", "a")
         );
         );
 
 
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {
         assertResponse(prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSearchType(DFS_QUERY_THEN_FETCH), searchResponse -> {

+ 19 - 127
x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java

@@ -16,11 +16,8 @@ import org.apache.lucene.util.NumericUtils;
 import org.elasticsearch.TransportVersion;
 import org.elasticsearch.TransportVersion;
 import org.elasticsearch.TransportVersions;
 import org.elasticsearch.TransportVersions;
 import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.common.ParsingException;
-import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.io.stream.Writeable;
-import org.elasticsearch.common.regex.Regex;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.MappedFieldType;
 import org.elasticsearch.index.mapper.MappedFieldType;
 import org.elasticsearch.index.query.AbstractQueryBuilder;
 import org.elasticsearch.index.query.AbstractQueryBuilder;
@@ -29,7 +26,6 @@ import org.elasticsearch.index.query.QueryRewriteContext;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.xcontent.ConstructingObjectParser;
 import org.elasticsearch.xcontent.ConstructingObjectParser;
 import org.elasticsearch.xcontent.ParseField;
 import org.elasticsearch.xcontent.ParseField;
-import org.elasticsearch.xcontent.ToXContentObject;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 
 
@@ -57,132 +53,26 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
     public static final ParseField DOCS_FIELD = new ParseField("docs");
     public static final ParseField DOCS_FIELD = new ParseField("docs");
     public static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic");
     public static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic");
 
 
-    private static final TransportVersion OPTIONAL_INDEX_IN_DOCS_VERSION = TransportVersions.V_8_11_X;
-
     private final List<String> ids;
     private final List<String> ids;
-    private final List<Item> docs;
+    private final List<SpecifiedDocument> docs;
     private QueryBuilder organicQuery;
     private QueryBuilder organicQuery;
 
 
     // Organic queries will have their scores capped to this number range,
     // Organic queries will have their scores capped to this number range,
     // We reserve the highest float exponent for scores of pinned queries
     // We reserve the highest float exponent for scores of pinned queries
     private static final float MAX_ORGANIC_SCORE = Float.intBitsToFloat((0xfe << 23)) - 1;
     private static final float MAX_ORGANIC_SCORE = Float.intBitsToFloat((0xfe << 23)) - 1;
 
 
-    /**
-     * A single item to be used for a {@link PinnedQueryBuilder}.
-     */
-    public static final class Item implements ToXContentObject, Writeable {
-        public static final String NAME = "item";
-
-        public static final ParseField INDEX_FIELD = new ParseField("_index");
-        public static final ParseField ID_FIELD = new ParseField("_id");
-
-        private final String index;
-        private final String id;
-
-        /**
-         * Constructor for a given item request
-         *
-         * @param index the index where the document is located
-         * @param id and its id
-         */
-        public Item(String index, String id) {
-            if (index != null && Regex.isSimpleMatchPattern(index)) {
-                throw new IllegalArgumentException("Item index cannot contain wildcard expressions");
-            }
-            if (id == null) {
-                throw new IllegalArgumentException("Item requires id to be non-null");
-            }
-            this.index = index;
-            this.id = id;
-        }
-
-        private Item(String id) {
-            this.index = null;
-            this.id = id;
-        }
-
-        /**
-         * Read from a stream.
-         */
-        public Item(StreamInput in) throws IOException {
-            if (in.getTransportVersion().onOrAfter(OPTIONAL_INDEX_IN_DOCS_VERSION)) {
-                index = in.readOptionalString();
-            } else {
-                index = in.readString();
-            }
-            id = in.readString();
-        }
-
-        @Override
-        public void writeTo(StreamOutput out) throws IOException {
-            if (out.getTransportVersion().onOrAfter(OPTIONAL_INDEX_IN_DOCS_VERSION)) {
-                out.writeOptionalString(index);
-            } else {
-                if (index == null) {
-                    throw new IllegalArgumentException(
-                        "[_index] needs to be specified for docs elements when cluster nodes are not in the same version"
-                    );
-                }
-                out.writeString(index);
-            }
-            out.writeString(id);
-        }
-
-        @Override
-        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
-            builder.startObject();
-            if (this.index != null) {
-                builder.field(INDEX_FIELD.getPreferredName(), this.index);
-            }
-            builder.field(ID_FIELD.getPreferredName(), this.id);
-            return builder.endObject();
-        }
-
-        private static final ConstructingObjectParser<Item, Void> PARSER = new ConstructingObjectParser<>(
-            NAME,
-            a -> new Item((String) a[0], (String) a[1])
-        );
-
-        static {
-            PARSER.declareString(optionalConstructorArg(), INDEX_FIELD);
-            PARSER.declareString(constructorArg(), ID_FIELD);
-        }
-
-        @Override
-        public String toString() {
-            return Strings.toString(this, true, true);
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hash(index, id);
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) {
-                return true;
-            }
-            if ((o instanceof Item) == false) {
-                return false;
-            }
-            Item other = (Item) o;
-            return Objects.equals(index, other.index) && Objects.equals(id, other.id);
-        }
-    }
-
     public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) {
     public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) {
         this(organicQuery, Arrays.asList(ids), null);
         this(organicQuery, Arrays.asList(ids), null);
     }
     }
 
 
-    public PinnedQueryBuilder(QueryBuilder organicQuery, Item... docs) {
+    public PinnedQueryBuilder(QueryBuilder organicQuery, SpecifiedDocument... docs) {
         this(organicQuery, null, Arrays.asList(docs));
         this(organicQuery, null, Arrays.asList(docs));
     }
     }
 
 
     /**
     /**
      * Creates a new PinnedQueryBuilder
      * Creates a new PinnedQueryBuilder
      */
      */
-    private PinnedQueryBuilder(QueryBuilder organicQuery, List<String> ids, List<Item> docs) {
+    private PinnedQueryBuilder(QueryBuilder organicQuery, List<String> ids, List<SpecifiedDocument> docs) {
         if (organicQuery == null) {
         if (organicQuery == null) {
             throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null");
             throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null");
         }
         }
@@ -215,8 +105,8 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
                     "[" + NAME + "] Max of " + MAX_NUM_PINNED_HITS + " docs exceeded: " + docs.size() + " provided."
                     "[" + NAME + "] Max of " + MAX_NUM_PINNED_HITS + " docs exceeded: " + docs.size() + " provided."
                 );
                 );
             }
             }
-            LinkedHashSet<Item> deduped = new LinkedHashSet<>();
-            for (Item doc : docs) {
+            LinkedHashSet<SpecifiedDocument> deduped = new LinkedHashSet<>();
+            for (SpecifiedDocument doc : docs) {
                 if (doc == null) {
                 if (doc == null) {
                     throw new IllegalArgumentException("[" + NAME + "] doc cannot be null");
                     throw new IllegalArgumentException("[" + NAME + "] doc cannot be null");
                 }
                 }
@@ -239,7 +129,7 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
             docs = null;
             docs = null;
         } else {
         } else {
             ids = in.readOptionalStringCollectionAsList();
             ids = in.readOptionalStringCollectionAsList();
-            docs = in.readBoolean() ? in.readCollectionAsList(Item::new) : null;
+            docs = in.readBoolean() ? in.readCollectionAsList(SpecifiedDocument::new) : null;
         }
         }
         organicQuery = in.readNamedWriteable(QueryBuilder.class);
         organicQuery = in.readNamedWriteable(QueryBuilder.class);
     }
     }
@@ -280,7 +170,7 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
     /**
     /**
      * @return the pinned docs for the query.
      * @return the pinned docs for the query.
      */
      */
-    public List<Item> docs() {
+    public List<SpecifiedDocument> docs() {
         if (this.docs == null) {
         if (this.docs == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
@@ -303,8 +193,8 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         }
         }
         if (docs != null) {
         if (docs != null) {
             builder.startArray(DOCS_FIELD.getPreferredName());
             builder.startArray(DOCS_FIELD.getPreferredName());
-            for (Item item : docs) {
-                builder.value(item);
+            for (SpecifiedDocument specifiedDocument : docs) {
+                builder.value(specifiedDocument);
             }
             }
             builder.endArray();
             builder.endArray();
         }
         }
@@ -317,13 +207,13 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         @SuppressWarnings("unchecked")
         @SuppressWarnings("unchecked")
         List<String> ids = (List<String>) a[1];
         List<String> ids = (List<String>) a[1];
         @SuppressWarnings("unchecked")
         @SuppressWarnings("unchecked")
-        List<Item> docs = (List<Item>) a[2];
+        List<SpecifiedDocument> docs = (List<SpecifiedDocument>) a[2];
         return new PinnedQueryBuilder(organicQuery, ids, docs);
         return new PinnedQueryBuilder(organicQuery, ids, docs);
     });
     });
     static {
     static {
         PARSER.declareObject(constructorArg(), (p, c) -> parseInnerQueryBuilder(p), ORGANIC_QUERY_FIELD);
         PARSER.declareObject(constructorArg(), (p, c) -> parseInnerQueryBuilder(p), ORGANIC_QUERY_FIELD);
         PARSER.declareStringArray(optionalConstructorArg(), IDS_FIELD);
         PARSER.declareStringArray(optionalConstructorArg(), IDS_FIELD);
-        PARSER.declareObjectArray(optionalConstructorArg(), Item.PARSER, DOCS_FIELD);
+        PARSER.declareObjectArray(optionalConstructorArg(), SpecifiedDocument.PARSER, DOCS_FIELD);
         declareStandardFields(PARSER);
         declareStandardFields(PARSER);
     }
     }
 
 
@@ -357,24 +247,26 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         if (idField == null) {
         if (idField == null) {
             return new MatchNoDocsQuery("No mappings");
             return new MatchNoDocsQuery("No mappings");
         }
         }
-        List<Item> items = (docs != null) ? docs : ids.stream().map(id -> new Item(id)).toList();
-        if (items.isEmpty()) {
+        List<SpecifiedDocument> specifiedDocuments = (docs != null)
+            ? docs
+            : ids.stream().map(id -> new SpecifiedDocument(null, id)).toList();
+        if (specifiedDocuments.isEmpty()) {
             return new CappedScoreQuery(organicQuery.toQuery(context), MAX_ORGANIC_SCORE);
             return new CappedScoreQuery(organicQuery.toQuery(context), MAX_ORGANIC_SCORE);
         } else {
         } else {
             List<Query> pinnedQueries = new ArrayList<>();
             List<Query> pinnedQueries = new ArrayList<>();
 
 
             // Ensure each pin order using a Boost query with the relevant boost factor
             // Ensure each pin order using a Boost query with the relevant boost factor
             int minPin = NumericUtils.floatToSortableInt(MAX_ORGANIC_SCORE) + 1;
             int minPin = NumericUtils.floatToSortableInt(MAX_ORGANIC_SCORE) + 1;
-            int boostNum = minPin + items.size();
+            int boostNum = minPin + specifiedDocuments.size();
             float lastScore = Float.MAX_VALUE;
             float lastScore = Float.MAX_VALUE;
-            for (Item item : items) {
+            for (SpecifiedDocument specifiedDocument : specifiedDocuments) {
                 float pinScore = NumericUtils.sortableIntToFloat(boostNum);
                 float pinScore = NumericUtils.sortableIntToFloat(boostNum);
                 assert pinScore < lastScore;
                 assert pinScore < lastScore;
                 lastScore = pinScore;
                 lastScore = pinScore;
                 boostNum--;
                 boostNum--;
-                if (item.index == null || context.indexMatches(item.index)) {
+                if (specifiedDocument.index() == null || context.indexMatches(specifiedDocument.index())) {
                     // Ensure the pin order using a Boost query with the relevant boost factor
                     // Ensure the pin order using a Boost query with the relevant boost factor
-                    Query idQuery = new BoostQuery(new ConstantScoreQuery(idField.termQuery(item.id, context)), pinScore);
+                    Query idQuery = new BoostQuery(new ConstantScoreQuery(idField.termQuery(specifiedDocument.id(), context)), pinScore);
                     pinnedQueries.add(idQuery);
                     pinnedQueries.add(idQuery);
                 }
                 }
             }
             }

+ 136 - 0
x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/SpecifiedDocument.java

@@ -0,0 +1,136 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.searchbusinessrules;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.TransportVersions;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.regex.Regex;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * A single specified document to be used for a {@link PinnedQueryBuilder} or query rules.
+ */
+public final class SpecifiedDocument implements ToXContentObject, Writeable {
+
+    public static final TransportVersion OPTIONAL_INDEX_IN_DOCS_VERSION = TransportVersions.V_8_11_X;
+
+    public static final String NAME = "specified_document";
+
+    public static final ParseField INDEX_FIELD = new ParseField("_index");
+    public static final ParseField ID_FIELD = new ParseField("_id");
+
+    private final String index;
+    private final String id;
+
+    /**
+     * Constructor for a given specified document request
+     *
+     * @param index the index where the document is located
+     * @param id and its id
+     */
+    public SpecifiedDocument(String index, String id) {
+        if (index != null && Regex.isSimpleMatchPattern(index)) {
+            throw new IllegalArgumentException("Specified document index cannot contain wildcard expressions");
+        }
+        if (id == null) {
+            throw new IllegalArgumentException("Specified document requires id to be non-null");
+        }
+        this.index = index;
+        this.id = id;
+    }
+
+    /**
+     * Read from a stream.
+     */
+    public SpecifiedDocument(StreamInput in) throws IOException {
+        if (in.getTransportVersion().onOrAfter(OPTIONAL_INDEX_IN_DOCS_VERSION)) {
+            index = in.readOptionalString();
+        } else {
+            index = in.readString();
+        }
+        id = in.readString();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        if (out.getTransportVersion().onOrAfter(OPTIONAL_INDEX_IN_DOCS_VERSION)) {
+            out.writeOptionalString(index);
+        } else {
+            if (index == null) {
+                throw new IllegalArgumentException(
+                    "[_index] needs to be specified for docs elements when cluster nodes are not in the same version"
+                );
+            }
+            out.writeString(index);
+        }
+        out.writeString(id);
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        if (this.index != null) {
+            builder.field(INDEX_FIELD.getPreferredName(), this.index);
+        }
+        builder.field(ID_FIELD.getPreferredName(), this.id);
+        return builder.endObject();
+    }
+
+    static final ConstructingObjectParser<SpecifiedDocument, Void> PARSER = new ConstructingObjectParser<>(
+        NAME,
+        a -> new SpecifiedDocument((String) a[0], (String) a[1])
+    );
+
+    static {
+        PARSER.declareString(optionalConstructorArg(), INDEX_FIELD);
+        PARSER.declareString(constructorArg(), ID_FIELD);
+    }
+
+    @Override
+    public String toString() {
+        return Strings.toString(this, true, true);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(index, id);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if ((o instanceof SpecifiedDocument) == false) {
+            return false;
+        }
+        SpecifiedDocument other = (SpecifiedDocument) o;
+        return Objects.equals(index, other.index) && Objects.equals(id, other.id);
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public String index() {
+        return index;
+    }
+}

+ 29 - 16
x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java

@@ -21,7 +21,6 @@ import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.json.JsonStringEncoder;
 import org.elasticsearch.xcontent.json.JsonStringEncoder;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -91,8 +90,13 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
         return new TermQueryBuilder(fieldName, value);
         return new TermQueryBuilder(fieldName, value);
     }
     }
 
 
-    private static Item[] generateRandomItems() {
-        return randomArray(1, 100, Item[]::new, () -> new Item(randomBoolean() ? null : randomAlphaOfLength(64), randomAlphaOfLength(256)));
+    private static SpecifiedDocument[] generateRandomItems() {
+        return randomArray(
+            1,
+            100,
+            SpecifiedDocument[]::new,
+            () -> new SpecifiedDocument(randomBoolean() ? null : randomAlphaOfLength(64), randomAlphaOfLength(256))
+        );
     }
     }
 
 
     @Override
     @Override
@@ -121,24 +125,29 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (String) null));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (String) null));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, "1"));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, "1"));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), "1", null, "2"));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), "1", null, "2"));
+        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (SpecifiedDocument) null));
+        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, new SpecifiedDocument("test", "1")));
         expectThrows(
         expectThrows(
             IllegalArgumentException.class,
             IllegalArgumentException.class,
-            () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (PinnedQueryBuilder.Item) null)
+            () -> new PinnedQueryBuilder(
+                new MatchAllQueryBuilder(),
+                new SpecifiedDocument("test", "1"),
+                null,
+                new SpecifiedDocument("test", "2")
+            )
         );
         );
-        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, new Item("test", "1")));
         expectThrows(
         expectThrows(
             IllegalArgumentException.class,
             IllegalArgumentException.class,
-            () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), new Item("test", "1"), null, new Item("test", "2"))
+            () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), new SpecifiedDocument("test*", "1"))
         );
         );
-        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), new Item("test*", "1")));
         String[] bigIdList = new String[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1];
         String[] bigIdList = new String[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1];
-        Item[] bigItemList = new Item[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1];
+        SpecifiedDocument[] bigSpecifiedDocumentList = new SpecifiedDocument[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1];
         for (int i = 0; i < bigIdList.length; i++) {
         for (int i = 0; i < bigIdList.length; i++) {
             bigIdList[i] = String.valueOf(i);
             bigIdList[i] = String.valueOf(i);
-            bigItemList[i] = new Item("test", String.valueOf(i));
+            bigSpecifiedDocumentList[i] = new SpecifiedDocument("test", String.valueOf(i));
         }
         }
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigIdList));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigIdList));
-        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigItemList));
+        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigSpecifiedDocumentList));
 
 
     }
     }
 
 
@@ -214,7 +223,7 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
     }
     }
 
 
     public void testDocsRewrite() throws IOException {
     public void testDocsRewrite() throws IOException {
-        PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("foo", 1), new Item("test", "1"));
+        PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("foo", 1), new SpecifiedDocument("test", "1"));
         QueryBuilder rewritten = pinnedQueryBuilder.rewrite(createSearchExecutionContext());
         QueryBuilder rewritten = pinnedQueryBuilder.rewrite(createSearchExecutionContext());
         assertThat(rewritten, instanceOf(PinnedQueryBuilder.class));
         assertThat(rewritten, instanceOf(PinnedQueryBuilder.class));
     }
     }
@@ -239,12 +248,16 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
     }
     }
 
 
     public void testDocInsertionOrderRetained() {
     public void testDocInsertionOrderRetained() {
-        Item[] items = randomArray(10, Item[]::new, () -> new Item(randomAlphaOfLength(64), randomAlphaOfLength(256)));
-        PinnedQueryBuilder pqb = new PinnedQueryBuilder(new MatchAllQueryBuilder(), items);
-        List<Item> addedDocs = pqb.docs();
+        SpecifiedDocument[] specifiedDocuments = randomArray(
+            10,
+            SpecifiedDocument[]::new,
+            () -> new SpecifiedDocument(randomAlphaOfLength(64), randomAlphaOfLength(256))
+        );
+        PinnedQueryBuilder pqb = new PinnedQueryBuilder(new MatchAllQueryBuilder(), specifiedDocuments);
+        List<SpecifiedDocument> addedDocs = pqb.docs();
         int pos = 0;
         int pos = 0;
-        for (Item item : addedDocs) {
-            assertEquals(items[pos++], item);
+        for (SpecifiedDocument specifiedDocument : addedDocs) {
+            assertEquals(specifiedDocuments[pos++], specifiedDocument);
         }
         }
     }
     }
 }
 }