Skip to content

Commit

Permalink
Allow specifying index in pinned queries (elastic#74873)
Browse files Browse the repository at this point in the history
The current `ids` option doesn't allow pinning a specific document in a
single index when searching over multiple indices. This introduces a
`documents` option, which is an array of `_id` and `_index`
fields to allow index-specific pins.

Closes elastic#67855.
  • Loading branch information
David Harsha authored and jtibshirani committed Jul 27, 2021
1 parent a92a5b4 commit 7be9355
Show file tree
Hide file tree
Showing 7 changed files with 700 additions and 138 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1380,13 +1380,25 @@ public void testCountAllIndicesMatchQuery() throws IOException {
public void testSearchWithBasicLicensedQuery() throws IOException {
SearchRequest searchRequest = new SearchRequest("index");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(), "2", "1");
searchSourceBuilder.query(pinnedQuery);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync);
assertSearchHeader(searchResponse);
assertFirstHit(searchResponse, hasId("2"));
assertSecondHit(searchResponse, hasId("1"));
{
PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(), "2", "1");
searchSourceBuilder.query(pinnedQuery);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync);
assertSearchHeader(searchResponse);
assertFirstHit(searchResponse, hasId("2"));
assertSecondHit(searchResponse, hasId("1"));
}
{
PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(),
new PinnedQueryBuilder.Item("index", "2"), new PinnedQueryBuilder.Item("index", "1"));
searchSourceBuilder.query(pinnedQuery);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync);
assertSearchHeader(searchResponse);
assertFirstHit(searchResponse, hasId("2"));
assertSecondHit(searchResponse, hasId("1"));
}
}

public void testPointInTime() throws Exception {
Expand Down
53 changes: 50 additions & 3 deletions docs/reference/query-dsl/pinned-query.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
=== Pinned Query
Promotes selected documents to rank higher than those matching a given query.
This feature is typically used to guide searchers to curated documents that are
promoted over and above any "organic" matches for a search.
promoted over and above any "organic" matches for a search.
The promoted or "pinned" documents are identified using the document IDs stored in
the <<mapping-id-field,`_id`>> field.

Expand All @@ -31,6 +31,53 @@ GET /_search
==== Top-level parameters for `pinned`

`ids`::
An array of <<mapping-id-field, document IDs>> listed in the order they are to appear in results.
(Optional, array) <<mapping-id-field, Document IDs>> listed in the order they are to appear in results.
Required if `docs` is not specified.
`docs`::
(Optional, array) Documents listed in the order they are to appear in results.
Required if `ids` is not specified.
You can specify the following attributes for each document:
+
--
`_id`::
(Required, string) The unique <<mapping-id-field, document ID>>.

`_index`::
(Required, string) The index that contains the document.
--
`organic`::
Any choice of query used to rank documents which will be ranked below the "pinned" document ids.
Any choice of query used to rank documents which will be ranked below the "pinned" documents.

==== Pin documents in a specific index

If you're searching over multiple indices, you can pin a document within a specific index using `docs`:

[source,console]
--------------------------------------------------
GET /_search
{
"query": {
"pinned": {
"docs": [
{
"_index": "my-index-000001",
"_id": "1"
},
{
"_index": "my-index-000001",
"_id": "4"
},
{
"_index": "my-index-000002",
"_id": "100"
}
],
"organic": {
"match": {
"description": "iphone"
}
}
}
}
}
--------------------------------------------------
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
package org.elasticsearch.xpack.core.index.query;

import org.apache.lucene.search.Query;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.regex.Regex;
import org.elasticsearch.common.xcontent.ParseField;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.index.query.AbstractQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.SearchExecutionContext;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
Expand All @@ -29,34 +32,91 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder<PinnedQueryBuilder>
public static final String NAME = "pinned";
protected final QueryBuilder organicQuery;
protected final List<String> ids;
private final List<Item> docs;
protected static final ParseField IDS_FIELD = new ParseField("ids");
private static final ParseField DOCS_FIELD = new ParseField("docs");
protected static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic");

/**
* A single item to be used for a {@link PinnedQueryBuilder}.
*/
public static final class Item implements ToXContentObject, Writeable {
private static final ParseField INDEX_FIELD = new ParseField("_index");
private static final ParseField ID_FIELD = new ParseField("_id");

private final String index;
private final String id;

public Item(String index, String id) {
if (index == null) {
throw new IllegalArgumentException("Item requires index to be non-null");
}
if (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;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(index);
out.writeString(id);
}

@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject();
builder.field(INDEX_FIELD.getPreferredName(), index);
builder.field(ID_FIELD.getPreferredName(), id);
return builder.endObject();
}
}

@Override
public String getWriteableName() {
return NAME;
}

public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) {
this(organicQuery, Arrays.asList(ids), null);
}

