From 0faa561716d054db798ac2c00ebbd8b6f71687d7 Mon Sep 17 00:00:00 2001 From: Jim Ferenczi Date: Tue, 14 Jan 2025 15:33:35 +0000 Subject: [PATCH] Add Multi-Field Support for Semantic Text Fields Semantic text fields now support multi-fields, either as part of a multi-field structure or containing multi-fields internally. This enhancement aligns with the semantic text field's current behavior as a standard text field. Note: Multi-field support is only available for the new index format. Attempting to set a multi-field on an index created with the older format will still result in a failure. --- .../mapping/types/semantic-text.asciidoc | 48 ++++---- .../index/mapper/ContentPath.java | 6 +- .../index/mapper/FieldMapper.java | 5 + ...SemanticInferenceMetadataFieldsMapper.java | 8 +- .../mapper/SemanticTextFieldMapper.java | 100 ++++++++++++----- .../mapper/SemanticTextFieldMapperTests.java | 94 ++++++++++++---- .../10_semantic_text_field_mapping.yml | 51 --------- .../inference/30_semantic_text_inference.yml | 105 ++++++++++++++++++ 8 files changed, 286 insertions(+), 131 deletions(-) diff --git a/docs/reference/mapping/types/semantic-text.asciidoc b/docs/reference/mapping/types/semantic-text.asciidoc index 4514c8b6756a8..b252a0058258f 100644 --- a/docs/reference/mapping/types/semantic-text.asciidoc +++ b/docs/reference/mapping/types/semantic-text.asciidoc @@ -182,16 +182,11 @@ Even if the script targets non-`semantic_text` fields, the update will fail when [discrete] [[copy-to-support]] -==== `copy_to` support +==== `copy_to` and multi-fields support -The `semantic_text` field type can be the target of -<>. This means you can use a single `semantic_text` -field to collect the values of other fields for semantic search. Each value has -its embeddings calculated separately; each field value is a separate set of chunk(s) in -the resulting embeddings. - -This imposes a restriction on bulk requests and ingestion pipelines that update documents with `semantic_text` fields. -In these cases, all fields that are copied to a `semantic_text` field, including the `semantic_text` field value, must have a value to ensure every embedding is calculated correctly. +The semantic_text field type can serve as the target of <>, +be part of a <> structure, or contain <> internally. +This means you can use a single field to collect the values of other fields for semantic search. For example, the following mapping: @@ -201,13 +196,13 @@ PUT test-index { "mappings": { "properties": { - "infer_field": { - "type": "semantic_text", - "inference_id": ".elser-2-elasticsearch" - }, "source_field": { "type": "text", "copy_to": "infer_field" + }, + "infer_field": { + "type": "semantic_text", + "inference_id": ".elser-2-elasticsearch" } } } @@ -215,19 +210,29 @@ PUT test-index ------------------------------------------------------------ // TEST[skip:TBD] -Will need the following bulk update request to ensure that `infer_field` is updated correctly: +can also be declared as multi-fields: [source,console] ------------------------------------------------------------ -PUT test-index/_bulk -{"update": {"_id": "1"}} -{"doc": {"infer_field": "updated inference field", "source_field": "updated source field"}} +PUT test-index +{ + "mappings": { + "properties": { + "source_field": { + "type": "text", + "fields": { + "infer_field": { + "type": "semantic_text", + "inference_id": ".elser-2-elasticsearch" + } + } + } + } + } +} ------------------------------------------------------------ // TEST[skip:TBD] -Notice that both the `semantic_text` field and the source field are updated in the bulk request. - - [discrete] [[limitations]] ==== Limitations @@ -235,5 +240,4 @@ Notice that both the `semantic_text` field and the source field are updated in t `semantic_text` field types have the following limitations: * `semantic_text` fields are not currently supported as elements of <>. -* `semantic_text` fields can't currently be set as part of <>. -* `semantic_text` fields can't be defined as <> of another field, nor can they contain other fields as multi-fields. +* `semantic_text` fields can't currently be set as part of <>. \ No newline at end of file diff --git a/server/src/main/java/org/elasticsearch/index/mapper/ContentPath.java b/server/src/main/java/org/elasticsearch/index/mapper/ContentPath.java index bd90fc14c2991..c7fcb682053ee 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/ContentPath.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/ContentPath.java @@ -43,8 +43,10 @@ private void expand() { path = newPath; } - public void remove() { - path[--index] = null; + public String remove() { + var ret = path[--index]; + path[index] = null; + return ret; } public void setWithinLeafObject(boolean withinLeafObject) { diff --git a/server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java index 5bbecbf117dba..c0c3c7193998a 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/FieldMapper.java @@ -1386,6 +1386,11 @@ public Builder init(FieldMapper initializer) { return this; } + public Builder addMultiField(FieldMapper.Builder builder) { + this.multiFieldsBuilder.add(builder); + return this; + } + protected BuilderParams builderParams(Mapper.Builder mainFieldBuilder, MapperBuilderContext context) { return new BuilderParams(multiFieldsBuilder.build(mainFieldBuilder, context), copyTo, sourceKeepMode, hasScript, onScriptError); } diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticInferenceMetadataFieldsMapper.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticInferenceMetadataFieldsMapper.java index 3f49973d6e35f..f20b202b2991f 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticInferenceMetadataFieldsMapper.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticInferenceMetadataFieldsMapper.java @@ -143,13 +143,7 @@ protected void parseCreateField(DocumentParserContext context) throws IOExceptio // directly. We can safely split on all "." chars because semantic text fields cannot be used when subobjects == false. String[] fieldNameParts = fieldName.split("\\."); setPath(context.path(), fieldNameParts); - - var parent = context.parent().findParentMapper(fieldName); - if (parent == null) { - throw new IllegalArgumentException("Field [" + fieldName + "] does not have a parent mapper"); - } - String suffix = parent != context.parent() ? fieldName.substring(parent.fullPath().length() + 1) : fieldName; - var mapper = parent.getMapper(suffix); + var mapper = context.mappingLookup().getMapper(fieldName); if (mapper instanceof SemanticTextFieldMapper fieldMapper) { XContentLocation xContentLocation = context.parser().getTokenLocation(); var input = fieldMapper.parseSemanticTextField(context); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapper.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapper.java index 0d2cae9335b74..da52586ad0fda 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapper.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapper.java @@ -42,7 +42,9 @@ import org.elasticsearch.index.mapper.Mapper; import org.elasticsearch.index.mapper.MapperBuilderContext; import org.elasticsearch.index.mapper.MapperMergeContext; +import org.elasticsearch.index.mapper.MapperParsingException; import org.elasticsearch.index.mapper.MappingLookup; +import org.elasticsearch.index.mapper.MappingParserContext; import org.elasticsearch.index.mapper.NestedObjectMapper; import org.elasticsearch.index.mapper.ObjectMapper; import org.elasticsearch.index.mapper.SimpleMappedFieldType; @@ -83,6 +85,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.Function; import static org.elasticsearch.search.SearchService.DEFAULT_SIZE; @@ -119,12 +122,22 @@ public class SemanticTextFieldMapper extends FieldMapper implements InferenceFie public static final TypeParser PARSER = new TypeParser( (n, c) -> new Builder(n, c::bitSetProducer, c.getIndexSettings()), - List.of(notInMultiFields(CONTENT_TYPE), notFromDynamicTemplates(CONTENT_TYPE)) + List.of(validateParserContext(CONTENT_TYPE)) ); + public static BiConsumer validateParserContext(String type) { + return (n, c) -> { + if (InferenceMetadataFieldsMapper.isEnabled(c.getIndexSettings().getSettings()) == false && c.isWithinMultiField()) { + throw new MapperParsingException("Field [" + n + "] of type [" + type + "] can't be used in multifields"); + } + if (c.isFromDynamicTemplate()) { + throw new MapperParsingException("Field [" + n + "] of type [" + type + "] can't be used in dynamic templates"); + } + }; + } + public static class Builder extends FieldMapper.Builder { private final boolean useLegacyFormat; - private final IndexVersion indexVersionCreated; private final Parameter inferenceId = Parameter.stringParam( INFERENCE_ID_FIELD, @@ -178,7 +191,6 @@ public static Builder from(SemanticTextFieldMapper mapper) { public Builder(String name, Function bitSetProducer, IndexSettings indexSettings) { super(name); - this.indexVersionCreated = indexSettings.getIndexVersionCreated(); this.useLegacyFormat = InferenceMetadataFieldsMapper.isEnabled(indexSettings.getSettings()) == false; this.inferenceFieldBuilder = c -> createInferenceField( c, @@ -225,10 +237,10 @@ protected void merge(FieldMapper mergeWith, Conflicts conflicts, MapperMergeCont @Override public SemanticTextFieldMapper build(MapperBuilderContext context) { - if (copyTo.copyToFields().isEmpty() == false) { + if (useLegacyFormat && copyTo.copyToFields().isEmpty() == false) { throw new IllegalArgumentException(CONTENT_TYPE + " field [" + leafName() + "] does not support [copy_to]"); } - if (multiFieldsBuilder.hasMultiFields()) { + if (useLegacyFormat && multiFieldsBuilder.hasMultiFields()) { throw new IllegalArgumentException(CONTENT_TYPE + " field [" + leafName() + "] does not support multi-fields"); } final String fullName = context.buildFullName(leafName()); @@ -247,7 +259,6 @@ public SemanticTextFieldMapper build(MapperBuilderContext context) { searchInferenceId.getValue(), modelSettings.getValue(), inferenceField, - indexVersionCreated, useLegacyFormat, meta.getValue() ), @@ -277,13 +288,33 @@ private SemanticTextFieldMapper copySettings(SemanticTextFieldMapper mapper, Map private SemanticTextFieldMapper(String simpleName, MappedFieldType mappedFieldType, BuilderParams builderParams) { super(simpleName, mappedFieldType, builderParams); + ensureMultiFields(builderParams.multiFields().iterator()); + } + + private void ensureMultiFields(Iterator mappers) { + while (mappers.hasNext()) { + var mapper = mappers.next(); + if (mapper.leafName().equals(CHUNKED_EMBEDDINGS_FIELD)) { + throw new IllegalArgumentException( + "Field [" + + mapper.fullPath() + + "] is already used by another field [" + + fullPath() + + "] internally. Please choose a different name." + ); + } + } } @Override public Iterator iterator() { - List subIterators = new ArrayList<>(); - subIterators.add(fieldType().getInferenceField()); - return subIterators.iterator(); + List mappers = new ArrayList<>(); + Iterator m = super.iterator(); + while (m.hasNext()) { + mappers.add(m.next()); + } + mappers.add(fieldType().getInferenceField()); + return mappers.iterator(); } @Override @@ -352,20 +383,7 @@ void parseCreateFieldFromContext(DocumentParserContext context, SemanticTextFiel final SemanticTextFieldMapper mapper; if (fieldType().getModelSettings() == null) { - context.path().remove(); - Builder builder = (Builder) new Builder( - leafName(), - fieldType().getChunksField().bitsetProducer(), - fieldType().getChunksField().indexSettings() - ).init(this); - try { - mapper = builder.setModelSettings(field.inference().modelSettings()) - .setInferenceId(field.inference().inferenceId()) - .build(context.createDynamicMapperBuilderContext()); - context.addDynamicMapper(mapper); - } finally { - context.path().add(leafName()); - } + mapper = addDynamicUpdate(context, field); } else { Conflicts conflicts = new Conflicts(fullFieldName); canMergeModelSettings(fieldType().getModelSettings(), field.inference().modelSettings(), conflicts); @@ -440,6 +458,32 @@ void parseCreateFieldFromContext(DocumentParserContext context, SemanticTextFiel } } + private SemanticTextFieldMapper addDynamicUpdate(DocumentParserContext context, SemanticTextField field) { + context.path().remove(); + Builder builder = (Builder) getMergeBuilder(); + try { + builder.setModelSettings(field.inference().modelSettings()).setInferenceId(field.inference().inferenceId()); + if (context.mappingLookup().isMultiField(fullPath())) { + // The field is part of a multi-field, so the parent field must also be updated accordingly. + var fieldName = context.path().remove(); + try { + var parentMapper = ((FieldMapper) context.mappingLookup().getMapper(context.mappingLookup().parentField(fullPath()))) + .getMergeBuilder(); + context.addDynamicMapper(parentMapper.addMultiField(builder).build(context.createDynamicMapperBuilderContext())); + return builder.build(context.createDynamicMapperBuilderContext()); + } finally { + context.path().add(fieldName); + } + } else { + var mapper = builder.build(context.createDynamicMapperBuilderContext()); + context.addDynamicMapper(mapper); + return mapper; + } + } finally { + context.path().add(leafName()); + } + } + @Override protected String contentType() { return CONTENT_TYPE; @@ -460,11 +504,14 @@ public InferenceFieldMetadata getMetadata(Set sourcePaths) { @Override protected void doValidate(MappingLookup mappers) { - int parentPathIndex = fullPath().lastIndexOf(leafName()); + String fullPath = mappers.isMultiField(fullPath()) ? mappers.parentField(fullPath()) : fullPath(); + String leafName = mappers.getMapper(fullPath).leafName(); + int parentPathIndex = fullPath.lastIndexOf(leafName); if (parentPathIndex > 0) { + String parentName = fullPath.substring(0, parentPathIndex - 1); // Check that the parent object field allows subobjects. // Subtract one from the parent path index to omit the trailing dot delimiter. - ObjectMapper parentMapper = mappers.objectMappers().get(fullPath().substring(0, parentPathIndex - 1)); + ObjectMapper parentMapper = mappers.objectMappers().get(parentName); if (parentMapper == null) { throw new IllegalStateException(CONTENT_TYPE + " field [" + fullPath() + "] does not have a parent object mapper"); } @@ -482,7 +529,6 @@ public static class SemanticTextFieldType extends SimpleMappedFieldType { private final String searchInferenceId; private final SemanticTextField.ModelSettings modelSettings; private final ObjectMapper inferenceField; - private final IndexVersion indexVersionCreated; private final boolean useLegacyFormat; public SemanticTextFieldType( @@ -491,7 +537,6 @@ public SemanticTextFieldType( String searchInferenceId, SemanticTextField.ModelSettings modelSettings, ObjectMapper inferenceField, - IndexVersion indexVersionCreated, boolean useLegacyFormat, Map meta ) { @@ -500,7 +545,6 @@ public SemanticTextFieldType( this.searchInferenceId = searchInferenceId; this.modelSettings = modelSettings; this.inferenceField = inferenceField; - this.indexVersionCreated = indexVersionCreated; this.useLegacyFormat = useLegacyFormat; } diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapperTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapperTests.java index b056f5880b21a..7ef9c3f910291 100644 --- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapperTests.java +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapperTests.java @@ -182,16 +182,7 @@ protected IngestScriptSupport ingestScriptSupport() { @Override public MappedFieldType getMappedFieldType() { - return new SemanticTextFieldMapper.SemanticTextFieldType( - "field", - "fake-inference-id", - null, - null, - null, - getVersion(), - false, - Map.of() - ); + return new SemanticTextFieldMapper.SemanticTextFieldType("field", "fake-inference-id", null, null, null, false, Map.of()); } @Override @@ -306,17 +297,78 @@ public void testInvalidInferenceEndpoints() { } } - public void testCannotBeUsedInMultiFields() { - Exception e = expectThrows(MapperParsingException.class, () -> createMapperService(fieldMapping(b -> { - b.field("type", "text"); - b.startObject("fields"); - b.startObject("semantic"); - b.field("type", "semantic_text"); - b.field("inference_id", "my_inference_id"); - b.endObject(); - b.endObject(); - }), useLegacyFormat)); - assertThat(e.getMessage(), containsString("Field [semantic] of type [semantic_text] can't be used in multifields")); + public void testMultiFieldsSupport() throws IOException { + if (useLegacyFormat) { + Exception e = expectThrows(MapperParsingException.class, () -> createMapperService(fieldMapping(b -> { + b.field("type", "text"); + b.startObject("fields"); + b.startObject("semantic"); + b.field("type", "semantic_text"); + b.field("inference_id", "my_inference_id"); + b.endObject(); + b.endObject(); + }), useLegacyFormat)); + assertThat(e.getMessage(), containsString("Field [semantic] of type [semantic_text] can't be used in multifields")); + } else { + var mapperService = createMapperService(fieldMapping(b -> { + b.field("type", "text"); + b.startObject("fields"); + b.startObject("semantic"); + b.field("type", "semantic_text"); + b.field("inference_id", "my_inference_id"); + b.startObject("model_settings"); + b.field("task_type", "sparse_embedding"); + b.endObject(); + b.endObject(); + b.endObject(); + }), useLegacyFormat); + assertSemanticTextField(mapperService, "field.semantic", true); + + mapperService = createMapperService(fieldMapping(b -> { + b.field("type", "semantic_text"); + b.field("inference_id", "my_inference_id"); + b.startObject("model_settings"); + b.field("task_type", "sparse_embedding"); + b.endObject(); + b.startObject("fields"); + b.startObject("text"); + b.field("type", "text"); + b.endObject(); + b.endObject(); + }), useLegacyFormat); + assertSemanticTextField(mapperService, "field", true); + + mapperService = createMapperService(fieldMapping(b -> { + b.field("type", "semantic_text"); + b.field("inference_id", "my_inference_id"); + b.startObject("model_settings"); + b.field("task_type", "sparse_embedding"); + b.endObject(); + b.startObject("fields"); + b.startObject("semantic"); + b.field("type", "semantic_text"); + b.field("inference_id", "another_inference_id"); + b.startObject("model_settings"); + b.field("task_type", "sparse_embedding"); + b.endObject(); + b.endObject(); + b.endObject(); + }), useLegacyFormat); + assertSemanticTextField(mapperService, "field", true); + assertSemanticTextField(mapperService, "field.semantic", true); + + Exception e = expectThrows(MapperParsingException.class, () -> createMapperService(fieldMapping(b -> { + b.field("type", "semantic_text"); + b.field("inference_id", "my_inference_id"); + b.startObject("fields"); + b.startObject("embeddings"); + b.field("type", "text"); + b.endObject(); + b.endObject(); + }), useLegacyFormat)); + assertThat(e.getMessage(), containsString("is already used by another field")); + + } } public void testUpdatesToInferenceIdNotSupported() throws IOException { diff --git a/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping.yml b/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping.yml index 5b1f1b9509d9d..fcbeab9262b20 100644 --- a/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping.yml +++ b/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/10_semantic_text_field_mapping.yml @@ -265,57 +265,6 @@ setup: - exists: fields.dense_field - match: { fields.dense_field.text.searchable: true } ---- -"Can't be used as a multifield": - - - do: - catch: /Field \[semantic\] of type \[semantic_text\] can't be used in multifields/ - indices.create: - index: test-multi-index - body: - mappings: - properties: - text_field: - type: text - fields: - semantic: - type: semantic_text - inference_id: sparse-inference-id - ---- -"Can't have multifields": - - - do: - catch: /semantic_text field \[semantic\] does not support multi-fields/ - indices.create: - index: test-multi-index - body: - mappings: - properties: - semantic: - type: semantic_text - inference_id: sparse-inference-id - fields: - keyword_field: - type: keyword - ---- -"Can't configure copy_to in semantic_text": - - - do: - catch: /semantic_text field \[semantic\] does not support \[copy_to\]/ - indices.create: - index: test-copy_to-index - body: - mappings: - properties: - semantic: - type: semantic_text - inference_id: sparse-inference-id - copy_to: another_field - another_field: - type: keyword - --- "Cannot be used directly as a nested field": diff --git a/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/30_semantic_text_inference.yml b/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/30_semantic_text_inference.yml index 1adf0d58de8e3..c67327d9447f9 100644 --- a/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/30_semantic_text_inference.yml +++ b/x-pack/plugin/inference/src/yamlRestTest/resources/rest-api-spec/test/inference/30_semantic_text_inference.yml @@ -852,3 +852,108 @@ setup: - match: { hits.total.value: 1 } - match: { hits.total.relation: eq } - match: { hits.hits.0._source.dense_field: "updated text" } + +--- +"Multi-fields support": + + - do: + indices.create: + index: test-multi-index + body: + settings: + index: + mapping: + semantic_text: + use_legacy_format: false + mappings: + properties: + field: + type: text + fields: + sparse: + type: semantic_text + inference_id: sparse-inference-id + dense: + type: semantic_text + inference_id: dense-inference-id + non_inference_field: + type: text + + - do: + bulk: + index: test-multi-index + refresh: true + body: | + {"index":{"_id": "1"}} + {"field": ["you know, for testing", "now with chunks"]} + {"index":{"_id": "2"}} + {"field": ["some more tests", "that include chunks"]} + + - do: + search: + index: test-multi-index + body: + query: + semantic: + field: field.sparse + query: "you know, for testing" + highlight: + fields: + "field.sparse": { + "type": "semantic", + "number_of_fragments": 2 + } + + - match: { hits.total.value: 2 } + - match: { hits.total.relation: eq } + - length: { hits.hits.0.highlight.field\.sparse: 2 } + - match: { hits.hits.0.highlight.field\.sparse.0: "you know, for testing" } + - match: { hits.hits.0.highlight.field\.sparse.1: "now with chunks" } + - length: { hits.hits.1.highlight.field\.sparse: 2 } + - match: { hits.hits.1.highlight.field\.sparse.0: "some more tests" } + - match: { hits.hits.1.highlight.field\.sparse.1: "that include chunks" } + + - do: + search: + index: test-multi-index + body: + query: + semantic: + field: field.dense + query: "you know, for testing" + highlight: + fields: + "field.dense": { + "type": "semantic", + "number_of_fragments": 2 + } + + - match: { hits.total.value: 2 } + - match: { hits.total.relation: eq } + - length: { hits.hits.0.highlight.field\.dense: 2 } + - match: { hits.hits.0.highlight.field\.dense.0: "you know, for testing" } + - match: { hits.hits.0.highlight.field\.dense.1: "now with chunks" } + - length: { hits.hits.1.highlight.field\.dense: 2 } + - match: { hits.hits.1.highlight.field\.dense.0: "some more tests" } + - match: { hits.hits.1.highlight.field\.dense.1: "that include chunks" } + + - do: + search: + index: test-multi-index + body: + query: + match: + field: "you know tests, with chunks" + highlight: + fields: + field: + number_of_fragments: 2 + + - match: { hits.total.value: 2 } + - match: { hits.total.relation: eq } + - length: { hits.hits.0.highlight.field: 2 } + - match: { hits.hits.0.highlight.field.0: "you know, for testing" } + - match: { hits.hits.0.highlight.field.1: "now with chunks" } + - length: { hits.hits.1.highlight.field: 2 } + - match: { hits.hits.1.highlight.field.0: "some more tests" } + - match: { hits.hits.1.highlight.field.1: "that include chunks" }