Skip to content

Commit f0dbc68

Browse files
authored
Search enhancement: pinned queries (#44345)
Search enhancement: - new query type allows selected documents to be promoted above any "organic” search results. This is the first feature in a new module `search-business-rules` which will house licensed (non OSS) logic for rewriting queries according to business rules. The PinnedQueryBuilder class offers a new `pinned` query in the DSL that takes an array of promoted IDs and an “organic” query and ensures the documents with the promoted IDs rank higher than the organic matches. Closes #44074
1 parent e134947 commit f0dbc68

File tree

10 files changed

+957
-1
lines changed

10 files changed

+957
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[role="xpack"]
2+
[testenv="basic"]
3+
[[query-dsl-pinned-query]]
4+
=== Pinned Query
5+
Promotes selected documents to rank higher than those matching a given query.
6+
This feature is typically used to guide searchers to curated documents that are
7+
promoted over and above any "organic" matches for a search.
8+
The promoted or "pinned" documents are identified using the document IDs stored in
9+
the <<mapping-id-field,`_id`>> field.
10+
11+
==== Example request
12+
13+
[source,js]
14+
--------------------------------------------------
15+
GET /_search
16+
{
17+
"query": {
18+
"pinned" : {
19+
"ids" : ["1", "4", "100"],
20+
"organic" : {
21+
"match":{
22+
"description": "iphone"
23+
}
24+
}
25+
}
26+
}
27+
}
28+
--------------------------------------------------
29+
// CONSOLE
30+
31+
[[pinned-query-top-level-parameters]]
32+
==== Top-level parameters for `pinned`
33+
34+
`ids`::
35+
An array of <<mapping-id-field, document IDs>> listed in the order they are to appear in results.
36+
`organic`::
37+
Any choice of query used to rank documents which will be ranked below the "pinned" document ids.

docs/reference/query-dsl/special-queries.asciidoc

+5-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ A query that allows to modify the score of a sub-query with a script.
3131
<<query-dsl-wrapper-query,`wrapper` query>>::
3232
A query that accepts other queries as json or yaml string.
3333

34+
<<query-dsl-pinned-query,`pinned` query>>::
35+
A query that promotes selected documents over others matching a given query.
3436

3537
include::distance-feature-query.asciidoc[]
3638

@@ -44,4 +46,6 @@ include::script-query.asciidoc[]
4446

4547
include::script-score-query.asciidoc[]
4648

47-
include::wrapper-query.asciidoc[]
49+
include::wrapper-query.asciidoc[]
50+
51+
include::pinned-query.asciidoc[]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
evaluationDependsOn(xpackModule('core'))
2+
3+
apply plugin: 'elasticsearch.esplugin'
4+
5+
esplugin {
6+
name 'search-business-rules'
7+
description 'A plugin for applying business rules to search result rankings'
8+
classname 'org.elasticsearch.xpack.searchbusinessrules.SearchBusinessRules'
9+
extendedPlugins = ['x-pack-core']
10+
}
11+
archivesBaseName = 'x-pack-searchbusinessrules'
12+
13+
14+
integTest.enabled = false
15+
16+
// Instead we create a separate task to run the
17+
// tests based on ESIntegTestCase
18+
task internalClusterTest(type: Test) {
19+
description = 'Java fantasy integration tests'
20+
mustRunAfter test
21+
22+
include '**/*IT.class'
23+
}
24+
25+
check.dependsOn internalClusterTest
26+
27+
dependencies {
28+
compileOnly project(path: xpackModule('core'), configuration: 'default')
29+
testCompile project(path: xpackModule('core'), configuration: 'testArtifacts')
30+
testCompile project(":test:framework")
31+
if (isEclipse) {
32+
testCompile project(path: xpackModule('core-tests'), configuration: 'testArtifacts')
33+
}
34+
}
35+
36+
// copied from CCR
37+
dependencyLicenses {
38+
ignoreSha 'x-pack-core'
39+
}
40+
41+
//testingConventions.naming {
42+
// IT {
43+
// baseClass "org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilderIT"
44+
// }
45+
//}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
package org.apache.lucene.search;
7+
8+
import java.io.IOException;
9+
import java.util.Objects;
10+
11+
import org.apache.lucene.index.IndexReader;
12+
import org.apache.lucene.index.LeafReaderContext;
13+
import org.apache.lucene.util.Bits;
14+
15+
/**
16+
* A query that wraps another query and ensures scores do not exceed a maximum value
17+
*/
18+
public final class CappedScoreQuery extends Query {
19+
private final Query query;
20+
private final float maxScore;
21+
22+
/** Caps scores from the passed in Query to the supplied maxScore parameter */
23+
public CappedScoreQuery(Query query, float maxScore) {
24+
this.query = Objects.requireNonNull(query, "Query must not be null");
25+
if (maxScore > 0 == false) {
26+
throw new IllegalArgumentException(this.getClass().getName() + " maxScore must be >0, " + maxScore + " supplied.");
27+
}
28+
this.maxScore = maxScore;
29+
}
30+
31+
/** Returns the encapsulated query. */
32+
public Query getQuery() {
33+
return query;
34+
}
35+
36+
@Override
37+
public Query rewrite(IndexReader reader) throws IOException {
38+
Query rewritten = query.rewrite(reader);
39+
40+
if (rewritten != query) {
41+
return new CappedScoreQuery(rewritten, maxScore);
42+
}
43+
44+
if (rewritten.getClass() == CappedScoreQuery.class) {
45+
return rewritten;
46+
}
47+
48+
if (rewritten.getClass() == BoostQuery.class) {
49+
return new CappedScoreQuery(((BoostQuery) rewritten).getQuery(), maxScore);
50+
}
51+
52+
return super.rewrite(reader);
53+
}
54+
55+
/**
56+
* We return this as our {@link BulkScorer} so that if the CSQ wraps a query with its own optimized top-level scorer (e.g.
57+
* BooleanScorer) we can use that top-level scorer.
58+
*/
59+
protected static class CappedBulkScorer extends BulkScorer {
60+
final BulkScorer bulkScorer;
61+
final Weight weight;
62+
final float maxScore;
63+
64+
public CappedBulkScorer(BulkScorer bulkScorer, Weight weight, float maxScore) {
65+
this.bulkScorer = bulkScorer;
66+
this.weight = weight;
67+
this.maxScore = maxScore;
68+
}
69+
70+
@Override
71+
public int score(LeafCollector collector, Bits acceptDocs, int min, int max) throws IOException {
72+
return bulkScorer.score(wrapCollector(collector), acceptDocs, min, max);
73+
}
74+
75+
private LeafCollector wrapCollector(LeafCollector collector) {
76+
return new FilterLeafCollector(collector) {
77+
@Override
78+
public void setScorer(Scorable scorer) throws IOException {
79+
// we must wrap again here, but using the scorer passed in as parameter:
80+
in.setScorer(new FilterScorable(scorer) {
81+
@Override
82+
public float score() throws IOException {
83+
return Math.min(maxScore, in.score());
84+
}
85+
86+
@Override
87+
public void setMinCompetitiveScore(float minScore) throws IOException {
88+
scorer.setMinCompetitiveScore(minScore);
89+
}
90+
91+
});
92+
}
93+
};
94+
}
95+
96+
@Override
97+
public long cost() {
98+
return bulkScorer.cost();
99+
}
100+
}
101+
102+
@Override
103+
public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) throws IOException {
104+
final Weight innerWeight = searcher.createWeight(query, scoreMode, boost);
105+
if (scoreMode.needsScores()) {
106+
return new CappedScoreWeight(this, innerWeight, maxScore) {
107+
@Override
108+
public BulkScorer bulkScorer(LeafReaderContext context) throws IOException {
109+
final BulkScorer innerScorer = innerWeight.bulkScorer(context);
110+
if (innerScorer == null) {
111+
return null;
112+
}
113+
return new CappedBulkScorer(innerScorer, this, maxScore);
114+
}
115+
116+
@Override
117+
public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
118+
ScorerSupplier innerScorerSupplier = innerWeight.scorerSupplier(context);
119+
if (innerScorerSupplier == null) {
120+
return null;
121+
}
122+
return new ScorerSupplier() {
123+
@Override
124+
public Scorer get(long leadCost) throws IOException {
125+
final Scorer innerScorer = innerScorerSupplier.get(leadCost);
126+
// short-circuit if scores will not need capping
127+
innerScorer.advanceShallow(0);
128+
if (innerScorer.getMaxScore(DocIdSetIterator.NO_MORE_DOCS) <= maxScore) {
129+
return innerScorer;
130+
}
131+
return new CappedScorer(innerWeight, innerScorer, maxScore);
132+
}
133+
134+
@Override
135+
public long cost() {
136+
return innerScorerSupplier.cost();
137+
}
138+
};
139+
}
140+
141+
@Override
142+
public Matches matches(LeafReaderContext context, int doc) throws IOException {
143+
return innerWeight.matches(context, doc);
144+
}
145+
146+
@Override
147+
public Scorer scorer(LeafReaderContext context) throws IOException {
148+
ScorerSupplier scorerSupplier = scorerSupplier(context);
149+
if (scorerSupplier == null) {
150+
return null;
151+
}
152+
return scorerSupplier.get(Long.MAX_VALUE);
153+
}
154+
};
155+
} else {
156+
return innerWeight;
157+
}
158+
}
159+
160+
@Override
161+
public String toString(String field) {
162+
return new StringBuilder("CappedScore(").append(query.toString(field)).append(')').toString();
163+
}
164+
165+
@Override
166+
public boolean equals(Object other) {
167+
return sameClassAs(other) && maxScore == ((CappedScoreQuery) other).maxScore &&
168+
query.equals(((CappedScoreQuery) other).query);
169+
}
170+
171+
@Override
172+
public int hashCode() {
173+
return 31 * classHash() + query.hashCode() + Float.hashCode(maxScore);
174+
}
175+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.apache.lucene.search;
8+
9+
import org.apache.lucene.index.LeafReaderContext;
10+
import org.apache.lucene.index.Term;
11+
12+
import java.io.IOException;
13+
import java.util.Set;
14+
15+
/**
16+
* A Weight that caps scores of the wrapped query to a maximum value
17+
*
18+
* @lucene.internal
19+
*/
20+
public abstract class CappedScoreWeight extends Weight {
21+
22+
private final float maxScore;
23+
private final Weight innerWeight;
24+
25+
protected CappedScoreWeight(Query query, Weight innerWeight, float maxScore) {
26+
super(query);
27+
this.maxScore = maxScore;
28+
this.innerWeight = innerWeight;
29+
}
30+
31+
@Override
32+
public void extractTerms(Set<Term> terms) {
33+
innerWeight.extractTerms(terms);
34+
}
35+
36+
@Override
37+
public boolean isCacheable(LeafReaderContext ctx) {
38+
return innerWeight.isCacheable(ctx);
39+
}
40+
41+
@Override
42+
public Scorer scorer(LeafReaderContext context) throws IOException {
43+
return new CappedScorer(this, innerWeight.scorer(context), maxScore);
44+
}
45+
46+
@Override
47+
public Explanation explain(LeafReaderContext context, int doc) throws IOException {
48+
49+
final Scorer s = scorer(context);
50+
final boolean exists;
51+
if (s == null) {
52+
exists = false;
53+
} else {
54+
final TwoPhaseIterator twoPhase = s.twoPhaseIterator();
55+
if (twoPhase == null) {
56+
exists = s.iterator().advance(doc) == doc;
57+
} else {
58+
exists = twoPhase.approximation().advance(doc) == doc && twoPhase.matches();
59+
}
60+
}
61+
62+
Explanation sub = innerWeight.explain(context, doc);
63+
if (sub.isMatch() && sub.getValue().floatValue() > maxScore) {
64+
return Explanation.match(maxScore, "Capped score of " + innerWeight.getQuery() + ", max of",
65+
sub,
66+
Explanation.match(maxScore, "maximum score"));
67+
} else {
68+
return sub;
69+
}
70+
}
71+
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.apache.lucene.search;
8+
9+
import java.io.IOException;
10+
11+
public class CappedScorer extends FilterScorer {
12+
private final float maxScore;
13+
14+
public CappedScorer(Weight weight, Scorer delegate, float maxScore) {
15+
super(delegate, weight);
16+
this.maxScore = maxScore;
17+
}
18+
19+
@Override
20+
public float getMaxScore(int upTo) throws IOException {
21+
return Math.min(maxScore, in.getMaxScore(upTo));
22+
}
23+
24+
@Override
25+
public int advanceShallow(int target) throws IOException {
26+
return in.advanceShallow(target);
27+
}
28+
29+
@Override
30+
public float score() throws IOException {
31+
return Math.min(maxScore, in.score());
32+
}
33+
34+
}

0 commit comments

Comments
 (0)