public PinnedQueryBuilder(QueryBuilder organicQuery, Item... docs) {
this(organicQuery, null, Arrays.asList(docs));
}

/**
* Creates a new PinnedQueryBuilder
*/
public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) {
private PinnedQueryBuilder(QueryBuilder organicQuery, List<String> ids, List<Item> docs) {
if (organicQuery == null) {
throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null");
}
this.organicQuery = organicQuery;
if (ids == null) {
throw new IllegalArgumentException("[" + NAME + "] ids cannot be null");
if (ids == null && docs == null) {
throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be null");
}
this.ids = new ArrayList<>();
Collections.addAll(this.ids, ids);

if (ids != null && docs != null) {
throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be used");
}
this.ids = ids;
this.docs = docs;
}


@Override
protected void doWriteTo(StreamOutput out) throws IOException {
out.writeStringCollection(this.ids);
out.writeOptionalStringCollection(this.ids);
if (docs == null) {
out.writeBoolean(false);
} else {
out.writeBoolean(true);
out.writeList(docs);
}
out.writeNamedWriteable(organicQuery);
}

Expand All @@ -71,9 +131,22 @@ public QueryBuilder organicQuery() {
* Returns the pinned ids for the query.
*/
public List<String> ids() {
if (this.ids == null) {
return Collections.emptyList();
}
return Collections.unmodifiableList(this.ids);
}

/**
* Returns the pinned docs for the query.
*/
public List<Item> docs() {
if (this.docs == null) {
return Collections.emptyList();
}
return Collections.unmodifiableList(this.docs);
}


@Override
protected void doXContent(XContentBuilder builder, Params params) throws IOException {
Expand All @@ -82,11 +155,20 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep
builder.field(ORGANIC_QUERY_FIELD.getPreferredName());
organicQuery.toXContent(builder, params);
}
builder.startArray(IDS_FIELD.getPreferredName());
for (String value : ids) {
builder.value(value);
if (ids != null) {
builder.startArray(IDS_FIELD.getPreferredName());
for (String value : ids) {
builder.value(value);
}
builder.endArray();
}
if (docs != null) {
builder.startArray(DOCS_FIELD.getPreferredName());
for (Item item : docs) {
builder.value(item);
}
builder.endArray();
}
builder.endArray();
printBoostAndQueryName(builder);
builder.endObject();
}
Expand All @@ -99,12 +181,15 @@ protected Query doToQuery(SearchExecutionContext context) throws IOException {

@Override
protected int doHashCode() {
return Objects.hash(ids, organicQuery);
return Objects.hash(ids, docs, organicQuery);
}

@Override
protected boolean doEquals(PinnedQueryBuilder other) {
return Objects.equals(ids, other.ids) && Objects.equals(organicQuery, other.organicQuery) && boost == other.boost;
return Objects.equals(ids, other.ids)
&& Objects.equals(docs, other.docs)
&& Objects.equals(organicQuery, other.organicQuery)
&& boost == other.boost;
}

}
Loading

0 comments on commit 7be9355

Please sign in to comment.