Skip to content

Commit f99321b

Browse files
authored
Support semantic_text in object fields (#114601)
1 parent d3fcead commit f99321b

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
@@ -36,4 +36,8 @@ public Set<NodeFeature> getFeatures() {
3636
return Set.copyOf(features);
3737
}
3838

39+
@Override
40+
public Set<NodeFeature> getTestFeatures() {
41+
return Set.of(SemanticTextFieldMapper.SEMANTIC_TEXT_IN_OBJECT_FIELD_FIX);
42+
}
3943
}

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;
@@ -86,6 +87,7 @@
8687
public class SemanticTextFieldMapper extends FieldMapper implements InferenceFieldMapper {
8788
public static final NodeFeature SEMANTIC_TEXT_SEARCH_INFERENCE_ID = new NodeFeature("semantic_text.search_inference_id");
8889
public static final NodeFeature SEMANTIC_TEXT_DEFAULT_ELSER_2 = new NodeFeature("semantic_text.default_elser_2");
90+
public static final NodeFeature SEMANTIC_TEXT_IN_OBJECT_FIELD_FIX = new NodeFeature("semantic_text.in_object_field_fix");
8991

9092
public static final String CONTENT_TYPE = "semantic_text";
9193
public static final String DEFAULT_ELSER_2_INFERENCE_ID = DEFAULT_ELSER_ID;
@@ -398,6 +400,25 @@ public Object getOriginalValue(Map<String, Object> sourceAsMap) {
398400
return XContentMapValues.extractValue(TEXT_FIELD, fieldValueMap);
399401
}
400402

403+
@Override
404+
protected void doValidate(MappingLookup mappers) {
405+
int parentPathIndex = fullPath().lastIndexOf(leafName());
406+
if (parentPathIndex > 0) {
407+
// Check that the parent object field allows subobjects.
408+
// Subtract one from the parent path index to omit the trailing dot delimiter.
409+
ObjectMapper parentMapper = mappers.objectMappers().get(fullPath().substring(0, parentPathIndex - 1));
410+
if (parentMapper == null) {
411+
throw new IllegalStateException(CONTENT_TYPE + " field [" + fullPath() + "] does not have a parent object mapper");
412+
}
413+
414+
if (parentMapper.subobjects() == ObjectMapper.Subobjects.DISABLED) {
415+
throw new IllegalArgumentException(
416+
CONTENT_TYPE + " field [" + fullPath() + "] cannot be in an object field with subobjects disabled"
417+
);
418+
}
419+
}
420+
}
421+
401422
public static class SemanticTextFieldType extends SimpleMappedFieldType {
402423
private final String inferenceId;
403424
private final String searchInferenceId;
@@ -592,6 +613,116 @@ private String generateInvalidQueryInferenceResultsMessage(StringBuilder baseMes
592613
}
593614
}
594615

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

0 commit comments

Comments
 (0)