Skip to content

Commit f7901f0

Browse files
Support duplicate suggestions in completion field (#121324)
Currently if a document has duplicate suggestions across different contexts, only the first gets indexed, and when a user tries to search using the second context, she will get 0 results. This PR addresses this, but adding support for duplicate suggestions across different contexts, so documents like below with duplicate inputs can be searched across all provided contexts. ```json { "my_suggest": [ { "input": [ "foox", "boo" ], "weight" : 2, "contexts": { "color": [ "red" ] } }, { "input": [ "foox" ], "weight" : 3, "contexts": { "color": [ "blue" ] } } ] } ``` Closes #82432
1 parent 38a3844 commit f7901f0

File tree

6 files changed

+283
-20
lines changed

6 files changed

+283
-20
lines changed

docs/changelog/121324.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 121324
2+
summary: Support duplicate suggestions in completion field
3+
area: Suggesters
4+
type: bug
5+
issues:
6+
- 82432

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/suggest/30_context.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,75 @@ setup:
395395
field: suggest_multi_contexts
396396
contexts:
397397
location: []
398+
399+
---
400+
"Duplicate suggestions in different contexts":
401+
- requires:
402+
cluster_features: [ "search.completion_field.duplicate.support" ]
403+
reason: "Support for duplicate suggestions in different contexts"
404+
405+
- do:
406+
index:
407+
refresh: true
408+
index: test
409+
id: "1"
410+
body:
411+
suggest_context:
412+
-
413+
input: "foox"
414+
weight: 2
415+
contexts:
416+
color: ["red", "yellow"]
417+
-
418+
input: "foox"
419+
weight: 3
420+
contexts:
421+
color: ["blue", "green", "yellow"]
422+
- do:
423+
search:
424+
body:
425+
suggest:
426+
result:
427+
text: "foo"
428+
completion:
429+
field: suggest_context
430+
contexts:
431+
color: "red"
432+
433+
- length: { suggest.result: 1 }
434+
- length: { suggest.result.0.options: 1 }
435+
- match: { suggest.result.0.options.0.text: "foox" }
436+
- match: { suggest.result.0.options.0._score: 2 }
437+
438+
- do:
439+
search:
440+
body:
441+
suggest:
442+
result:
443+
text: "foo"
444+
completion:
445+
field: suggest_context
446+
contexts:
447+
color: "yellow"
448+
449+
- length: { suggest.result: 1 }
450+
- length: { suggest.result.0.options: 1 }
451+
- match: { suggest.result.0.options.0.text: "foox" }
452+
# the highest weight wins
453+
- match: { suggest.result.0.options.0._score: 3 }
454+
455+
- do:
456+
search:
457+
body:
458+
suggest:
459+
result:
460+
text: "foo"
461+
completion:
462+
field: suggest_context
463+
contexts:
464+
color: "blue"
465+
466+
- length: { suggest.result: 1 }
467+
- length: { suggest.result.0.options: 1 }
468+
- match: { suggest.result.0.options.0.text: "foox" }
469+
- match: { suggest.result.0.options.0._score: 3 }

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/suggest/50_completion_with_multi_fields.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,80 @@
268268

269269
- length: { suggest.result: 1 }
270270
- length: { suggest.result.0.options: 1 }
271+
272+
---
273+
"Duplicate suggestions in different contexts in sub-fields":
274+
- requires:
275+
cluster_features: [ "search.completion_field.duplicate.support" ]
276+
reason: "Support for duplicate suggestions in different contexts"
277+
278+
- do:
279+
indices.create:
280+
index: completion_with_context
281+
body:
282+
mappings:
283+
"properties":
284+
"suggest_1":
285+
"type": "completion"
286+
"contexts":
287+
-
288+
"name": "color"
289+
"type": "category"
290+
"fields":
291+
"suggest_2":
292+
"type": "completion"
293+
"contexts":
294+
-
295+
"name": "color"
296+
"type": "category"
297+
298+
299+
- do:
300+
index:
301+
refresh: true
302+
index: completion_with_context
303+
id: "1"
304+
body:
305+
suggest_1:
306+
-
307+
input: "foox"
308+
weight: 2
309+
contexts:
310+
color: ["red"]
311+
-
312+
input: "foox"
313+
weight: 3
314+
contexts:
315+
color: ["blue", "green"]
316+
- do:
317+
search:
318+
body:
319+
suggest:
320+
result:
321+
text: "foo"
322+
completion:
323+
field: suggest_1.suggest_2
324+
contexts:
325+
color: "red"
326+
327+
- length: { suggest.result: 1 }
328+
- length: { suggest.result.0.options: 1 }
329+
- match: { suggest.result.0.options.0.text: "foox" }
330+
- match: { suggest.result.0.options.0._score: 2 }
331+
332+
333+
- do:
334+
search:
335+
body:
336+
suggest:
337+
result:
338+
text: "foo"
339+
completion:
340+
field: suggest_1.suggest_2
341+
contexts:
342+
color: "blue"
343+
344+
- length: { suggest.result: 1 }
345+
- length: { suggest.result.0.options: 1 }
346+
- match: { suggest.result.0.options.0.text: "foox" }
347+
- match: { suggest.result.0.options.0._score: 3 }

server/src/main/java/org/elasticsearch/index/mapper/CompletionFieldMapper.java

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ public void parse(DocumentParserContext context) throws IOException {
392392
// parse
393393
XContentParser parser = context.parser();
394394
Token token = parser.currentToken();
395-
Map<String, CompletionInputMetadata> inputMap = Maps.newMapWithExpectedSize(1);
395+
Map<String, CompletionInputMetadataContainer> inputMap = Maps.newMapWithExpectedSize(1);
396396

397397
if (token == Token.VALUE_NULL) { // ignore null values
398398
return;
@@ -405,7 +405,7 @@ public void parse(DocumentParserContext context) throws IOException {
405405
}
406406

407407
// index
408-
for (Map.Entry<String, CompletionInputMetadata> completionInput : inputMap.entrySet()) {
408+
for (Map.Entry<String, CompletionInputMetadataContainer> completionInput : inputMap.entrySet()) {
409409
String input = completionInput.getKey();
410410
if (input.trim().isEmpty()) {
411411
context.addIgnoredField(mappedFieldType.name());
@@ -420,21 +420,33 @@ public void parse(DocumentParserContext context) throws IOException {
420420
}
421421
input = input.substring(0, len);
422422
}
423-
CompletionInputMetadata metadata = completionInput.getValue();
423+
CompletionInputMetadataContainer cmc = completionInput.getValue();
424424
if (fieldType().hasContextMappings()) {
425-
fieldType().getContextMappings().addField(context.doc(), fieldType().name(), input, metadata.weight, metadata.contexts);
425+
for (CompletionInputMetadata metadata : cmc.getValues()) {
426+
fieldType().getContextMappings().addField(context.doc(), fieldType().name(), input, metadata.weight, metadata.contexts);
427+
}
426428
} else {
427-
context.doc().add(new SuggestField(fieldType().name(), input, metadata.weight));
429+
context.doc().add(new SuggestField(fieldType().name(), input, cmc.getWeight()));
428430
}
429431
}
430-
431432
context.addToFieldNames(fieldType().name());
432-
for (CompletionInputMetadata metadata : inputMap.values()) {
433-
multiFields().parse(
434-
this,
435-
context,
436-
() -> context.switchParser(new MultiFieldParser(metadata, fieldType().name(), context.parser().getTokenLocation()))
437-
);
433+
for (CompletionInputMetadataContainer cmc : inputMap.values()) {
434+
if (fieldType().hasContextMappings()) {
435+
for (CompletionInputMetadata metadata : cmc.getValues()) {
436+
multiFields().parse(
437+
this,
438+
context,
439+
() -> context.switchParser(new MultiFieldParser(metadata, fieldType().name(), context.parser().getTokenLocation()))
440+
);
441+
}
442+
} else {
443+
CompletionInputMetadata metadata = cmc.getValue();
444+
multiFields().parse(
445+
this,
446+
context,
447+
() -> context.switchParser(new MultiFieldParser(metadata, fieldType().name(), context.parser().getTokenLocation()))
448+
);
449+
}
438450
}
439451
}
440452

@@ -447,11 +459,13 @@ private void parse(
447459
DocumentParserContext documentParserContext,
448460
Token token,
449461
XContentParser parser,
450-
Map<String, CompletionInputMetadata> inputMap
462+
Map<String, CompletionInputMetadataContainer> inputMap
451463
) throws IOException {
452464
String currentFieldName = null;
453465
if (token == Token.VALUE_STRING) {
454-
inputMap.put(parser.text(), new CompletionInputMetadata(parser.text(), Collections.<String, Set<String>>emptyMap(), 1));
466+
CompletionInputMetadataContainer cmc = new CompletionInputMetadataContainer(fieldType().hasContextMappings());
467+
cmc.add(new CompletionInputMetadata(parser.text(), Collections.emptyMap(), 1));
468+
inputMap.put(parser.text(), cmc);
455469
} else if (token == Token.START_OBJECT) {
456470
Set<String> inputs = new HashSet<>();
457471
int weight = 1;
@@ -531,8 +545,14 @@ private void parse(
531545
}
532546
}
533547
for (String input : inputs) {
534-
if (inputMap.containsKey(input) == false || inputMap.get(input).weight < weight) {
535-
inputMap.put(input, new CompletionInputMetadata(input, contextsMap, weight));
548+
CompletionInputMetadata cm = new CompletionInputMetadata(input, contextsMap, weight);
549+
CompletionInputMetadataContainer cmc = inputMap.get(input);
550+
if (cmc != null) {
551+
cmc.add(cm);
552+
} else {
553+
cmc = new CompletionInputMetadataContainer(fieldType().hasContextMappings());
554+
cmc.add(cm);
555+
inputMap.put(input, cmc);
536556
}
537557
}
538558
} else {
@@ -543,10 +563,46 @@ private void parse(
543563
}
544564
}
545565

566+
static class CompletionInputMetadataContainer {
567+
private final boolean hasContexts;
568+
private final List<CompletionInputMetadata> list;
569+
private CompletionInputMetadata single;
570+
571+
CompletionInputMetadataContainer(boolean hasContexts) {
572+
this.hasContexts = hasContexts;
573+
this.list = hasContexts ? new ArrayList<>() : null;
574+
}
575+
576+
void add(CompletionInputMetadata cm) {
577+
if (hasContexts) {
578+
list.add(cm);
579+
} else {
580+
if (single == null || single.weight < cm.weight) {
581+
single = cm;
582+
}
583+
}
584+
}
585+
586+
List<CompletionInputMetadata> getValues() {
587+
assert hasContexts;
588+
return list;
589+
}
590+
591+
CompletionInputMetadata getValue() {
592+
assert hasContexts == false;
593+
return single;
594+
}
595+
596+
int getWeight() {
597+
assert hasContexts == false;
598+
return single.weight;
599+
}
600+
}
601+
546602
static class CompletionInputMetadata {
547-
public final String input;
548-
public final Map<String, Set<String>> contexts;
549-
public final int weight;
603+
private final String input;
604+
private final Map<String, Set<String>> contexts;
605+
private final int weight;
550606

551607
CompletionInputMetadata(String input, Map<String, Set<String>> contexts, int weight) {
552608
this.input = input;

server/src/main/java/org/elasticsearch/search/SearchFeatures.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ public Set<NodeFeature> getFeatures() {
2525
}
2626

2727
public static final NodeFeature RETRIEVER_RESCORER_ENABLED = new NodeFeature("search.retriever.rescorer.enabled");
28+
public static final NodeFeature COMPLETION_FIELD_SUPPORTS_DUPLICATE_SUGGESTIONS = new NodeFeature(
29+
"search.completion_field.duplicate.support"
30+
);
2831

2932
@Override
3033
public Set<NodeFeature> getTestFeatures() {
31-
return Set.of(RETRIEVER_RESCORER_ENABLED);
34+
return Set.of(RETRIEVER_RESCORER_ENABLED, COMPLETION_FIELD_SUPPORTS_DUPLICATE_SUGGESTIONS);
3235
}
3336
}

server/src/test/java/org/elasticsearch/index/mapper/CompletionFieldMapperTests.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,55 @@ public void testKeywordWithSubCompletionAndContext() throws Exception {
303303
);
304304
}
305305

306+
public void testDuplicateSuggestionsWithContexts() throws IOException {
307+
DocumentMapper defaultMapper = createDocumentMapper(fieldMapping(b -> {
308+
b.field("type", "completion");
309+
b.startArray("contexts");
310+
{
311+
b.startObject();
312+
b.field("name", "place");
313+
b.field("type", "category");
314+
b.endObject();
315+
}
316+
b.endArray();
317+
}));
318+
319+
ParsedDocument parsedDocument = defaultMapper.parse(source(b -> {
320+
b.startArray("field");
321+
{
322+
b.startObject();
323+
{
324+
b.array("input", "timmy", "starbucks");
325+
b.startObject("contexts").array("place", "cafe", "food").endObject();
326+
b.field("weight", 10);
327+
}
328+
b.endObject();
329+
b.startObject();
330+
{
331+
b.array("input", "timmy", "starbucks");
332+
b.startObject("contexts").array("place", "restaurant").endObject();
333+
b.field("weight", 1);
334+
}
335+
b.endObject();
336+
}
337+
b.endArray();
338+
}));
339+
340+
List<IndexableField> indexedFields = parsedDocument.rootDoc().getFields("field");
341+
assertThat(indexedFields, hasSize(4));
342+
343+
assertThat(
344+
indexedFields,
345+
containsInAnyOrder(
346+
contextSuggestField("timmy"),
347+
contextSuggestField("timmy"),
348+
contextSuggestField("starbucks"),
349+
contextSuggestField("starbucks")
350+
)
351+
);
352+
353+
}
354+
306355
public void testCompletionWithContextAndSubCompletion() throws Exception {
307356
DocumentMapper defaultMapper = createDocumentMapper(fieldMapping(b -> {
308357
b.field("type", "completion");

0 commit comments

Comments
 (0)