Highlight fields in request order

Because json objects are unordered this also adds an explicit order syntax
that looks like
    "highlight": {
        "fields": [
            {"title":{ /*params*/ }},
            {"text":{ /*params*/ }}
        ]
    }

This is not useful for any of the builtin highlighters but will be useful
in plugins.

Closes #4649
This commit is contained in:
Nik Everett 2014-05-14 15:20:59 -04:00 committed by Adrien Grand
parent 81cddacffa
commit 3573822b7e
7 changed files with 199 additions and 85 deletions

View File

@ -547,3 +547,20 @@ keep in mind that scoring more phrases consumes more time and memory.
If using `matched_fields` keep in mind that `phrase_limit` phrases per If using `matched_fields` keep in mind that `phrase_limit` phrases per
matched field are considered. matched field are considered.
[[explicit-field-order]]
=== Field Highlight Order
Elasticsearch highlights the fields in the order that they are sent. Per the
json spec objects are unordered but if you need to be explicit about the order
that fields are highlighted then you can use an array for `fields` like this:
[source,js]
--------------------------------------------------
"highlight": {
"fields": [
{"title":{ /*params*/ }},
{"text":{ /*params*/ }}
]
}
--------------------------------------------------
None of the highlighters built into Elasticsearch care about the order that the
fields are highlighted but a plugin may.

View File

@ -806,6 +806,15 @@ public class SearchRequestBuilder extends ActionRequestBuilder<SearchRequest, Se
return this; return this;
} }
/**
* Send the fields to be highlighted using a syntax that is specific about the order in which they should be highlighted.
* @return this for chaining
*/
public SearchRequestBuilder setHighlighterExplicitFieldOrder(boolean explicitFieldOrder) {
highlightBuilder().useExplicitFieldOrder(explicitFieldOrder);
return this;
}
/** /**
* Delegates to {@link org.elasticsearch.search.suggest.SuggestBuilder#setText(String)}. * Delegates to {@link org.elasticsearch.search.suggest.SuggestBuilder#setText(String)}.
*/ */

View File

@ -74,6 +74,8 @@ public class HighlightBuilder implements ToXContent {
private Boolean forceSource; private Boolean forceSource;
private boolean useExplicitFieldOrder = false;
/** /**
* Adds a field to be highlighted with default fragment size of 100 characters, and * Adds a field to be highlighted with default fragment size of 100 characters, and
* default number of fragments of 5 using the default encoder * default number of fragments of 5 using the default encoder
@ -289,6 +291,15 @@ public class HighlightBuilder implements ToXContent {
return this; return this;
} }
/**
* Send the fields to be highlighted using a syntax that is specific about the order in which they should be highlighted.
* @return this for chaining
*/
public HighlightBuilder useExplicitFieldOrder(boolean useExplicitFieldOrder) {
this.useExplicitFieldOrder = useExplicitFieldOrder;
return this;
}
@Override @Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject("highlight"); builder.startObject("highlight");
@ -347,8 +358,15 @@ public class HighlightBuilder implements ToXContent {
builder.field("force_source", forceSource); builder.field("force_source", forceSource);
} }
if (fields != null) { if (fields != null) {
if (useExplicitFieldOrder) {
builder.startArray("fields");
} else {
builder.startObject("fields"); builder.startObject("fields");
}
for (Field field : fields) { for (Field field : fields) {
if (useExplicitFieldOrder) {
builder.startObject();
}
builder.startObject(field.name()); builder.startObject(field.name());
if (field.preTags != null) { if (field.preTags != null) {
builder.field("pre_tags", field.preTags); builder.field("pre_tags", field.preTags);
@ -406,10 +424,16 @@ public class HighlightBuilder implements ToXContent {
} }
builder.endObject(); builder.endObject();
} if (useExplicitFieldOrder) {
builder.endObject(); builder.endObject();
} }
}
if (useExplicitFieldOrder) {
builder.endArray();
} else {
builder.endObject();
}
}
builder.endObject(); builder.endObject();
return builder; return builder;
} }

View File

