Bläddra i källkod

[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 år sedan
förälder
incheckning
02c494963a
17 ändrade filer med 818 tillägg och 297 borttagningar
  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.
-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.
 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]
 ====

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

@@ -26,7 +26,10 @@ Requires the `manage_search_query_rules` privilege.
 
 `type`::
 (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`::
 (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.
 
 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.
-- `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.
+There is a maximum value of 100 documents in a rule.
 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>>.
 --
 
@@ -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`.
 
-`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]
 ----

+ 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.
 - `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.
 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.
@@ -84,13 +84,13 @@ Only one value must match for the criteria to be met.
 Required for all criteria types except `always`.
 
 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.
-- `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.
+There is a maximum value of 100 documents in a rule.
 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>>.
 --
 
-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.
 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.
@@ -111,7 +111,7 @@ The following example creates a new query ruleset called `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-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]
 ----
@@ -142,7 +142,7 @@ PUT _query_rules/my-ruleset
         },
         {
             "rule_id": "my-rule2",
-            "type": "pinned",
+            "type": "exclude",
             "criteria": [
                 {
                     "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
 
 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.
+* `exclude` will exclude specified results from the returned result set.
 
 [discrete]
 [[query-rule-criteria]]
@@ -91,12 +92,11 @@ Allowed criteria types are:
 
 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.
 `ids` and `docs` cannot be combined in the same query.
-See <<query-dsl-pinned-query,pinned query>> for details.
 
 [discrete]
 [[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.
 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 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]
@@ -147,7 +147,7 @@ PUT /_query_rules/my-ruleset
     },
     {
       "rule_id": "rule2",
-      "type": "pinned",
+      "type": "exclude",
       "criteria": [
         {
           "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.
 
 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
 - 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'
                   - '_index': 'test-index2'
                     '_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' }
 
@@ -75,6 +87,18 @@ teardown:
                 '_id': 'id3'
               - '_index': 'test-index2'
                 '_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':

+ 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:
                   - 'id7'
                   - 'id8'
+            - rule_id: query-rule-id5
+              type: exclude
+              criteria:
+                - type: fuzzy
+                  metadata: query_string
+                  values: [ inference ]
+              actions:
+                ids:
+                  - 'id9'
+                  - 'id10'
 ---
 teardown:
   - do:
@@ -144,8 +154,8 @@ teardown:
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
 
   - 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.rule_total_count: 2 }
@@ -161,8 +171,8 @@ teardown:
 
   # Alphabetical order by ruleset_id for results
   - 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.rule_total_count: 2 }
@@ -182,8 +192,8 @@ teardown:
   - match: { results.0.rule_criteria_types_counts: { exact: 3 } }
 
   - 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":

+ 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:
               number_of_shards: 1
               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:
       bulk:
@@ -38,6 +51,22 @@ setup:
           - index:
               _id: doc7
           - { "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:
       query_rules.put_ruleset:
@@ -82,6 +111,15 @@ setup:
               actions:
                 ids:
                   - 'doc7'
+            - rule_id: rule5
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'doc8'
 
   - do:
       query_rules.put_ruleset:
@@ -115,6 +153,21 @@ teardown:
         ruleset_id: combined-ruleset
         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":
   - do:
@@ -165,7 +218,7 @@ teardown:
                 foo: bar
 
 ---
-"Perform a rule query with malformed rule":
+"Perform a search with malformed rule query":
   - do:
       catch: bad_request
       search:
@@ -184,6 +237,7 @@ teardown:
 
   - do:
       search:
+        index: test-index1
         body:
           query:
             rule:
@@ -208,6 +262,7 @@ teardown:
   - do:
       headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
       search:
+        index: test-index1
         body:
           query:
             rule:
@@ -294,6 +349,7 @@ teardown:
 
   - do:
       search:
+        index: test-index1
         body:
           query:
             rule:
@@ -310,6 +366,190 @@ teardown:
   - match: { hits.hits.0._id: 'doc2' }
   - 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":
 
@@ -509,6 +749,7 @@ teardown:
 
   - do:
       search:
+        index: test-index1
         body:
           query:
             rule:
@@ -634,6 +875,7 @@ teardown:
 
   - do:
       search:
+        index: test-index1
         body:
           query:
             rule_query:
@@ -651,4 +893,3 @@ teardown:
   - match: { hits.total.value: 2 }
   - match: { hits.hits.0._id: 'doc1' }
   - 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'
                   - 'id2'
             - rule_id: query-rule-id2
-              type: pinned
+              type: exclude
               criteria:
                 - type: exact
                   metadata: query_string
@@ -122,7 +122,7 @@ teardown:
   - match:
       rules:
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
             - type: exact
               metadata: query_string
@@ -169,7 +169,7 @@ teardown:
   - match:
       rules:
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
             - type: exact
               metadata: query_string
@@ -225,7 +225,7 @@ teardown:
   - match:
       rules:
         - rule_id: query-rule-id2
-          type: pinned
+          type: exclude
           criteria:
             - type: exact
               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;
 
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
+
 import java.util.ArrayList;
 import java.util.List;
 
-import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
-
 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() {
         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.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.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentType;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder;
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -31,14 +31,11 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 
 import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
 import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
 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:
@@ -51,6 +48,11 @@ import static org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.MAX
  */
 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 QueryRuleType type;
     private final List<QueryRuleCriteria> criteria;
@@ -61,6 +63,7 @@ public class QueryRule implements Writeable, ToXContentObject {
     public static final int MAX_PRIORITY = 1000000;
 
     public enum QueryRuleType {
+        EXCLUDE,
         PINNED;
 
         public static QueryRuleType queryRuleType(String type) {
@@ -137,32 +140,31 @@ public class QueryRule implements Writeable, ToXContentObject {
     }
 
     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)) {
             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 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()) {
-                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);
     }
 
-    @SuppressWarnings("unchecked")
     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;
 
         // 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 (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())) {
                 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 -> new PinnedQueryBuilder.Item(
+                        map -> new SpecifiedDocument(
                             map.get(INDEX_FIELD.getPreferredName()),
-                            map.get(PinnedQueryBuilder.Item.ID_FIELD.getPreferredName())
+                            map.get(SpecifiedDocument.ID_FIELD.getPreferredName())
                         )
                     )
                     .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.StreamOutput;
 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.BoolQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.index.query.QueryRewriteContext;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.license.LicenseUtils;
@@ -32,7 +36,7 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 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.util.List;
@@ -68,8 +72,8 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
     private final Map<String, Object> matchCriteria;
     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
     public TransportVersion getMinimalSupportedVersion() {
@@ -89,18 +93,18 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         } else {
             rulesetIds = List.of(in.readString());
             in.readOptionalStringCollectionAsList();
-            in.readOptionalCollectionAsList(Item::new);
+            in.readOptionalCollectionAsList(SpecifiedDocument::new);
         }
-        pinnedIdsSupplier = null;
         pinnedDocsSupplier = null;
+        excludedDocsSupplier = null;
     }
 
     private RuleQueryBuilder(
         QueryBuilder organicQuery,
         Map<String, Object> matchCriteria,
         List<String> rulesetIds,
-        Supplier<List<String>> pinnedIdsSupplier,
-        Supplier<List<Item>> pinnedDocsSupplier
+        Supplier<List<SpecifiedDocument>> pinnedDocsSupplier,
+        Supplier<List<SpecifiedDocument>> excludedDocsSupplier
 
     ) {
         if (organicQuery == null) {
@@ -124,18 +128,18 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         this.organicQuery = organicQuery;
         this.matchCriteria = matchCriteria;
         this.rulesetIds = rulesetIds;
-        this.pinnedIdsSupplier = pinnedIdsSupplier;
         this.pinnedDocsSupplier = pinnedDocsSupplier;
+        this.excludedDocsSupplier = excludedDocsSupplier;
     }
 
     @Override
     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) {
             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.writeGenericMap(matchCriteria);
@@ -176,18 +180,11 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
     @Override
     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
-        // 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.
-        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);
         } else {
             return organicQuery.toQuery(context);
@@ -196,26 +193,43 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
 
     @Override
     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 {
-                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();
 
         // 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());
+                    excludedDocsSetOnce.set(appliedRules.excludedDocs().stream().distinct().toList());
                     listener.onResponse(null);
 
                 }, 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
         ).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) {
         // 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.
@@ -285,13 +320,13 @@ public class RuleQueryBuilder extends AbstractQueryBuilder<RuleQueryBuilder> {
         return Objects.equals(rulesetIds, other.rulesetIds)
             && Objects.equals(matchCriteria, other.matchCriteria)
             && 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
     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<>(

+ 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.XContentType;
 import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils;
+import org.elasticsearch.xpack.searchbusinessrules.SpecifiedDocument;
 import org.junit.Before;
 
 import java.io.IOException;
@@ -99,7 +100,22 @@ public class QueryRuleTests extends ESTestCase {
                 "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 {
@@ -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);
         boolean humanReadable = true;
         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));
     }
 
-    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(
             randomAlphaOfLength(10),
             QueryRule.QueryRuleType.PINNED,
@@ -162,14 +285,35 @@ public class QueryRuleTests extends ESTestCase {
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         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();
         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(
             randomAlphaOfLength(10),
             QueryRule.QueryRuleType.PINNED,
@@ -179,11 +323,14 @@ public class QueryRuleTests extends ESTestCase {
         );
         AppliedQueryRules appliedQueryRules = new AppliedQueryRules();
         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();
         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 {

+ 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.HighlightField;
 import org.elasticsearch.test.ESIntegTestCase;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -90,11 +89,11 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
             int numPromotions = randomIntBetween(0, totalDocs);
 
             LinkedHashSet<String> idPins = new LinkedHashSet<>();
-            LinkedHashSet<Item> docPins = new LinkedHashSet<>();
+            LinkedHashSet<SpecifiedDocument> docPins = new LinkedHashSet<>();
             for (int j = 0; j < numPromotions; j++) {
                 String id = Integer.toString(randomIntBetween(0, totalDocs));
                 idPins.add(id);
-                docPins.add(new Item("test", id));
+                docPins.add(new SpecifiedDocument("test", id));
             }
             QueryBuilder organicQuery = null;
             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, 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");
         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) {
@@ -218,7 +222,7 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         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) {
@@ -259,7 +263,7 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
         QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR);
         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) {
@@ -320,9 +324,9 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             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 -> {
@@ -360,9 +364,9 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             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 -> {
@@ -416,11 +420,11 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase {
 
         PinnedQueryBuilder pqb = new PinnedQueryBuilder(
             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 -> {

+ 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.TransportVersions;
 import org.elasticsearch.common.ParsingException;
-import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.io.stream.Writeable;
-import org.elasticsearch.common.regex.Regex;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.MappedFieldType;
 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.xcontent.ConstructingObjectParser;
 import org.elasticsearch.xcontent.ParseField;
-import org.elasticsearch.xcontent.ToXContentObject;
 import org.elasticsearch.xcontent.XContentBuilder;
 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 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<Item> docs;
+    private final List<SpecifiedDocument> docs;
     private QueryBuilder organicQuery;
 
     // Organic queries will have their scores capped to this number range,
     // We reserve the highest float exponent for scores of pinned queries
     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) {
         this(organicQuery, Arrays.asList(ids), null);
     }
 
-    public PinnedQueryBuilder(QueryBuilder organicQuery, Item... docs) {
+    public PinnedQueryBuilder(QueryBuilder organicQuery, SpecifiedDocument... docs) {
         this(organicQuery, null, Arrays.asList(docs));
     }
 
     /**
      * 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) {
             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."
                 );
             }
-            LinkedHashSet<Item> deduped = new LinkedHashSet<>();
-            for (Item doc : docs) {
+            LinkedHashSet<SpecifiedDocument> deduped = new LinkedHashSet<>();
+            for (SpecifiedDocument doc : docs) {
                 if (doc == null) {
                     throw new IllegalArgumentException("[" + NAME + "] doc cannot be null");
                 }
@@ -239,7 +129,7 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
             docs = null;
         } else {
             ids = in.readOptionalStringCollectionAsList();
-            docs = in.readBoolean() ? in.readCollectionAsList(Item::new) : null;
+            docs = in.readBoolean() ? in.readCollectionAsList(SpecifiedDocument::new) : null;
         }
         organicQuery = in.readNamedWriteable(QueryBuilder.class);
     }
@@ -280,7 +170,7 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
     /**
      * @return the pinned docs for the query.
      */
-    public List<Item> docs() {
+    public List<SpecifiedDocument> docs() {
         if (this.docs == null) {
             return Collections.emptyList();
         }
@@ -303,8 +193,8 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         }
         if (docs != null) {
             builder.startArray(DOCS_FIELD.getPreferredName());
-            for (Item item : docs) {
-                builder.value(item);
+            for (SpecifiedDocument specifiedDocument : docs) {
+                builder.value(specifiedDocument);
             }
             builder.endArray();
         }
@@ -317,13 +207,13 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         @SuppressWarnings("unchecked")
         List<String> ids = (List<String>) a[1];
         @SuppressWarnings("unchecked")
-        List<Item> docs = (List<Item>) a[2];
+        List<SpecifiedDocument> docs = (List<SpecifiedDocument>) a[2];
         return new PinnedQueryBuilder(organicQuery, ids, docs);
     });
     static {
         PARSER.declareObject(constructorArg(), (p, c) -> parseInnerQueryBuilder(p), ORGANIC_QUERY_FIELD);
         PARSER.declareStringArray(optionalConstructorArg(), IDS_FIELD);
-        PARSER.declareObjectArray(optionalConstructorArg(), Item.PARSER, DOCS_FIELD);
+        PARSER.declareObjectArray(optionalConstructorArg(), SpecifiedDocument.PARSER, DOCS_FIELD);
         declareStandardFields(PARSER);
     }
 
@@ -357,24 +247,26 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
         if (idField == null) {
             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);
         } else {
             List<Query> pinnedQueries = new ArrayList<>();
 
             // Ensure each pin order using a Boost query with the relevant boost factor
             int minPin = NumericUtils.floatToSortableInt(MAX_ORGANIC_SCORE) + 1;
-            int boostNum = minPin + items.size();
+            int boostNum = minPin + specifiedDocuments.size();
             float lastScore = Float.MAX_VALUE;
-            for (Item item : items) {
+            for (SpecifiedDocument specifiedDocument : specifiedDocuments) {
                 float pinScore = NumericUtils.sortableIntToFloat(boostNum);
                 assert pinScore < lastScore;
                 lastScore = pinScore;
                 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
-                    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);
                 }
             }

+ 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.XContentType;
 import org.elasticsearch.xcontent.json.JsonStringEncoder;
-import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -91,8 +90,13 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
         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
@@ -121,24 +125,29 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (String) null));
         expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, "1"));
         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(
             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(
             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];
-        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++) {
             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(), bigItemList));
+        expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigSpecifiedDocumentList));
 
     }
 
@@ -214,7 +223,7 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
     }
 
     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());
         assertThat(rewritten, instanceOf(PinnedQueryBuilder.class));
     }
@@ -239,12 +248,16 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase<PinnedQueryBu
     }
 
     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;
-        for (Item item : addedDocs) {
-            assertEquals(items[pos++], item);
+        for (SpecifiedDocument specifiedDocument : addedDocs) {
+            assertEquals(specifiedDocuments[pos++], specifiedDocument);
         }
     }
 }