Skip to content

Commit d9cdf7b

Browse files
committed
Search enhancement: pinned queries (elastic#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 elastic#44074
1 parent 0f51dd6 commit d9cdf7b

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)