Skip to content

Commit 8933c15

Browse files
authored
Allow including semantic field embeddings in _source (#134717)
Adds support for returning `_inference_fields` (embeddings for `semantic_text` fields) as part of `_source` when `_source.exclude_vectors` is explicitly set to `false`. This enables use cases like reindexing documents without recomputing embeddings. By default, embeddings remain excluded.
1 parent 413025e commit 8933c15

File tree

16 files changed

+458
-106
lines changed

16 files changed

+458
-106
lines changed

docs/changelog/134717.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 134717
2+
summary: Allow including semantic field embeddings in `_source`
3+
area: Vector Search
4+
type: enhancement
5+
issues: []

docs/reference/elasticsearch/mapping-reference/semantic-text.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,95 @@ If you want to avoid unnecessary inference and keep existing embeddings:
413413
* Use **partial updates through the Bulk API**.
414414
* Omit any `semantic_text` fields that did not change from the `doc` object in your request.
415415

416+
## Returning semantic field embeddings in `_source`
417+
418+
```{applies_to}
419+
stack: ga 9.2
420+
serverless: ga
421+
```
422+
423+
By default, the embeddings generated for `semantic_text` fields are stored internally and **not included in `_source`** when retrieving documents.
424+
425+
To include the full inference fields, including their embeddings, in `_source`, set the `_source.exclude_vectors` option to `false`.
426+
This works with the
427+
[Get](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-get),
428+
[Search](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search),
429+
and
430+
[Reindex](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex)
431+
APIs.
432+
433+
```console
434+
POST my-index/_search
435+
{
436+
"_source": {
437+
"exclude_vectors": false
438+
},
439+
"query": {
440+
"match_all": {}
441+
}
442+
}
443+
```
444+
445+
The embeddings will appear under `_inference_fields` in `_source`.
446+
447+
**Use cases**
448+
Including embeddings in `_source` is useful when you want to:
449+
450+
* Reindex documents into another index **with the same `inference_id`** without re-running inference.
451+
* Export or migrate documents while preserving their embeddings.
452+
* Inspect or debug the raw embeddings generated for your content.
453+
454+
### Example: Reindex while preserving embeddings
455+
456+
```console
457+
POST _reindex
458+
{
459+
"source": {
460+
"index": "my-index-src",
461+
"_source": {
462+
"exclude_vectors": false <1>
463+
}
464+
},
465+
"dest": {
466+
"index": "my-index-dest"
467+
}
468+
}
469+
```
470+
471+
1. Sends the source documents with their stored embeddings to the destination index.
472+
473+
::::{warning}
474+
If the target index’s `semantic_text` field does **not** use the **same `inference_id`** as the source index,
475+
the documents will **fail the reindex task**.
476+
Matching `inference_id` values are required to reuse the existing embeddings.
477+
::::
478+
479+
This allows documents to be re-indexed without triggering inference again, **as long as the target `semantic_text` field uses the same `inference_id` as the source**.
480+
481+
::::{note}
482+
**For versions prior to 9.2.0**
483+
484+
Older versions do not support the `exclude_vectors` option to retrieve the embeddings of the semantic text fields.
485+
To return the `_inference_fields`, use the `fields` option in a search request instead:
486+
487+
```console
488+
POST test-index/_search
489+
{
490+
"query": {
491+
"match": {
492+
"my_semantic_field": "Which country is Paris in?"
493+
}
494+
},
495+
"fields": [
496+
"_inference_fields"
497+
]
498+
}
499+
```
500+
501+
This returns the chunked embeddings used for semantic search under `_inference_fields` in `_source`.
502+
Note that the `fields` option is **not** available for the Reindex API.
503+
::::
504+
416505
## Customizing `semantic_text` indexing [custom-indexing]
417506

418507
`semantic_text` uses defaults for indexing data based on the {{infer}} endpoint

modules/reindex/src/main/java/org/elasticsearch/reindex/AbstractAsyncBulkByScrollAction.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ static <Request extends AbstractBulkByScrollRequest<Request>> SearchRequest prep
214214
// always include vectors in the response unless explicitly set
215215
var fetchSource = sourceBuilder.fetchSource();
216216
if (fetchSource == null) {
217-
sourceBuilder.fetchSource(FetchSourceContext.FETCH_ALL_SOURCE);
217+
sourceBuilder.fetchSource(FetchSourceContext.FETCH_ALL_SOURCE_EXCLUDE_INFERENCE_FIELDS);
218218
} else if (fetchSource.excludeVectors() == null) {
219219
sourceBuilder.excludeVectors(false);
220220
}

server/src/main/java/org/elasticsearch/TransportVersions.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ static TransportVersion def(int id) {
330330
public static final TransportVersion NEW_SEMANTIC_QUERY_INTERCEPTORS = def(9_162_0_00);
331331
public static final TransportVersion ESQL_LOOKUP_JOIN_ON_EXPRESSION = def(9_163_0_00);
332332
public static final TransportVersion INFERENCE_REQUEST_ADAPTIVE_RATE_LIMITING_REMOVED = def(9_164_0_00);
333+
public static final TransportVersion SEARCH_SOURCE_EXCLUDE_INFERENCE_FIELDS_PARAM = def(9_165_0_00);
333334

334335
/*
335336
* STOP! READ THIS FIRST! No, really,

server/src/main/java/org/elasticsearch/action/bulk/TransportShardBulkAction.java

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,9 @@
4949
import org.elasticsearch.index.engine.VersionConflictEngineException;
5050
import org.elasticsearch.index.get.GetResult;
5151
import org.elasticsearch.index.mapper.DocumentMapper;
52-
import org.elasticsearch.index.mapper.InferenceMetadataFieldsMapper;
5352
import org.elasticsearch.index.mapper.MapperException;
5453
import org.elasticsearch.index.mapper.MapperService;
5554
import org.elasticsearch.index.mapper.MappingLookup;
56-
import org.elasticsearch.index.mapper.RoutingFieldMapper;
5755
import org.elasticsearch.index.mapper.SourceToParse;
5856
import org.elasticsearch.index.seqno.SequenceNumbers;
5957
import org.elasticsearch.index.shard.IndexShard;
@@ -65,6 +63,7 @@
6563
import org.elasticsearch.node.NodeClosedException;
6664
import org.elasticsearch.plugins.internal.DocumentParsingProvider;
6765
import org.elasticsearch.plugins.internal.XContentMeteringParserDecorator;
66+
import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
6867
import org.elasticsearch.threadpool.ThreadPool;
6968
import org.elasticsearch.transport.TransportRequestOptions;
7069
import org.elasticsearch.transport.TransportService;
@@ -366,8 +365,13 @@ static boolean executeBulkItemRequest(
366365
if (opType == DocWriteRequest.OpType.UPDATE) {
367366
final UpdateRequest updateRequest = (UpdateRequest) context.getCurrent();
368367
try {
369-
var gFields = getStoredFieldsSpec(context.getPrimary());
370-
updateResult = updateHelper.prepare(updateRequest, context.getPrimary(), nowInMillisSupplier, gFields);
368+
updateResult = updateHelper.prepare(
369+
updateRequest,
370+
context.getPrimary(),
371+
nowInMillisSupplier,
372+
// Include inference fields so that partial updates can still retrieve embeddings for fields that weren't updated.
373+
FetchSourceContext.FETCH_ALL_SOURCE
374+
);
371375
} catch (Exception failure) {
372376
// we may fail translating a update to index or delete operation
373377
// we use index result to communicate failure while translating update request
@@ -443,16 +447,6 @@ static boolean executeBulkItemRequest(
443447
return true;
444448
}
445449

446-
private static String[] getStoredFieldsSpec(IndexShard indexShard) {
447-
if (InferenceMetadataFieldsMapper.isEnabled(indexShard.mapperService().mappingLookup())) {
448-
if (indexShard.mapperService().mappingLookup().inferenceFields().size() > 0) {
449-
// Retrieves the inference metadata field containing the inference results for all semantic fields defined in the mapping.
450-
return new String[] { RoutingFieldMapper.NAME, InferenceMetadataFieldsMapper.NAME };
451-
}
452-
}
453-
return new String[] { RoutingFieldMapper.NAME };
454-
}
455-
456450
private static boolean handleMappingUpdateRequired(
457451
BulkPrimaryExecutionContext context,
458452
MappingUpdatePerformer mappingUpdater,

server/src/main/java/org/elasticsearch/action/update/TransportUpdateAction.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import org.elasticsearch.indices.IndicesService;
5555
import org.elasticsearch.injection.guice.Inject;
5656
import org.elasticsearch.rest.RestStatus;
57+
import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
5758
import org.elasticsearch.tasks.Task;
5859
import org.elasticsearch.threadpool.ThreadPool;
5960
import org.elasticsearch.threadpool.ThreadPool.Names;
@@ -215,7 +216,14 @@ protected void shardOperation(final UpdateRequest request, final ActionListener<
215216
assert ThreadPool.assertCurrentThreadPool(Names.SYSTEM_WRITE, Names.WRITE);
216217
return deleteInferenceResults(
217218
request,
218-
updateHelper.prepare(request, indexShard, threadPool::absoluteTimeInMillis), // Gets the doc using the engine
219+
// Gets the doc using the engine
220+
updateHelper.prepare(
221+
request,
222+
indexShard,
223+
threadPool::absoluteTimeInMillis,
224+
// Exclude inference fields to ensure embeddings are recomputed.
225+
FetchSourceContext.FETCH_ALL_SOURCE_EXCLUDE_INFERENCE_FIELDS
226+
),
219227
indexService.getMetadata(),
220228
mappingLookup
221229
);

server/src/main/java/org/elasticsearch/action/update/UpdateHelper.java

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.elasticsearch.script.UpdateCtxMap;
3434
import org.elasticsearch.script.UpdateScript;
3535
import org.elasticsearch.script.UpsertCtxMap;
36+
import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
3637
import org.elasticsearch.search.lookup.Source;
3738
import org.elasticsearch.search.lookup.SourceFilter;
3839
import org.elasticsearch.xcontent.XContentType;
@@ -58,16 +59,10 @@ public UpdateHelper(ScriptService scriptService) {
5859
/**
5960
* Prepares an update request by converting it into an index or delete request or an update response (no action).
6061
*/
61-
public Result prepare(UpdateRequest request, IndexShard indexShard, LongSupplier nowInMillis) throws IOException {
62-
// TODO: Don't hard-code gFields
63-
return prepare(request, indexShard, nowInMillis, new String[] { RoutingFieldMapper.NAME });
64-
}
65-
66-
/**
67-
* Prepares an update request by converting it into an index or delete request or an update response (no action).
68-
*/
69-
public Result prepare(UpdateRequest request, IndexShard indexShard, LongSupplier nowInMillis, String[] gFields) throws IOException {
70-
final GetResult getResult = indexShard.getService().getForUpdate(request.id(), request.ifSeqNo(), request.ifPrimaryTerm(), gFields);
62+
public Result prepare(UpdateRequest request, IndexShard indexShard, LongSupplier nowInMillis, FetchSourceContext fetchSourceContext)
63+
throws IOException {
64+
final GetResult getResult = indexShard.getService()
65+
.getForUpdate(request.id(), request.ifSeqNo(), request.ifPrimaryTerm(), fetchSourceContext);
7166
return prepare(indexShard, request, getResult, nowInMillis);
7267
}
7368

server/src/main/java/org/elasticsearch/index/get/ShardGetService.java

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
import java.util.Set;
5959
import java.util.concurrent.TimeUnit;
6060
import java.util.function.Function;
61-
import java.util.stream.Collectors;
6261

6362
import static org.elasticsearch.index.IndexSettings.INDEX_MAPPING_EXCLUDE_SOURCE_VECTORS_SETTING;
6463
import static org.elasticsearch.index.seqno.SequenceNumbers.UNASSIGNED_PRIMARY_TERM;
@@ -217,16 +216,16 @@ public GetResult getFromTranslog(
217216
);
218217
}
219218

220-
public GetResult getForUpdate(String id, long ifSeqNo, long ifPrimaryTerm, String[] gFields) throws IOException {
219+
public GetResult getForUpdate(String id, long ifSeqNo, long ifPrimaryTerm, FetchSourceContext fetchSourceContext) throws IOException {
221220
return doGet(
222221
id,
223-
gFields,
222+
new String[] { RoutingFieldMapper.NAME },
224223
true,
225224
Versions.MATCH_ANY,
226225
VersionType.INTERNAL,
227226
ifSeqNo,
228227
ifPrimaryTerm,
229-
FetchSourceContext.FETCH_ALL_SOURCE,
228+
fetchSourceContext,
230229
false,
231230
indexShard::get
232231
);
@@ -290,14 +289,8 @@ private GetResult innerGetFetch(
290289
// check first if stored fields to be loaded don't contain an object field
291290
MappingLookup mappingLookup = mapperService.mappingLookup();
292291
final Set<String> storedFieldSet = new HashSet<>();
293-
boolean hasInferenceMetadataFields = false;
294292
if (storedFields != null) {
295293
for (String field : storedFields) {
296-
if (field.equals(InferenceMetadataFieldsMapper.NAME)
297-
&& InferenceMetadataFieldsMapper.isEnabled(indexShard.mapperService().mappingLookup())) {
298-
hasInferenceMetadataFields = true;
299-
continue;
300-
}
301294
Mapper fieldMapper = mappingLookup.getMapper(field);
302295
if (fieldMapper == null) {
303296
if (mappingLookup.objectMappers().get(field) != null) {
@@ -313,10 +306,16 @@ private GetResult innerGetFetch(
313306
Map<String, DocumentField> metadataFields = null;
314307
DocIdAndVersion docIdAndVersion = get.docIdAndVersion();
315308

316-
var res = maybeExcludeSyntheticVectorFields(mappingLookup, indexSettings, fetchSourceContext, null);
309+
var res = maybeExcludeVectorFields(mappingLookup, indexSettings, fetchSourceContext, null);
317310
if (res.v1() != fetchSourceContext) {
318311
fetchSourceContext = res.v1();
319312
}
313+
314+
if (mappingLookup.inferenceFields().isEmpty() == false
315+
&& shouldExcludeInferenceFieldsFromSource(indexSettings, fetchSourceContext) == false) {
316+
storedFieldSet.add(InferenceMetadataFieldsMapper.NAME);
317+
}
318+
320319
var sourceFilter = res.v2();
321320
SourceLoader loader = forceSyntheticSource
322321
? new SourceLoader.Synthetic(
@@ -389,7 +388,7 @@ private GetResult innerGetFetch(
389388
source = source.filter(filter);
390389
}
391390

392-
if (hasInferenceMetadataFields) {
391+
if (storedFieldSet.contains(InferenceMetadataFieldsMapper.NAME)) {
393392
/**
394393
* Adds the {@link InferenceMetadataFieldsMapper#NAME} field from the document fields
395394
* to the original _source if it has been requested.
@@ -417,18 +416,29 @@ private GetResult innerGetFetch(
417416
* Returns {@code true} if vector fields are explicitly marked to be excluded and {@code false} otherwise.
418417
*/
419418
public static boolean shouldExcludeVectorsFromSource(IndexSettings indexSettings, FetchSourceContext fetchSourceContext) {
420-
if (fetchSourceContext == null || fetchSourceContext.excludeVectors() == null) {
421-
return INDEX_MAPPING_EXCLUDE_SOURCE_VECTORS_SETTING.get(indexSettings.getSettings());
422-
}
423-
return fetchSourceContext.excludeVectors();
419+
var explicit = shouldExcludeVectorsFromSourceExplicit(fetchSourceContext);
420+
return explicit != null ? explicit : INDEX_MAPPING_EXCLUDE_SOURCE_VECTORS_SETTING.get(indexSettings.getSettings());
421+
}
422+
423+
private static Boolean shouldExcludeVectorsFromSourceExplicit(FetchSourceContext fetchSourceContext) {
424+
return fetchSourceContext != null ? fetchSourceContext.excludeVectors() : null;
425+
}
426+
427+
public static boolean shouldExcludeInferenceFieldsFromSource(IndexSettings indexSettings, FetchSourceContext fetchSourceContext) {
428+
var explicit = shouldExcludeInferenceFieldsFromSourceExplicit(fetchSourceContext);
429+
return explicit != null ? explicit : INDEX_MAPPING_EXCLUDE_SOURCE_VECTORS_SETTING.get(indexSettings.getSettings());
430+
}
431+
432+
private static Boolean shouldExcludeInferenceFieldsFromSourceExplicit(FetchSourceContext fetchSourceContext) {
433+
return fetchSourceContext != null ? fetchSourceContext.excludeInferenceFields() : null;
424434
}
425435

426436
/**
427437
* Returns a {@link SourceFilter} that excludes vector fields not associated with semantic text fields,
428438
* unless vectors are explicitly requested to be included in the source.
429439
* Returns {@code null} when vectors should not be filtered out.
430440
*/
431-
public static Tuple<FetchSourceContext, SourceFilter> maybeExcludeSyntheticVectorFields(
441+
public static Tuple<FetchSourceContext, SourceFilter> maybeExcludeVectorFields(
432442
MappingLookup mappingLookup,
433443
IndexSettings indexSettings,
434444
FetchSourceContext fetchSourceContext,
@@ -457,7 +467,7 @@ public static Tuple<FetchSourceContext, SourceFilter> maybeExcludeSyntheticVecto
457467
}
458468
// Exclude vectors from semantic text fields, as they are processed separately
459469
return inferenceFieldsAut == null || inferenceFieldsAut.run(f.name()) == false;
460-
}).map(f -> f.name()).collect(Collectors.toList());
470+
}).map(MappedFieldType::name).toList();
461471

462472
var sourceFilter = excludes.isEmpty() ? null : new SourceFilter(new String[] {}, excludes.toArray(String[]::new));
463473
if (lateExcludes.size() > 0) {
@@ -466,15 +476,14 @@ public static Tuple<FetchSourceContext, SourceFilter> maybeExcludeSyntheticVecto
466476
* This ensures that vector fields are available to sub-fetch phases, but excluded during the {@link FetchSourcePhase}.
467477
*/
468478
if (fetchSourceContext != null && fetchSourceContext.excludes() != null) {
469-
for (var exclude : fetchSourceContext.excludes()) {
470-
lateExcludes.add(exclude);
471-
}
479+
lateExcludes.addAll(Arrays.asList(fetchSourceContext.excludes()));
472480
}
473481
var newFetchSourceContext = fetchSourceContext == null
474482
? FetchSourceContext.of(true, false, null, lateExcludes.toArray(String[]::new))
475483
: FetchSourceContext.of(
476484
fetchSourceContext.fetchSource(),
477485
fetchSourceContext.excludeVectors(),
486+
fetchSourceContext.excludeInferenceFields(),
478487
fetchSourceContext.includes(),
479488
lateExcludes.toArray(String[]::new)
480489
);

server/src/main/java/org/elasticsearch/search/builder/SearchSourceBuilder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,7 @@ public SearchSourceBuilder excludeVectors(boolean excludeVectors) {
945945
this.fetchSourceContext = FetchSourceContext.of(
946946
fetchSourceContext.fetchSource(),
947947
excludeVectors,
948+
fetchSourceContext.excludeInferenceFields(),
948949
fetchSourceContext.includes(),
949950
fetchSourceContext.excludes()
950951
);

0 commit comments

Comments
 (0)