Skip to content

Commit 310f67f

Browse files
authored
[8.x] Support semantic_text in object fields (#114601) (#115040)
* Support semantic_text in object fields (#114601) * Fix build error * Fix test build error
1 parent 1d8c61f commit 310f67f

File tree

7 files changed

+542
-1
lines changed

7 files changed

+542
-1
lines changed

docs/changelog/114601.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 114601
2+
summary: Support semantic_text in object fields
3+
area: Vector Search
4+
type: bug
5+
issues:
6+
- 114401

x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceFeatures.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,8 @@ public Set<NodeFeature> getFeatures() {
3131
);
3232
}
3333

34+
@Override
35+
public Set<NodeFeature> getTestFeatures() {
36+
return Set.of(SemanticTextFieldMapper.SEMANTIC_TEXT_IN_OBJECT_FIELD_FIX);
37+
}
3438
}

x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
397397
),
398398
indexRequest.getContentType()
399399
);
400-
newDocMap.put(fieldName, result);
400+
SemanticTextFieldMapper.insertValue(fieldName, newDocMap, result);
401401
}
402402
indexRequest.source(newDocMap, indexRequest.getContentType());
403403
}

x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldMapper.java

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.elasticsearch.index.mapper.Mapper;
3333
import org.elasticsearch.index.mapper.MapperBuilderContext;
3434
import org.elasticsearch.index.mapper.MapperMergeContext;
35+
import org.elasticsearch.index.mapper.MappingLookup;
3536
import org.elasticsearch.index.mapper.NestedObjectMapper;
3637
import org.elasticsearch.index.mapper.ObjectMapper;
3738
import org.elasticsearch.index.mapper.SimpleMappedFieldType;
@@ -85,6 +86,7 @@
8586
public class SemanticTextFieldMapper extends FieldMapper implements InferenceFieldMapper {
8687
public static final NodeFeature SEMANTIC_TEXT_SEARCH_INFERENCE_ID = new NodeFeature("semantic_text.search_inference_id");
8788
public static final NodeFeature SEMANTIC_TEXT_DEFAULT_ELSER_2 = new NodeFeature("semantic_text.default_elser_2");
89+
public static final NodeFeature SEMANTIC_TEXT_IN_OBJECT_FIELD_FIX = new NodeFeature("semantic_text.in_object_field_fix");
8890

8991
public static final String CONTENT_TYPE = "semantic_text";
9092
public static final String DEFAULT_ELSER_2_INFERENCE_ID = DEFAULT_ELSER_ID;
@@ -393,6 +395,25 @@ public Object getOriginalValue(Map<String, Object> sourceAsMap) {
393395
return XContentMapValues.extractValue(TEXT_FIELD, fieldValueMap);
394396
}
395397

398+
@Override
399+
protected void doValidate(MappingLookup mappers) {
400+
int parentPathIndex = fullPath().lastIndexOf(leafName());
401+
if (parentPathIndex > 0) {
402+
// Check that the parent object field allows subobjects.
403+
// Subtract one from the parent path index to omit the trailing dot delimiter.
404+
ObjectMapper parentMapper = mappers.objectMappers().get(fullPath().substring(0, parentPathIndex - 1));
405+
if (parentMapper == null) {
406+
throw new IllegalStateException(CONTENT_TYPE + " field [" + fullPath() + "] does not have a parent object mapper");
407+
}
408+
409+
if (parentMapper.subobjects() == ObjectMapper.Subobjects.DISABLED) {
410+
throw new IllegalArgumentException(
411+
CONTENT_TYPE + " field [" + fullPath() + "] cannot be in an object field with subobjects disabled"
412+
);
413+
}
414+
}
415+
}
416+
396417
public static class SemanticTextFieldType extends SimpleMappedFieldType {
397418
private final String inferenceId;
398419
private final String searchInferenceId;
@@ -587,6 +608,116 @@ private String generateInvalidQueryInferenceResultsMessage(StringBuilder baseMes
587608
}
588609
}
589610

611+
/**
612+
* <p>
613+
* Insert or replace the path's value in the map with the provided new value. The map will be modified in-place.
614+
* If the complete path does not exist in the map, it will be added to the deepest (sub-)map possible.
615+
* </p>
616+
* <p>
617+
* For example, given the map:
618+
* </p>
619+
* <pre>
620+
* {
621+
* "path1": {
622+
* "path2": {
623+
* "key1": "value1"
624+
* }
625+
* }
626+
* }
627+
* </pre>
628+
* <p>
629+
* And the caller wanted to insert {@code "path1.path2.path3.key2": "value2"}, the method would emit the modified map:
630+
* </p>
631+
* <pre>
632+
* {
633+
* "path1": {
634+
* "path2": {
635+
* "key1": "value1",
636+
* "path3.key2": "value2"
637+
* }
638+
* }
639+
* }
640+
* </pre>
641+
*
642+
* @param path the value's path in the map.
643+
* @param map the map to search and modify in-place.
644+
* @param newValue the new value to assign to the path.
645+
*
646+
* @throws IllegalArgumentException If either the path cannot be fully traversed or there is ambiguity about where to insert the new
647+
* value.
648+
*/
649+
public static void insertValue(String path, Map<?, ?> map, Object newValue) {
650+
String[] pathElements = path.split("\\.");
651+
if (pathElements.length == 0) {
652+
return;
653+
}
654+
655+
List<SuffixMap> suffixMaps = extractSuffixMaps(pathElements, 0, map);
656+
if (suffixMaps.isEmpty()) {
657+
// This should never happen. Throw in case it does for some reason.
658+
throw new IllegalStateException("extractSuffixMaps returned an empty suffix map list");
659+
} else if (suffixMaps.size() == 1) {
660+
SuffixMap suffixMap = suffixMaps.get(0);
661+
suffixMap.map().put(suffixMap.suffix(), newValue);
662+
} else {
663+
throw new IllegalArgumentException(
664+
"Path [" + path + "] could be inserted in " + suffixMaps.size() + " distinct ways, it is ambiguous which one to use"
665+
);
666+
}
667+
}
668+
669+
private record SuffixMap(String suffix, Map<String, Object> map) {}
670+
671+
private static List<SuffixMap> extractSuffixMaps(String[] pathElements, int index, Object currentValue) {
672+
if (currentValue instanceof List<?> valueList) {
673+
List<SuffixMap> suffixMaps = new ArrayList<>(valueList.size());
674+
for (Object o : valueList) {
675+
suffixMaps.addAll(extractSuffixMaps(pathElements, index, o));
676+
}
677+
678+
return suffixMaps;
679+
} else if (currentValue instanceof Map<?, ?>) {
680+
@SuppressWarnings("unchecked")
681+
Map<String, Object> map = (Map<String, Object>) currentValue;
682+
List<SuffixMap> suffixMaps = new ArrayList<>(map.size());
683+
684+
String key = pathElements[index];
685+
while (index < pathElements.length) {
686+
if (map.containsKey(key)) {
687+
if (index + 1 == pathElements.length) {
688+
// We found the complete path
689+
suffixMaps.add(new SuffixMap(key, map));
690+
} else {
691+
// We've matched that path partially, keep traversing to try to match it fully
692+
suffixMaps.addAll(extractSuffixMaps(pathElements, index + 1, map.get(key)));
693+
}
694+
}
695+
696+
if (++index < pathElements.length) {
697+
key += "." + pathElements[index];
698+
}
699+
}
700+
701+
if (suffixMaps.isEmpty()) {
702+
// We checked for all remaining elements in the path, and they do not exist. This means we found a leaf map that we should
703+
// add the value to.
704+
suffixMaps.add(new SuffixMap(key, map));
705+
}
706+
707+
return suffixMaps;
708+
} else {
709+
throw new IllegalArgumentException(
710+
"Path ["
711+
+ String.join(".", Arrays.copyOfRange(pathElements, 0, index))
712+
+ "] has value ["
713+
+ currentValue
714+
+ "] of type ["
715+
+ currentValue.getClass().getSimpleName()
716+
+ "], which cannot be traversed into further"
717+
);
718+
}
719+
}
720+
590721
private static ObjectMapper createInferenceField(
591722
MapperBuilderContext context,
592723
IndexVersion indexVersionCreated,

0 commit comments

Comments
 (0)