@ -28,6 +28,7 @@ import org.elasticsearch.search.SearchParseElement;
import org.elasticsearch.search.SearchParseException; import org.elasticsearch.search.SearchParseException;
import org.elasticsearch.search.internal.SearchContext; import org.elasticsearch.search.internal.SearchContext;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -92,6 +93,24 @@ public class HighlighterParseElement implements SearchParseElement {
postTagsList.add(parser.text()); postTagsList.add(parser.text());
} }
globalOptionsBuilder.postTags(postTagsList.toArray(new String[postTagsList.size()])); globalOptionsBuilder.postTags(postTagsList.toArray(new String[postTagsList.size()]));
} else if ("fields".equals(topLevelFieldName)) {
while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) {
if (token == XContentParser.Token.START_OBJECT) {
String highlightFieldName = null;
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
if (token == XContentParser.Token.FIELD_NAME) {
if (highlightFieldName != null) {
throw new SearchParseException(context, "If highlighter fields is an array it must contain objects containing a single field");
}
highlightFieldName = parser.currentName();
} else if (token == XContentParser.Token.START_OBJECT) {
fieldsOptions.add(Tuple.tuple(highlightFieldName, parseFields(parser, context)));
}
}
} else {
throw new SearchParseException(context, "If highlighter fields is an array it must contain objects containing a single field");
}
}
} }
} else if (token.isValue()) { } else if (token.isValue()) {
if ("order".equals(topLevelFieldName)) { if ("order".equals(topLevelFieldName)) {
@ -141,6 +160,32 @@ public class HighlighterParseElement implements SearchParseElement {
if (token == XContentParser.Token.FIELD_NAME) { if (token == XContentParser.Token.FIELD_NAME) {
highlightFieldName = parser.currentName(); highlightFieldName = parser.currentName();
} else if (token == XContentParser.Token.START_OBJECT) { } else if (token == XContentParser.Token.START_OBJECT) {
fieldsOptions.add(Tuple.tuple(highlightFieldName, parseFields(parser, context)));
}
}
} else if ("highlight_query".equals(topLevelFieldName) || "highlightQuery".equals(topLevelFieldName)) {
globalOptionsBuilder.highlightQuery(context.queryParserService().parse(parser).query());
}
}
}
SearchContextHighlight.FieldOptions globalOptions = globalOptionsBuilder.build();
if (globalOptions.preTags() != null && globalOptions.postTags() == null) {
throw new SearchParseException(context, "Highlighter global preTags are set, but global postTags are not set");
}
List<SearchContextHighlight.Field> fields = Lists.newArrayList();
// now, go over and fill all fieldsOptions with default values from the global state
for (Tuple<String, SearchContextHighlight.FieldOptions.Builder> tuple : fieldsOptions) {
fields.add(new SearchContextHighlight.Field(tuple.v1(), tuple.v2().merge(globalOptions).build()));
}
context.highlight(new SearchContextHighlight(fields));
}
private SearchContextHighlight.FieldOptions.Builder parseFields(XContentParser parser, SearchContext context) throws IOException {
XContentParser.Token token;
SearchContextHighlight.FieldOptions.Builder fieldOptionsBuilder = new SearchContextHighlight.FieldOptions.Builder(); SearchContextHighlight.FieldOptions.Builder fieldOptionsBuilder = new SearchContextHighlight.FieldOptions.Builder();
String fieldName = null; String fieldName = null;
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
@ -179,9 +224,9 @@ public class HighlighterParseElement implements SearchParseElement {
fieldOptionsBuilder.scoreOrdered("score".equals(parser.text())); fieldOptionsBuilder.scoreOrdered("score".equals(parser.text()));
} else if ("require_field_match".equals(fieldName) || "requireFieldMatch".equals(fieldName)) { } else if ("require_field_match".equals(fieldName) || "requireFieldMatch".equals(fieldName)) {
fieldOptionsBuilder.requireFieldMatch(parser.booleanValue()); fieldOptionsBuilder.requireFieldMatch(parser.booleanValue());
} else if ("boundary_max_scan".equals(topLevelFieldName) || "boundaryMaxScan".equals(topLevelFieldName)) { } else if ("boundary_max_scan".equals(fieldName) || "boundaryMaxScan".equals(fieldName)) {
fieldOptionsBuilder.boundaryMaxScan(parser.intValue()); fieldOptionsBuilder.boundaryMaxScan(parser.intValue());
} else if ("boundary_chars".equals(topLevelFieldName) || "boundaryChars".equals(topLevelFieldName)) { } else if ("boundary_chars".equals(fieldName) || "boundaryChars".equals(fieldName)) {
char[] charsArr = parser.text().toCharArray(); char[] charsArr = parser.text().toCharArray();
Character[] boundaryChars = new Character[charsArr.length]; Character[] boundaryChars = new Character[charsArr.length];
for (int i = 0; i < charsArr.length; i++) { for (int i = 0; i < charsArr.length; i++) {
@ -207,26 +252,6 @@ public class HighlighterParseElement implements SearchParseElement {
} }
} }
} }
fieldsOptions.add(Tuple.tuple(highlightFieldName, fieldOptionsBuilder)); return fieldOptionsBuilder;
}
}
} else if ("highlight_query".equals(topLevelFieldName) || "highlightQuery".equals(topLevelFieldName)) {
globalOptionsBuilder.highlightQuery(context.queryParserService().parse(parser).query());
}
}
}
SearchContextHighlight.FieldOptions globalOptions = globalOptionsBuilder.build();
if (globalOptions.preTags() != null && globalOptions.postTags() == null) {
throw new SearchParseException(context, "Highlighter global preTags are set, but global postTags are not set");
}
List<SearchContextHighlight.Field> fields = Lists.newArrayList();
// now, go over and fill all fieldsOptions with default values from the global state
for (Tuple<String, SearchContextHighlight.FieldOptions.Builder> tuple : fieldsOptions) {
fields.add(new SearchContextHighlight.Field(tuple.v1(), tuple.v2().merge(globalOptions).build()));
}
context.highlight(new SearchContextHighlight(fields));
} }
} }

View File

@ -35,7 +35,7 @@ public class SearchContextHighlight {
public SearchContextHighlight(Collection<Field> fields) { public SearchContextHighlight(Collection<Field> fields) {
assert fields != null; assert fields != null;
this.fields = Maps.newHashMap(); this.fields = new LinkedHashMap<String, Field>(fields.size());
for (Field field : fields) { for (Field field : fields) {
this.fields.put(field.field, field); this.fields.put(field.field, field);
} }

View File

@ -23,6 +23,7 @@ import org.elasticsearch.common.text.StringText;
import org.elasticsearch.common.text.Text; import org.elasticsearch.common.text.Text;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
/** /**
@ -38,9 +39,24 @@ public class CustomHighlighter implements Highlighter {
@Override @Override
public HighlightField highlight(HighlighterContext highlighterContext) { public HighlightField highlight(HighlighterContext highlighterContext) {
SearchContextHighlight.Field field = highlighterContext.field; SearchContextHighlight.Field field = highlighterContext.field;
CacheEntry cacheEntry = (CacheEntry) highlighterContext.hitContext.cache().get("test-custom");
if (cacheEntry == null) {
cacheEntry = new CacheEntry();
highlighterContext.hitContext.cache().put("test-custom", cacheEntry);
cacheEntry.docId = highlighterContext.hitContext.docId();
cacheEntry.position = 1;
} else {
if (cacheEntry.docId == highlighterContext.hitContext.docId()) {
cacheEntry.position++;
} else {
cacheEntry.docId = highlighterContext.hitContext.docId();
cacheEntry.position = 1;
}
}
List<Text> responses = Lists.newArrayList(); List<Text> responses = Lists.newArrayList();
responses.add(new StringText("standard response")); responses.add(new StringText(String.format(Locale.ENGLISH, "standard response for %s at position %s", field.field(),
cacheEntry.position)));
if (field.fieldOptions().options() != null) { if (field.fieldOptions().options() != null) {
for (Map.Entry<String, Object> entry : field.fieldOptions().options().entrySet()) { for (Map.Entry<String, Object> entry : field.fieldOptions().options().entrySet()) {
@ -50,4 +66,9 @@ public class CustomHighlighter implements Highlighter {
return new HighlightField(highlighterContext.fieldName, responses.toArray(new Text[]{})); return new HighlightField(highlighterContext.fieldName, responses.toArray(new Text[]{}));
} }
private static class CacheEntry {
private int position;
private int docId;
}
} }

View File

@ -20,11 +20,11 @@ package org.elasticsearch.search.highlight;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.common.Priority;
import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.test.ElasticsearchIntegrationTest; import org.elasticsearch.test.ElasticsearchIntegrationTest;
import org.elasticsearch.test.ElasticsearchIntegrationTest.ClusterScope;
import org.elasticsearch.test.ElasticsearchIntegrationTest.Scope;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
@ -32,8 +32,6 @@ import java.io.IOException;
import java.util.Map; import java.util.Map;
import static org.elasticsearch.common.settings.ImmutableSettings.settingsBuilder; import static org.elasticsearch.common.settings.ImmutableSettings.settingsBuilder;
import static org.elasticsearch.test.ElasticsearchIntegrationTest.ClusterScope;
import static org.elasticsearch.test.ElasticsearchIntegrationTest.Scope;
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHighlight; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHighlight;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
@ -53,12 +51,12 @@ public class CustomHighlighterSearchTests extends ElasticsearchIntegrationTest {
@Before @Before
protected void setup() throws Exception{ protected void setup() throws Exception{
client().prepareIndex("test", "test", "1").setSource(XContentFactory.jsonBuilder() indexRandom(true,
.startObject() client().prepareIndex("test", "test", "1").setSource(
.field("name", "arbitrary content") "name", "arbitrary content", "other_name", "foo", "other_other_name", "bar"),
.endObject()) client().prepareIndex("test", "test", "2").setSource(
.setRefresh(true).execute().actionGet(); "other_name", "foo", "other_other_name", "bar"));
client().admin().cluster().prepareHealth().setWaitForEvents(Priority.LANGUID).setWaitForYellowStatus().execute().actionGet(); ensureYellow();
} }
@Test @Test
@ -67,7 +65,7 @@ public class CustomHighlighterSearchTests extends ElasticsearchIntegrationTest {
.setQuery(QueryBuilders.matchAllQuery()) .setQuery(QueryBuilders.matchAllQuery())
.addHighlightedField("name").setHighlighterType("test-custom") .addHighlightedField("name").setHighlighterType("test-custom")
.execute().actionGet(); .execute().actionGet();
assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response")); assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response for name at position 1"));
} }
@Test @Test
@ -83,7 +81,7 @@ public class CustomHighlighterSearchTests extends ElasticsearchIntegrationTest {
.addHighlightedField(highlightConfig) .addHighlightedField(highlightConfig)
.execute().actionGet(); .execute().actionGet();
assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response")); assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response for name at position 1"));
assertHighlight(searchResponse, 0, "name", 1, equalTo("field:myFieldOption:someValue")); assertHighlight(searchResponse, 0, "name", 1, equalTo("field:myFieldOption:someValue"));
} }
@ -99,7 +97,27 @@ public class CustomHighlighterSearchTests extends ElasticsearchIntegrationTest {
.addHighlightedField("name") .addHighlightedField("name")
.execute().actionGet(); .execute().actionGet();
assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response")); assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response for name at position 1"));
assertHighlight(searchResponse, 0, "name", 1, equalTo("field:myGlobalOption:someValue")); assertHighlight(searchResponse, 0, "name", 1, equalTo("field:myGlobalOption:someValue"));
} }
@Test
public void testThatCustomHighlighterReceivesFieldsInOrder() throws Exception {
SearchResponse searchResponse = client().prepareSearch("test").setTypes("test")
.setQuery(QueryBuilders.boolQuery().must(QueryBuilders.matchAllQuery()).should(QueryBuilders
.termQuery("name", "arbitrary")))
.setHighlighterType("test-custom")
.addHighlightedField("name")
.addHighlightedField("other_name")
.addHighlightedField("other_other_name")
.setHighlighterExplicitFieldOrder(true)
.get();
assertHighlight(searchResponse, 0, "name", 0, equalTo("standard response for name at position 1"));
assertHighlight(searchResponse, 0, "other_name", 0, equalTo("standard response for other_name at position 2"));
assertHighlight(searchResponse, 0, "other_other_name", 0, equalTo("standard response for other_other_name at position 3"));
assertHighlight(searchResponse, 1, "name", 0, equalTo("standard response for name at position 1"));
assertHighlight(searchResponse, 1, "other_name", 0, equalTo("standard response for other_name at position 2"));
assertHighlight(searchResponse, 1, "other_other_name", 0, equalTo("standard response for other_other_name at position 3"));
}
} }