diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/document/DocumentMetadata.java b/spring-ai-commons/src/main/java/org/springframework/ai/document/DocumentMetadata.java
index f4a308a004..0ae1306334 100644
--- a/spring-ai-commons/src/main/java/org/springframework/ai/document/DocumentMetadata.java
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/document/DocumentMetadata.java
@@ -32,7 +32,36 @@ public enum DocumentMetadata {
* The lower the distance, the more they are similar.
* It's the opposite of the similarity score.
*/
- DISTANCE("distance");
+ DISTANCE("distance"),
+
+ /**
+ * Metadata key holding a pointer to content kept outside the vector store.
+ *
+ * Use it when a row's embedding was computed from something that isn't stored
+ * in the row itself — an image, a video frame, an audio clip, or a
+ * document too large to inline. The vector is stored and stays searchable, but
+ * in place of the content the row keeps a reference to where the content really
+ * lives (for example an S3 URI, a CDN URL, or a database key). The store treats
+ * this reference as an opaque string and never resolves it; after a search
+ * returns the row, the application follows the pointer to fetch the content.
+ *
+ * Such a row is created with an empty-text {@link Document} plus this key, and
+ * written with a caller-supplied embedding through {@code VectorStore.upsert}
+ * (which stores the vector as given and never embeds). Note the empty text only
+ * exists to satisfy the document's text-or-media rule; it is not content, so a
+ * document carrying this key is a reference row even though {@code isText()}
+ * reports true.
+ *
+ * Nothing in the framework reads or validates this key. It is an ordinary
+ * metadata entry that the application writes and later resolves, and keeping the
+ * pointer non-empty is up to the caller. A row that still has real text of its own
+ * can be written with {@code VectorStore.add}, which is how a summary or a chunk
+ * keeps a pointer back to the full article. A row with empty text has to go
+ * through {@code upsert}, because {@code add} embeds the text and there is nothing
+ * there to embed.
+ * @since 2.1.0
+ */
+ CONTENT_REF("content_ref");
private final String value;
diff --git a/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentMetadataTests.java b/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentMetadataTests.java
new file mode 100644
index 0000000000..9a980cce2f
--- /dev/null
+++ b/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentMetadataTests.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.document;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DocumentMetadataTests {
+
+ @Test
+ void distanceKey() {
+ assertThat(DocumentMetadata.DISTANCE.value()).isEqualTo("distance");
+ assertThat(DocumentMetadata.DISTANCE).hasToString("distance");
+ }
+
+ @Test
+ void contentRefKey() {
+ assertThat(DocumentMetadata.CONTENT_REF.value()).isEqualTo("content_ref");
+ assertThat(DocumentMetadata.CONTENT_REF).hasToString("content_ref");
+ }
+
+}
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
index a62de7eff8..f4d748f306 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs.adoc
@@ -57,6 +57,8 @@ public interface VectorStore extends DocumentWriter, VectorStoreRetriever {
void add(List documents);
+ default void upsert(List entries) { ... }
+
void delete(List idList);
void delete(Filter.Expression filterExpression);
@@ -70,6 +72,7 @@ public interface VectorStore extends DocumentWriter, VectorStoreRetriever {
----
The `VectorStore` interface combines both read and write operations, allowing you to add, delete, and search for documents in a vector database.
+While `add` computes the embeddings for you, `upsert` writes documents together with embeddings you already have; see <>.
=== SearchRequest Builder
@@ -437,6 +440,120 @@ void load(String sourceFile) {
}
----
+[[writing-precomputed-embeddings]]
+=== Writing Pre-computed Embeddings
+
+`add` computes an embedding for every document inside the store, using the store's embedding model.
+Sometimes you already have the vectors and want to write them as-is: a provider's batch API computed them overnight at half price, another team owns the embedding pipeline, or you are migrating from a system that exports text and vectors together.
+For those cases the `VectorStore` interface offers `upsert`, which takes the document and its vector paired together in an `EmbeddedDocument` and never embeds anything itself:
+
+[source,java]
+----
+public record EmbeddedDocument(Document document, float[] embedding) {
+}
+----
+
+As the name says, `upsert` replaces by id: writing the same id again overwrites the existing row rather than adding a duplicate.
+That makes a re-run with stable ids safe, which is useful for an ingestion job that may be retried after a failure.
+
+[source,java]
+----
+@Autowired
+VectorStore vectorStore;
+
+// a vector you computed elsewhere
+float[] embedding = ...;
+Document document = new Document("8f14e45f-ceea-467a-9a3e-5b1c2d6f7a90", "some text",
+ Map.of("source", "manual"));
+
+this.vectorStore.upsert(List.of(new EmbeddedDocument(document, embedding)));
+----
+
+NOTE: Some stores constrain the document id.
+Qdrant requires a UUID, and `PgVectorStore` stores the id in a UUID column unless you configure `idType`, so an arbitrary string fails on those.
+Deriving the UUID from a key of your own, for example `UUID.nameUUIDFromBytes(key.getBytes(UTF_8))`, keeps the id stable across runs, which is what makes a re-run replace the row rather than add a second one.
+
+To write plain text through `upsert`, embed it first and hand over the result:
+
+[source,java]
+----
+@Autowired
+EmbeddingModel embeddingModel;
+
+float[] vector = this.embeddingModel.embed(document);
+this.vectorStore.upsert(List.of(new EmbeddedDocument(document, vector)));
+----
+
+`upsert` is opt-in per store: the default implementation throws `UnsupportedOperationException`, so a store supports it only once it has been implemented.
+It is available on pgvector, Redis, Elasticsearch, and Qdrant, with more stores to follow.
+When a store knows its index dimension, it checks every vector in the batch against it before writing anything, so a wrong-sized vector fails fast and cannot partially write.
+Where that dimension can only be guessed, the check is left to the database, which rejects the vector with its own error.
+
+Because the vectors come from outside the store, lining them up with the index is yours to get right.
+Three things have to agree, and only the first is checked for you:
+
+Width:: Your embedding model decides the width, and the index has to be created with that same width, for example `dimensions(1024)` on the store builder or a pre-created index whose mapping says 1024.
+A mismatch fails on the first write.
+Vector space:: Nothing records which model produced a row, so vectors written by one model and searched with another come back plausible but meaningless, with no error anywhere.
+Writing the model name and version into metadata costs nothing and tells a later reader what a row actually holds.
+The query side:: `similaritySearch` still embeds the query with the store's own `EmbeddingModel`, so that model has to produce vectors in the same space and of the same width as the ones you upserted.
+
+=== Indexing Non-Text or Oversized Content
+
+With text, the store keeps the document and its vector together in one row: search matches on the vector and hands back the text.
+A multimodal model breaks that assumption. It gives you a searchable vector for an image, a video segment, or an audio clip, but the content itself lives in S3, a CDN, or a database, and there is no text to put in the row.
+The same applies to text that is simply too large for the row, such as a full article whose chunks were indexed.
+
+The row then keeps everything except the content: id, metadata, vector, and a pointer to where the content lives.
+The pointer goes in metadata under `DocumentMetadata.CONTENT_REF`, and the store never interprets it: it is an S3 URI, a CDN URL, or a database key that only your application resolves.
+Such a row is written with `upsert`, because you supply the vector from the multimodal model yourself:
+
+[source,java]
+----
+byte[] frame = ...; // the image/video/audio bytes
+float[] embedding = multimodalModel.embed(frame); // your multimodal model
+
+String key = "video-42/frame-4711";
+
+Document reference = Document.builder()
+ // Stable and UUID-shaped, so a re-run replaces this row
+ .id(UUID.nameUUIDFromBytes(key.getBytes(UTF_8)).toString())
+ .text("") // empty: the content lives elsewhere
+ .metadata(DocumentMetadata.CONTENT_REF.value(), "s3://media/video-42.mp4#t=4711")
+ .metadata("camera", "north-gate") // still filterable like any row
+ .build();
+
+this.vectorStore.upsert(List.of(new EmbeddedDocument(reference, embedding)));
+----
+
+NOTE: The empty text is what makes the document valid; it is not content. A document carrying `CONTENT_REF` is a reference row even though `isText()` reports true. `upsert` requires text for this reason: no store can persist a row that carries none, so a media document is rejected rather than half-written.
+
+The pointer is an ordinary metadata entry.
+Nothing in the framework reads or validates it, so keeping it non-empty is up to you.
+A row that still has real text of its own can be written with `add`, which is how a summary or a chunk keeps a pointer back to the full article.
+A row with empty text has to go through `upsert`, because `add` embeds the text and there is nothing there to embed.
+
+Search is unchanged. `similaritySearch` matches on vectors and returns `Document` s as always, and for these rows the caller follows the pointer:
+
+[source,java]
+----
+List results = this.vectorStore.similaritySearch(SearchRequest.builder()
+ .query("a truck at the north gate after dark") // embedded by the same multimodal model
+ .filterExpression("camera == 'north-gate'")
+ .build());
+
+for (Document doc : results) {
+ String ref = (String) doc.getMetadata().get(DocumentMetadata.CONTENT_REF.value());
+ byte[] frame = mediaStorage.fetch(ref); // S3, CDN, wherever the pointer leads
+}
+----
+
+Text queries against image vectors work when the store's embedding model is the multimodal model's text encoder, so query and content land in the same vector space.
+
+NOTE: Redis returns only the metadata fields declared when the store is built, so `content_ref` has to be declared for the pointer to survive a read: `.metadataFields(MetadataField.tag(DocumentMetadata.CONTENT_REF.value()))`.
+The same goes for any field you filter on, such as `camera` above.
+Adding a field to a store that already has an index means recreating the index.
+
=== Reading from a Vector Store
Later, when a user question is passed into the AI model, a similarity search is done to retrieve similar documents, which are then "stuffed" into the prompt as context for the user's question.
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc
index 51124d8f0d..b2de0c9016 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/elasticsearch.adoc
@@ -214,6 +214,12 @@ is converted into the proprietary Elasticsearch filter format:
(metadata.author:john OR jill) AND metadata.article_type:blog
----
+== Upsert support
+
+`ElasticsearchVectorStore` implements `upsert(List)`, so you can write documents together with embeddings you computed elsewhere instead of having the store embed them.
+Writing the same id again replaces the existing row, so a re-run with stable ids does not create duplicates.
+See xref:api/vectordbs.adoc#writing-precomputed-embeddings[Writing Pre-computed Embeddings] for details.
+
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the Elasticsearch vector store. For this you need to add the `spring-ai-elasticsearch-store` to your project:
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pgvector.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pgvector.adoc
index f1a38f6ac2..a972999193 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pgvector.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pgvector.adoc
@@ -192,6 +192,12 @@ vectorStore.similaritySearch(SearchRequest.builder()
NOTE: These filter expressions are converted into PostgreSQL JSON path expressions for efficient metadata filtering.
+== Upsert support
+
+`PgVectorStore` implements `upsert(List)`, so you can write documents together with embeddings you computed elsewhere instead of having the store embed them.
+Writing the same id again replaces the existing row, so a re-run with stable ids does not create duplicates.
+See xref:api/vectordbs.adoc#writing-precomputed-embeddings[Writing Pre-computed Embeddings] for details.
+
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the `PgVectorStore`.
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc
index 2777e4cef9..25e752228c 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc
@@ -108,6 +108,12 @@ Properties starting with `spring.ai.vectorstore.qdrant.*` are used to configure
|`spring.ai.vectorstore.qdrant.initialize-schema`| Whether to initialize the schema | `false`
|===
+== Upsert support
+
+`QdrantVectorStore` implements `upsert(List)`, so you can write documents together with embeddings you computed elsewhere instead of having the store embed them.
+Writing the same id again replaces the existing row, so a re-run with stable ids does not create duplicates.
+See xref:api/vectordbs.adoc#writing-precomputed-embeddings[Writing Pre-computed Embeddings] for details.
+
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the Qdrant vector store. For this you need to add the `spring-ai-qdrant-store` to your project:
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc
index 791d4efbb4..2ba20fabb7 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc
@@ -187,6 +187,26 @@ is converted into the proprietary Redis filter format:
@country:{UK | NL} @year:[2020 inf]
----
+== Upsert support
+
+`RedisVectorStore` implements `upsert(List)`, so you can write documents together with embeddings you computed elsewhere instead of having the store embed them.
+Writing the same id again replaces the existing row, so a re-run with stable ids does not create duplicates.
+See xref:api/vectordbs.adoc#writing-precomputed-embeddings[Writing Pre-computed Embeddings] for details.
+
+Redis stores every metadata key you write, but a search returns only the fields declared through `metadataFields`, so anything else comes back missing.
+Declare the keys you need returned, not just the ones you filter on:
+
+[source,java]
+----
+RedisVectorStore.builder(jedisClient, embeddingModel)
+ .metadataFields(MetadataField.tag(DocumentMetadata.CONTENT_REF.value()))
+ .initializeSchema(true)
+ .build();
+----
+
+The store logs a warning the first time it writes a document carrying an undeclared field.
+Adding a field to a store that already has an index means recreating the index.
+
== Manual Configuration
Instead of using the Spring Boot auto-configuration, you can manually configure the Redis vector store. For this you need to add the `spring-ai-redis-store` to your project:
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
index 67f3c05096..fee5ff4665 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
@@ -272,14 +272,14 @@ WARNING: If you enable logging of the image prompt data, there's a risk of expos
All vector store implementations in Spring AI are instrumented to provide metrics and distributed tracing data through Micrometer.
The `db.vector.client.operation` observations are recorded when interacting with the Vector Store.
-They measure the time spent on the `query`, `add` and `remove` operations and propagate the related tracing information.
+They measure the time spent on the `query`, `add`, `upsert` and `delete` operations and propagate the related tracing information.
.Low Cardinality Keys
[cols="a,a", stripes=even]
|===
|Name | Description
-|`db.operation.name` | The name of the operation or command being executed. One of `add`, `delete`, or `query`.
+|`db.operation.name` | The name of the operation or command being executed. One of `add`, `upsert`, `delete`, or `query`.
|`db.system` | The database management system (DBMS) product as identified by the client instrumentation. One of `pg_vector`, `azure`, `cassandra`, `chroma`, `elasticsearch`, `milvus`, `neo4j`, `opensearch`, `qdrant`, `redis`, `typesense`, `weaviate`, `pinecone`, `oracle`, `mongodb`, `gemfire`, `simple`.
|`spring.ai.kind` | The kind of framework API in Spring AI: `vector_store`.
|===
@@ -440,7 +440,7 @@ The following shows how base metric names expand to Prometheus time series.
|`db_vector_client_operation_seconds_sum`
|Timer
|seconds
-|Total time spent in vector store operations (add/delete/query)
+|Total time spent in vector store operations (add/upsert/delete/query)
|`db_vector_client_operation_seconds_count`
|Counter
@@ -463,7 +463,7 @@ The following shows how base metric names expand to Prometheus time series.
[cols="2,3", options="header", stripes=even]
|===
|Label | Meaning
-|`db_operation_name` | Operation type (`add`, `delete`, `query`)
+|`db_operation_name` | Operation type (`add`, `upsert`, `delete`, `query`)
|`db_system` | Vector DB/provider (`redis`, `chroma`, `pgvector`, …)
|`spring_ai_kind` | `vector_store`
|===
diff --git a/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/AbstractVectorStoreUpsertTests.java b/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/AbstractVectorStoreUpsertTests.java
new file mode 100644
index 0000000000..45f3c4db8d
--- /dev/null
+++ b/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/AbstractVectorStoreUpsertTests.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.test.vectorstore;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.document.Document;
+import org.springframework.ai.document.DocumentMetadata;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
+import org.springframework.ai.vectorstore.SearchRequest;
+import org.springframework.ai.vectorstore.VectorStore;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Shared verification suite for {@link VectorStore#upsert(List)} implementations, a
+ * sibling to {@link BaseVectorStoreTests}. A concrete store test extends this class,
+ * provides a configured store through {@link #executeTest(Consumer)}, and reports the
+ * embedding dimension its store expects through {@link #embeddingDimensions()} so the
+ * suite can build correctly sized caller vectors.
+ *
+ * The read-back assertions rely on {@link SearchRequest.Builder#similarityThresholdAll()}
+ * to return every stored row regardless of ranking, so they do not depend on the actual
+ * embedding values.
+ *
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+public abstract class AbstractVectorStoreUpsertTests {
+
+ /**
+ * Number of distinct entries upserted by {@link #pairingAcrossBatches()}. Concrete
+ * store tests should configure their store with a smaller batch size than this so the
+ * upsert genuinely spans multiple write batches.
+ */
+ protected static final int MULTI_BATCH_ENTRY_COUNT = 20;
+
+ /**
+ * Execute a test function with a configured {@link VectorStore} instance. This method
+ * is responsible for providing a properly initialized store within the appropriate
+ * Spring application context for testing.
+ * @param testFunction the consumer that executes test operations on the store
+ */
+ protected abstract void executeTest(Consumer testFunction);
+
+ /**
+ * The embedding dimension the store under test expects, so the suite can build
+ * correctly sized caller vectors. This is the single hook a concrete store test must
+ * supply.
+ * @return the embedding dimension
+ */
+ protected abstract int embeddingDimensions();
+
+ @Test
+ protected void replaceById() {
+ executeTest(vectorStore -> {
+ String id = UUID.randomUUID().toString();
+
+ vectorStore.upsert(List.of(embeddedDocument(id, "first version", Map.of("tag", "v1"))));
+ vectorStore.upsert(List.of(embeddedDocument(id, "second version", Map.of("tag", "v2"))));
+
+ await().atMost(5, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> {
+ List results = readAll(vectorStore, 10);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getId()).isEqualTo(id);
+ assertThat(results.get(0).getText()).isEqualTo("second version");
+ assertThat(normalizeValue(results.get(0).getMetadata().get("tag"))).isEqualTo("v2");
+ });
+ });
+ }
+
+ @Test
+ protected void pairingAcrossBatches() {
+ executeTest(vectorStore -> {
+ List entries = new ArrayList<>();
+ Map expectedTextById = new HashMap<>();
+ Map expectedTagById = new HashMap<>();
+ for (int i = 0; i < MULTI_BATCH_ENTRY_COUNT; i++) {
+ String id = UUID.randomUUID().toString();
+ String text = "content-" + i;
+ String tag = "tag-" + i;
+ entries.add(embeddedDocument(id, text, Map.of("tag", tag)));
+ expectedTextById.put(id, text);
+ expectedTagById.put(id, tag);
+ }
+
+ vectorStore.upsert(entries);
+
+ await().atMost(5, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> {
+ List results = readAll(vectorStore, MULTI_BATCH_ENTRY_COUNT);
+ assertThat(results).hasSize(MULTI_BATCH_ENTRY_COUNT);
+
+ Map resultsById = results.stream().collect(Collectors.toMap(Document::getId, d -> d));
+ assertThat(resultsById.keySet()).containsExactlyInAnyOrderElementsOf(expectedTextById.keySet());
+
+ resultsById.forEach((id, result) -> {
+ assertThat(result.getText()).isEqualTo(expectedTextById.get(id));
+ assertThat(normalizeValue(result.getMetadata().get("tag"))).isEqualTo(expectedTagById.get(id));
+ });
+ });
+ });
+ }
+
+ @Test
+ protected void retryIdempotent() {
+ executeTest(vectorStore -> {
+ List batch = List.of(
+ embeddedDocument(UUID.randomUUID().toString(), "alpha", Map.of("tag", "a")),
+ embeddedDocument(UUID.randomUUID().toString(), "beta", Map.of("tag", "b")));
+
+ vectorStore.upsert(batch);
+ vectorStore.upsert(batch);
+
+ await().atMost(5, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> {
+ List results = readAll(vectorStore, 10);
+ assertThat(results).hasSize(2);
+ assertThat(results.stream().map(Document::getText)).containsExactlyInAnyOrder("alpha", "beta");
+ });
+ });
+ }
+
+ @Test
+ protected void wrongDimensionRejectedBeforeWrite() {
+ executeTest(vectorStore -> {
+ String baselineId = UUID.randomUUID().toString();
+ vectorStore.upsert(List.of(embeddedDocument(baselineId, "baseline", Map.of("tag", "base"))));
+
+ await().atMost(5, TimeUnit.SECONDS)
+ .pollInterval(Duration.ofMillis(500))
+ .untilAsserted(() -> assertThat(readAll(vectorStore, 10)).hasSize(1));
+
+ // Valid entry first, wrong-dimension entry second. A store that wrote while
+ // iterating would have persisted the valid one before hitting the bad one.
+ String wouldBeWrittenId = UUID.randomUUID().toString();
+ List badBatch = List.of(
+ embeddedDocument(wouldBeWrittenId, "should not be written", Map.of("tag", "nope")),
+ new EmbeddedDocument(new Document(UUID.randomUUID().toString(), "wrong dimension", new HashMap<>()),
+ vectorOfLength(embeddingDimensions() + 1)));
+
+ assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> vectorStore.upsert(badBatch));
+
+ // Nothing from the rejected batch was written: only the baseline row remains.
+ List results = readAll(vectorStore, 10);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getId()).isEqualTo(baselineId);
+ });
+ }
+
+ @Test
+ protected void contentRefRoundTrip() {
+ executeTest(vectorStore -> {
+ String id = UUID.randomUUID().toString();
+ String pointer = "s3://media/video-42.mp4#t=4711";
+
+ // A reference row: no content of its own, just a pointer to where the content
+ // really lives. It has to go through upsert, because add embeds the text and
+ // there is nothing there to embed.
+ Document reference = Document.builder()
+ .id(id)
+ .text("")
+ .metadata(DocumentMetadata.CONTENT_REF.value(), pointer)
+ .build();
+
+ vectorStore.upsert(List.of(new EmbeddedDocument(reference, vectorFor(id))));
+
+ await().atMost(5, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> {
+ List results = readAll(vectorStore, 10);
+ assertThat(results).hasSize(1);
+ assertThat(results.get(0).getId()).isEqualTo(id);
+ assertThat(normalizeValue(results.get(0).getMetadata().get(DocumentMetadata.CONTENT_REF.value())))
+ .isEqualTo(pointer);
+ });
+ });
+ }
+
+ /**
+ * Build an {@link EmbeddedDocument} with a deterministic, correctly sized vector
+ * derived from the id so distinct ids get distinct vectors.
+ * @param id the document id
+ * @param content the document text
+ * @param metadata the document metadata
+ * @return the embedded document
+ */
+ protected EmbeddedDocument embeddedDocument(String id, String content, Map metadata) {
+ return new EmbeddedDocument(new Document(id, content, metadata), vectorFor(id));
+ }
+
+ private float[] vectorFor(String id) {
+ int dimensions = embeddingDimensions();
+ float[] vector = new float[dimensions];
+ int hash = id.hashCode();
+ for (int i = 0; i < dimensions; i++) {
+ vector[i] = ((hash >> (i % Integer.SIZE)) & 1) == 0 ? 0.1f * (i + 1) : 0.2f * (i + 1);
+ }
+ return vector;
+ }
+
+ private float[] vectorOfLength(int length) {
+ float[] vector = new float[length];
+ for (int i = 0; i < length; i++) {
+ vector[i] = 0.1f;
+ }
+ return vector;
+ }
+
+ private List readAll(VectorStore vectorStore, int topK) {
+ return vectorStore
+ .similaritySearch(SearchRequest.builder().query("read back").topK(topK).similarityThresholdAll().build());
+ }
+
+ private @Nullable String normalizeValue(@Nullable Object value) {
+ if (value == null) {
+ return null;
+ }
+ return value.toString().replaceAll("^\"|\"$", "").trim();
+ }
+
+}
diff --git a/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/FixedDimensionEmbeddingModel.java b/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/FixedDimensionEmbeddingModel.java
new file mode 100644
index 0000000000..c922edffb1
--- /dev/null
+++ b/spring-ai-test/src/main/java/org/springframework/ai/test/vectorstore/FixedDimensionEmbeddingModel.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.test.vectorstore;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.ai.document.Document;
+import org.springframework.ai.embedding.Embedding;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.embedding.EmbeddingRequest;
+import org.springframework.ai.embedding.EmbeddingResponse;
+
+/**
+ * A minimal deterministic {@link EmbeddingModel} that returns a constant vector of a
+ * configured dimension, for tests that must not depend on a hosted embedding provider.
+ *
+ * It is the query-side model for the {@link AbstractVectorStoreUpsertTests} suite:
+ * {@code upsert} supplies its own vectors, and the suite reads back with
+ * {@code similarityThresholdAll()} regardless of ranking, so a constant vector is
+ * sufficient and no API key is needed. Wire it into a store's upsert integration test and
+ * size the store's index to {@link #dimensions()}.
+ *
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+public final class FixedDimensionEmbeddingModel implements EmbeddingModel {
+
+ private final int dimensions;
+
+ public FixedDimensionEmbeddingModel(int dimensions) {
+ this.dimensions = dimensions;
+ }
+
+ @Override
+ public float[] embed(Document document) {
+ return fixedVector();
+ }
+
+ @Override
+ public float[] embed(String text) {
+ return fixedVector();
+ }
+
+ private float[] fixedVector() {
+ float[] vector = new float[this.dimensions];
+ Arrays.fill(vector, 0.1f);
+ return vector;
+ }
+
+ @Override
+ public EmbeddingResponse call(EmbeddingRequest request) {
+ List embeddings = new ArrayList<>();
+ for (int i = 0; i < request.getInstructions().size(); i++) {
+ embeddings.add(new Embedding(embed(request.getInstructions().get(i)), i));
+ }
+ return new EmbeddingResponse(embeddings);
+ }
+
+ @Override
+ public int dimensions() {
+ return this.dimensions;
+ }
+
+}
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/EmbeddedDocument.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/EmbeddedDocument.java
new file mode 100644
index 0000000000..4cfa9d1349
--- /dev/null
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/EmbeddedDocument.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore;
+
+import java.util.Arrays;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.document.Document;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link Document} paired with a pre-computed embedding vector, written to a
+ * {@link VectorStore} via {@link VectorStore#upsert(java.util.List)} without invoking the
+ * store's embedding model.
+ *
+ * This is the "bring your own vector" entry point: the caller supplies the embedding —
+ * computed by a batch API, an external embedding pipeline, or exported from another
+ * system — and the store persists it as-is.
+ *
+ * The embedding array is defensively copied on construction and on access, so the vector
+ * held by an instance cannot be mutated after handoff. Equality compares the document and
+ * the element-by-element contents of the embedding array; a record's generated
+ * {@code equals} would compare the array by reference instead.
+ *
+ * @param document the document to store; must not be null
+ * @param embedding the pre-computed embedding vector; must not be null, must be
+ * non-empty, and must contain only finite values (no {@code NaN} or {@code Infinity}).
+ * Caller-supplied vectors are validated here because, unlike {@link VectorStore#add},
+ * {@code upsert} never re-embeds through a trusted model.
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+public record EmbeddedDocument(Document document, float[] embedding) {
+
+ public EmbeddedDocument {
+ Assert.notNull(document, "document must not be null");
+ Assert.notNull(embedding, "embedding must not be null");
+ Assert.isTrue(embedding.length > 0, "embedding vector must not be empty");
+ for (float value : embedding) {
+ Assert.isTrue(Float.isFinite(value), () -> "embedding vector must contain only finite values; found "
+ + (Float.isNaN(value) ? "NaN" : "Infinity"));
+ }
+ embedding = Arrays.copyOf(embedding, embedding.length);
+ }
+
+ /**
+ * Returns a copy of the embedding vector. Mutating the returned array does not affect
+ * this instance.
+ * @return a defensive copy of the embedding vector
+ */
+ @Override
+ public float[] embedding() {
+ return Arrays.copyOf(this.embedding, this.embedding.length);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof EmbeddedDocument that)) {
+ return false;
+ }
+ return this.document.equals(that.document) && Arrays.equals(this.embedding, that.embedding);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * this.document.hashCode() + Arrays.hashCode(this.embedding);
+ }
+
+}
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java
index d09fd79639..a0e64248c2 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java
@@ -55,6 +55,30 @@ default void accept(List documents) {
add(documents);
}
+ /**
+ * Upserts a list of {@link EmbeddedDocument}s into the vector store using
+ * caller-supplied embeddings, bypassing the store's own embedding model.
+ *
+ * Write semantics are replace-by-id: if an entry's document id already exists, the
+ * existing row is replaced; otherwise a new row is inserted. As a result, an
+ * ingestion job that uses stable ids is safe to re-run — the same input yields the
+ * same rows with no duplicates. This is in contrast to {@link #add(List)}, whose
+ * behavior for a repeated id is left to the underlying provider.
+ *
+ * If any entry fails, the operation throws. A backend may have written some entries
+ * before failing, so callers should treat the batch outcome as unknown and retry the
+ * whole batch, which replace-by-id makes safe.
+ *
+ * The default implementation throws {@link UnsupportedOperationException}; each store
+ * opts in independently.
+ * @param entries the documents and their pre-computed embeddings to upsert
+ * @throws UnsupportedOperationException if the store does not support upsert
+ * @since 2.1.0
+ */
+ default void upsert(List entries) {
+ throw new UnsupportedOperationException(getName() + " does not support upsert");
+ }
+
/**
* Deletes documents from the vector store.
* @param idList list of document ids for which documents will be removed.
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java
index 6ddbcb3a1e..7f63c1b8a3 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java
@@ -25,6 +25,7 @@
import org.springframework.ai.embedding.BatchingStrategy;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
@@ -84,6 +85,26 @@ public void add(List documents) {
.observe(() -> this.doAdd(documents));
}
+ /**
+ * Like {@link #add(List)}, this path accepts text documents only, but for a different
+ * reason: {@code add} is restricted because it invokes the text embedding model,
+ * while {@code upsert} never embeds and is restricted because no store can persist a
+ * row that carries no text. A document whose text is empty is fine, which is how a
+ * row points at content held outside the store.
+ */
+ @Override
+ public void upsert(List entries) {
+ validateNonTextDocuments(entries.stream().map(EmbeddedDocument::document).toList());
+ VectorStoreObservationContext observationContext = this
+ .createObservationContextBuilder(VectorStoreObservationContext.Operation.UPSERT.value())
+ .build();
+
+ VectorStoreObservationDocumentation.AI_VECTOR_STORE
+ .observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
+ this.observationRegistry)
+ .observe(() -> this.doUpsert(entries));
+ }
+
private void validateNonTextDocuments(List documents) {
if (documents == null) {
return;
@@ -148,6 +169,16 @@ public List similaritySearch(SearchRequest request) {
*/
public abstract void doAdd(List documents);
+ /**
+ * Template method for concrete implementations to provide upsert logic using
+ * caller-supplied embeddings. The default implementation throws, so stores opt in
+ * independently.
+ * @param entries the documents and their pre-computed embeddings to upsert
+ */
+ protected void doUpsert(List entries) {
+ throw new UnsupportedOperationException(getName() + " does not support upsert");
+ }
+
/**
* Perform the actual delete operation.
* @param idList the list of document IDs to delete
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContext.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContext.java
index b959be4760..cb4f064255 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContext.java
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContext.java
@@ -142,6 +142,10 @@ public enum Operation {
* VectorStore add operation.
*/
ADD("add"),
+ /**
+ * VectorStore upsert operation.
+ */
+ UPSERT("upsert"),
/**
* VectorStore delete operation.
*/
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/EmbeddedDocumentTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/EmbeddedDocumentTests.java
new file mode 100644
index 0000000000..4a797242cf
--- /dev/null
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/EmbeddedDocumentTests.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.document.Document;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link EmbeddedDocument}.
+ *
+ * @author Soby Chacko
+ */
+class EmbeddedDocumentTests {
+
+ private static Document doc(String id) {
+ return Document.builder().id(id).text("content of " + id).build();
+ }
+
+ @Test
+ void constructorCopiesEmbedding() {
+ float[] source = { 0.1f, 0.2f, 0.3f };
+ EmbeddedDocument entry = new EmbeddedDocument(doc("a"), source);
+
+ // Mutating the source array after handoff must not change the stored vector.
+ source[0] = 9.9f;
+
+ assertThat(entry.embedding()).containsExactly(0.1f, 0.2f, 0.3f);
+ }
+
+ @Test
+ void accessorReturnsCopy() {
+ EmbeddedDocument entry = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.2f, 0.3f });
+
+ // Mutating the returned array must not change the stored vector.
+ entry.embedding()[0] = 9.9f;
+
+ assertThat(entry.embedding()).containsExactly(0.1f, 0.2f, 0.3f);
+ }
+
+ @Test
+ void equalsAndHashCodeCompareEmbeddingByContents() {
+ // Distinct array instances holding the same values are equal.
+ EmbeddedDocument one = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.2f });
+ EmbeddedDocument two = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.2f });
+
+ assertThat(one).isEqualTo(two);
+ assertThat(one).hasSameHashCodeAs(two);
+ }
+
+ @Test
+ void notEqualWhenEmbeddingDiffers() {
+ EmbeddedDocument one = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.2f });
+ EmbeddedDocument two = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.9f });
+
+ assertThat(one).isNotEqualTo(two);
+ }
+
+ @Test
+ void notEqualWhenDocumentDiffers() {
+ EmbeddedDocument one = new EmbeddedDocument(doc("a"), new float[] { 0.1f, 0.2f });
+ EmbeddedDocument two = new EmbeddedDocument(doc("b"), new float[] { 0.1f, 0.2f });
+
+ assertThat(one).isNotEqualTo(two);
+ }
+
+ @Test
+ void nullDocumentThrows() {
+ assertThatThrownBy(() -> new EmbeddedDocument(null, new float[] { 0.1f }))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("document must not be null");
+ }
+
+ @Test
+ void nullEmbeddingThrows() {
+ assertThatThrownBy(() -> new EmbeddedDocument(doc("a"), null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("embedding must not be null");
+ }
+
+ @Test
+ void emptyEmbeddingThrows() {
+ assertThatThrownBy(() -> new EmbeddedDocument(doc("a"), new float[0]))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("embedding vector must not be empty");
+ }
+
+ @Test
+ void nanEmbeddingThrows() {
+ assertThatThrownBy(() -> new EmbeddedDocument(doc("a"), new float[] { 0.1f, Float.NaN, 0.3f }))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("NaN");
+ }
+
+ @Test
+ void infiniteEmbeddingThrows() {
+ assertThatThrownBy(() -> new EmbeddedDocument(doc("a"), new float[] { 0.1f, Float.POSITIVE_INFINITY }))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Infinity");
+
+ assertThatThrownBy(() -> new EmbeddedDocument(doc("a"), new float[] { Float.NEGATIVE_INFINITY, 0.2f }))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Infinity");
+ }
+
+}
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/VectorStoreUpsertDefaultTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/VectorStoreUpsertDefaultTests.java
new file mode 100644
index 0000000000..fe93169b5a
--- /dev/null
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/VectorStoreUpsertDefaultTests.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.document.Document;
+import org.springframework.ai.vectorstore.filter.Filter;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for the default {@link VectorStore#upsert(List)} implementation, which stores
+ * that have not opted in inherit.
+ *
+ * @author Soby Chacko
+ */
+class VectorStoreUpsertDefaultTests {
+
+ @Test
+ void defaultUpsertThrowsWithStoreNameInMessage() {
+ VectorStore store = new NoOpVectorStore();
+
+ List entries = List
+ .of(new EmbeddedDocument(Document.builder().id("a").text("hello").build(), new float[] { 0.1f, 0.2f }));
+
+ assertThatThrownBy(() -> store.upsert(entries)).isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("NoOpVectorStore")
+ .hasMessageContaining("does not support upsert");
+ }
+
+ /**
+ * A minimal {@link VectorStore} that does not override {@code upsert}, used to
+ * exercise the interface default.
+ */
+ private static final class NoOpVectorStore implements VectorStore {
+
+ @Override
+ public void add(List documents) {
+ }
+
+ @Override
+ public void delete(List idList) {
+ }
+
+ @Override
+ public void delete(Filter.Expression filterExpression) {
+ }
+
+ @Override
+ public List similaritySearch(SearchRequest request) {
+ return List.of();
+ }
+
+ }
+
+}
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStoreUpsertTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStoreUpsertTests.java
new file mode 100644
index 0000000000..cb02080305
--- /dev/null
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStoreUpsertTests.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore.observation;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.micrometer.observation.tck.TestObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistryAssert;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.content.Media;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
+import org.springframework.ai.vectorstore.SearchRequest;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.util.MimeType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests the {@code upsert} wiring on {@link AbstractObservationVectorStore}: routing
+ * through {@code doUpsert}, the throwing default, and observation emission.
+ *
+ * @author Soby Chacko
+ */
+class AbstractObservationVectorStoreUpsertTests {
+
+ private static final EmbeddingModel EMBEDDING_MODEL = mock(EmbeddingModel.class);
+
+ private static EmbeddedDocument entry() {
+ return new EmbeddedDocument(Document.builder().id("a").text("hello").build(), new float[] { 0.1f, 0.2f });
+ }
+
+ @Test
+ void upsertRoutesToDoUpsert() {
+ AtomicReference> captured = new AtomicReference<>();
+ TestObservationRegistry registry = TestObservationRegistry.create();
+ TestVectorStore store = new TestVectorStore(registry, captured);
+
+ List entries = List.of(entry());
+ store.upsert(entries);
+
+ assertThat(captured.get()).isSameAs(entries);
+ }
+
+ @Test
+ void upsertEmitsUpsertObservation() {
+ TestObservationRegistry registry = TestObservationRegistry.create();
+ TestVectorStore store = new TestVectorStore(registry, new AtomicReference<>());
+
+ store.upsert(List.of(entry()));
+
+ TestObservationRegistryAssert.assertThat(registry)
+ .hasObservationWithNameEqualTo(DefaultVectorStoreObservationConvention.DEFAULT_NAME)
+ .that()
+ .hasLowCardinalityKeyValue(
+ VectorStoreObservationDocumentation.LowCardinalityKeyNames.DB_OPERATION_NAME.asString(), "upsert");
+ }
+
+ @Test
+ void mediaDocumentIsRejectedBeforeDoUpsert() {
+ AtomicReference> captured = new AtomicReference<>();
+ TestVectorStore store = new TestVectorStore(TestObservationRegistry.create(), captured);
+
+ Media media = new Media(MimeType.valueOf("image/png"), new ByteArrayResource(new byte[] { 0x00 }));
+ Document imageDocument = Document.builder().id("a").media(media).build();
+ List entries = List.of(new EmbeddedDocument(imageDocument, new float[] { 0.1f, 0.2f }));
+
+ // No store can persist a row that carries no text, so the entry is rejected up
+ // front and never reaches doUpsert.
+ assertThatThrownBy(() -> store.upsert(entries)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Only text documents are supported");
+ assertThat(captured.get()).isNull();
+ }
+
+ @Test
+ void emptyTextDocumentIsAccepted() {
+ AtomicReference> captured = new AtomicReference<>();
+ TestVectorStore store = new TestVectorStore(TestObservationRegistry.create(), captured);
+
+ // A reference row carries no content of its own, only a pointer in metadata, so
+ // empty text has to stay valid.
+ List entries = List
+ .of(new EmbeddedDocument(Document.builder().id("a").text("").build(), new float[] { 0.1f, 0.2f }));
+
+ store.upsert(entries);
+
+ assertThat(captured.get()).isSameAs(entries);
+ }
+
+ @Test
+ void defaultDoUpsertThrows() {
+ // A store that does not override doUpsert inherits the throwing default.
+ ThrowingVectorStore store = new ThrowingVectorStore(TestObservationRegistry.create());
+
+ assertThatThrownBy(() -> store.upsert(List.of(entry()))).isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("ThrowingVectorStore")
+ .hasMessageContaining("does not support upsert");
+ }
+
+ private static class TestVectorStoreBuilder extends AbstractVectorStoreBuilder {
+
+ protected TestVectorStoreBuilder(TestObservationRegistry registry) {
+ super(EMBEDDING_MODEL);
+ observationRegistry(registry);
+ }
+
+ @Override
+ public VectorStore build() {
+ // The stores under test are constructed directly from this builder.
+ throw new UnsupportedOperationException();
+ }
+
+ }
+
+ /**
+ * A store that overrides {@code doUpsert} to capture the entries it receives.
+ */
+ private static final class TestVectorStore extends AbstractObservationVectorStore {
+
+ private final AtomicReference> captured;
+
+ private TestVectorStore(TestObservationRegistry registry, AtomicReference> captured) {
+ super(new TestVectorStoreBuilder(registry));
+ this.captured = captured;
+ }
+
+ @Override
+ protected void doUpsert(List entries) {
+ this.captured.set(entries);
+ }
+
+ @Override
+ public void doAdd(List documents) {
+ }
+
+ @Override
+ public void doDelete(List idList) {
+ }
+
+ @Override
+ public List doSimilaritySearch(SearchRequest request) {
+ return List.of();
+ }
+
+ @Override
+ public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) {
+ return VectorStoreObservationContext.builder("test", operationName);
+ }
+
+ }
+
+ /**
+ * A store that does not override {@code doUpsert}, exercising the throwing default.
+ */
+ private static final class ThrowingVectorStore extends AbstractObservationVectorStore {
+
+ private ThrowingVectorStore(TestObservationRegistry registry) {
+ super(new TestVectorStoreBuilder(registry));
+ }
+
+ @Override
+ public void doAdd(List documents) {
+ }
+
+ @Override
+ public void doDelete(List idList) {
+ }
+
+ @Override
+ public List doSimilaritySearch(SearchRequest request) {
+ return List.of();
+ }
+
+ @Override
+ public VectorStoreObservationContext.Builder createObservationContextBuilder(String operationName) {
+ return VectorStoreObservationContext.builder("test", operationName);
+ }
+
+ }
+
+}
diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStore.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStore.java
index d23df38b23..972dc2c842 100644
--- a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStore.java
+++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStore.java
@@ -48,6 +48,7 @@
import org.springframework.ai.observation.conventions.VectorStoreProvider;
import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric;
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
@@ -166,6 +167,10 @@ public class ElasticsearchVectorStore extends AbstractObservationVectorStore imp
private final boolean initializeSchema;
+ // True when this store created the index, which is the only case where the
+ // configured dimension is known to match the index mapping.
+ private volatile boolean indexMappingCreatedHere;
+
protected ElasticsearchVectorStore(Builder builder) {
super(builder);
@@ -206,6 +211,48 @@ public void doAdd(List documents) {
}
}
+ @Override
+ protected void doUpsert(List entries) {
+ // Whole-batch dimension pre-check before any write, so a mismatched vector fails
+ // fast and cannot partially write. The authoritative dimension is the index
+ // mapping, not the query embedding model, since upsert stores caller-supplied
+ // vectors. The configured dimension is only known to match that mapping when this
+ // store created the index; for a pre-existing index the check is left to
+ // Elasticsearch. Non-empty and finiteness are already enforced by the
+ // EmbeddedDocument constructor.
+ if (this.indexMappingCreatedHere) {
+ int expected = this.options.getDimensions();
+ for (int i = 0; i < entries.size(); i++) {
+ int actual = entries.get(i).embedding().length;
+ if (actual != expected) {
+ throw new IllegalArgumentException("Embedding at index " + i + " has dimension " + actual
+ + " but the store expects dimension " + expected);
+ }
+ }
+ }
+
+ BulkRequest.Builder bulkRequestBuilder = new BulkRequest.Builder();
+ for (int i = 0; i < entries.size(); i++) {
+ // Pair positionally: the document and its embedding come from the same entry,
+ // so there is no indexOf lookup to slip.
+ EmbeddedDocument entry = entries.get(i);
+ Document document = entry.document();
+ float[] embedding = entry.embedding();
+ bulkRequestBuilder.operations(op -> op.index(idx -> idx.index(this.options.getIndexName())
+ .id(document.getId())
+ .document(getDocument(document, embedding, this.options.getEmbeddingFieldName()))));
+ }
+ BulkResponse bulkRequest = bulkRequest(bulkRequestBuilder.build());
+ if (bulkRequest.errors()) {
+ List bulkResponseItems = bulkRequest.items();
+ for (BulkResponseItem bulkResponseItem : bulkResponseItems) {
+ if (bulkResponseItem.error() != null) {
+ throw new IllegalStateException(bulkResponseItem.error().reason());
+ }
+ }
+ }
+ }
+
private Object getDocument(Document document, float[] embedding, String embeddingFieldName) {
Assert.notNull(document.getText(), "document's text must not be null");
@@ -363,6 +410,7 @@ public void afterPropertiesSet() {
throw new IllegalArgumentException("Index not found");
}
createIndexMapping();
+ this.indexMappingCreatedHere = true;
}
@Override
diff --git a/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStoreUpsertIT.java b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStoreUpsertIT.java
new file mode 100644
index 0000000000..75b973f9a7
--- /dev/null
+++ b/vector-stores/spring-ai-elasticsearch-store/src/test/java/org/springframework/ai/vectorstore/elasticsearch/ElasticsearchVectorStoreUpsertIT.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore.elasticsearch;
+
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.function.Consumer;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch.cat.indices.IndicesRecord;
+import co.elastic.clients.json.jackson.Jackson3JsonpMapper;
+import co.elastic.clients.transport.rest5_client.Rest5ClientTransport;
+import co.elastic.clients.transport.rest5_client.low_level.Rest5Client;
+import org.apache.hc.core5.http.HttpHost;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.cfg.DateTimeFeature;
+import tools.jackson.databind.json.JsonMapper;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.test.vectorstore.AbstractVectorStoreUpsertTests;
+import org.springframework.ai.test.vectorstore.FixedDimensionEmbeddingModel;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Upsert verification for {@link ElasticsearchVectorStore}, running the shared
+ * {@link AbstractVectorStoreUpsertTests} suite.
+ *
+ * Unlike {@link ElasticsearchVectorStoreIT}, this needs no {@code OPENAI_API_KEY}:
+ * {@code doUpsert} never embeds, and read-back only needs a query vector, so a
+ * {@link FixedDimensionEmbeddingModel} is wired instead. Only Testcontainers
+ * Elasticsearch is required. Elasticsearch writes a batch as a single bulk request, so
+ * {@code pairingAcrossBatches} exercises positional pairing across all entries rather
+ * than across write-batch boundaries.
+ *
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+@Testcontainers
+public class ElasticsearchVectorStoreUpsertIT extends AbstractVectorStoreUpsertTests {
+
+ private static final int EMBEDDING_DIMENSIONS = 4;
+
+ @Container
+ private static final ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer(
+ ElasticsearchImage.DEFAULT_IMAGE)
+ .withEnv("xpack.security.enabled", "false");
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestApplication.class);
+
+ @BeforeEach
+ void cleanDatabase() {
+ this.contextRunner.run(context -> {
+ ElasticsearchClient elasticsearchClient = context.getBean(ElasticsearchClient.class);
+ List indices = elasticsearchClient.cat()
+ .indices()
+ .indices()
+ .stream()
+ .map(IndicesRecord::index)
+ .toList();
+ if (!indices.isEmpty()) {
+ elasticsearchClient.indices().delete(del -> del.index(indices));
+ }
+ });
+ }
+
+ @Override
+ protected void executeTest(Consumer testFunction) {
+ this.contextRunner.run(context -> {
+ VectorStore vectorStore = context.getBean(VectorStore.class);
+ testFunction.accept(vectorStore);
+ });
+ }
+
+ @Override
+ protected int embeddingDimensions() {
+ return EMBEDDING_DIMENSIONS;
+ }
+
+ @SpringBootConfiguration
+ public static class TestApplication {
+
+ @Bean
+ public ElasticsearchVectorStore vectorStore(EmbeddingModel embeddingModel, Rest5Client restClient) {
+ ElasticsearchVectorStoreOptions options = new ElasticsearchVectorStoreOptions();
+ options.setDimensions(EMBEDDING_DIMENSIONS);
+ return ElasticsearchVectorStore.builder(restClient, embeddingModel)
+ .initializeSchema(true)
+ .options(options)
+ .build();
+ }
+
+ @Bean
+ public EmbeddingModel embeddingModel() {
+ return new FixedDimensionEmbeddingModel(EMBEDDING_DIMENSIONS);
+ }
+
+ @Bean
+ Rest5Client restClient() throws URISyntaxException {
+ return Rest5Client.builder(HttpHost.create(elasticsearchContainer.getHttpHostAddress())).build();
+ }
+
+ @Bean
+ ElasticsearchClient elasticsearchClient(Rest5Client restClient) {
+ JsonMapper jsonMapper = JsonMapper.builder()
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .build();
+ return new ElasticsearchClient(new Rest5ClientTransport(restClient, new Jackson3JsonpMapper(jsonMapper)));
+ }
+
+ }
+
+}
diff --git a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
index ffccfb353e..ed2e0a2807 100644
--- a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
+++ b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java
@@ -39,6 +39,7 @@
import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric;
import org.springframework.ai.util.JacksonUtils;
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
@@ -181,6 +182,12 @@ public class PgVectorStore extends AbstractObservationVectorStore implements Ini
VectorStoreSimilarityMetric.EUCLIDEAN, PgDistanceType.NEGATIVE_INNER_PRODUCT,
VectorStoreSimilarityMetric.DOT);
+ // Replace-by-id write shared by add (doAdd) and upsert (doUpsert). The single
+ // %s is the fully-qualified table name. Kept in one place so the two write paths
+ // cannot drift.
+ private static final String UPSERT_SQL = "INSERT INTO %s (id, content, metadata, embedding) VALUES (?, ?, ?::jsonb, ?) "
+ + "ON CONFLICT (id) DO UPDATE SET content = ? , metadata = ?::jsonb , embedding = ? ";
+
public final FilterExpressionConverter filterExpressionConverter = new PgVectorFilterExpressionConverter();
private final String vectorTableName;
@@ -274,9 +281,7 @@ private List> batchDocuments(List documents) {
}
private void insertOrUpdateBatch(List batch, List documents, List embeddings) {
- String sql = "INSERT INTO " + getFullyQualifiedTableName()
- + " (id, content, metadata, embedding) VALUES (?, ?, ?::jsonb, ?) " + "ON CONFLICT (id) DO "
- + "UPDATE SET content = ? , metadata = ?::jsonb , embedding = ? ";
+ String sql = UPSERT_SQL.formatted(getFullyQualifiedTableName());
this.jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@@ -306,6 +311,69 @@ public int getBatchSize() {
});
}
+ @Override
+ protected void doUpsert(List entries) {
+ // Whole-batch dimension pre-check before any write, so a mismatched vector fails
+ // fast and cannot partially write. Non-empty and finiteness are already enforced
+ // by the EmbeddedDocument constructor. Only runs when the dimension is actually
+ // known: guessing it would reject vectors the table would have accepted, so in
+ // that case the check is left to Postgres.
+ int expected = knownEmbeddingDimensions();
+ if (expected > 0) {
+ for (int i = 0; i < entries.size(); i++) {
+ int actual = entries.get(i).embedding().length;
+ if (actual != expected) {
+ throw new IllegalArgumentException("Embedding at index " + i + " has dimension " + actual
+ + " but the store expects dimension " + expected);
+ }
+ }
+ }
+
+ List> batchedEntries = batchEmbeddedDocuments(entries);
+ batchedEntries.forEach(this::upsertBatch);
+ }
+
+ private List> batchEmbeddedDocuments(List entries) {
+ List> batches = new ArrayList<>();
+ for (int i = 0; i < entries.size(); i += this.maxDocumentBatchSize) {
+ batches.add(entries.subList(i, Math.min(i + this.maxDocumentBatchSize, entries.size())));
+ }
+ return batches;
+ }
+
+ private void upsertBatch(List batch) {
+ String sql = UPSERT_SQL.formatted(getFullyQualifiedTableName());
+
+ this.jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
+
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+
+ // Pair positionally within the batch: the document and its embedding come
+ // from the same entry, so there is no indexOf lookup to slip.
+ var entry = batch.get(i);
+ var document = entry.document();
+ var id = convertIdToPgType(document.getId());
+ var content = document.getText();
+ var json = toJson(document.getMetadata());
+ var pGvector = new PGvector(entry.embedding());
+
+ StatementCreatorUtils.setParameterValue(ps, 1, SqlTypeValue.TYPE_UNKNOWN, id);
+ StatementCreatorUtils.setParameterValue(ps, 2, SqlTypeValue.TYPE_UNKNOWN, content);
+ StatementCreatorUtils.setParameterValue(ps, 3, SqlTypeValue.TYPE_UNKNOWN, json);
+ StatementCreatorUtils.setParameterValue(ps, 4, SqlTypeValue.TYPE_UNKNOWN, pGvector);
+ StatementCreatorUtils.setParameterValue(ps, 5, SqlTypeValue.TYPE_UNKNOWN, content);
+ StatementCreatorUtils.setParameterValue(ps, 6, SqlTypeValue.TYPE_UNKNOWN, json);
+ StatementCreatorUtils.setParameterValue(ps, 7, SqlTypeValue.TYPE_UNKNOWN, pGvector);
+ }
+
+ @Override
+ public int getBatchSize() {
+ return batch.size();
+ }
+ });
+ }
+
private String toJson(Map map) {
return this.jsonMapper.writeValueAsString(map);
}
@@ -495,6 +563,28 @@ private String getColumnTypeName() {
};
}
+ /**
+ * The embedding dimension when it is known, or -1 when it is not. Mirrors
+ * {@link #embeddingDimensions()} without its fallback to a default, so a caller that
+ * must not guess can tell the two cases apart.
+ * @return the known dimension, or -1
+ */
+ private int knownEmbeddingDimensions() {
+ if (this.dimensions > 0) {
+ return this.dimensions;
+ }
+ try {
+ int modelDimensions = this.embeddingModel.dimensions();
+ if (modelDimensions > 0) {
+ return modelDimensions;
+ }
+ }
+ catch (Exception ex) {
+ logger.debug("Could not obtain the embedding dimensions from the embedding model", ex);
+ }
+ return -1;
+ }
+
int embeddingDimensions() {
// The manually set dimensions have precedence over the computed one.
if (this.dimensions > 0) {
diff --git a/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreUpsertIT.java b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreUpsertIT.java
new file mode 100644
index 0000000000..261c6f0f08
--- /dev/null
+++ b/vector-stores/spring-ai-pgvector-store/src/test/java/org/springframework/ai/vectorstore/pgvector/PgVectorStoreUpsertIT.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore.pgvector;
+
+import java.util.function.Consumer;
+
+import javax.sql.DataSource;
+
+import com.zaxxer.hikari.HikariDataSource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.test.vectorstore.AbstractVectorStoreUpsertTests;
+import org.springframework.ai.test.vectorstore.FixedDimensionEmbeddingModel;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.ai.vectorstore.pgvector.PgVectorStore.PgIndexType;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Upsert verification for {@link PgVectorStore}, running the shared
+ * {@link AbstractVectorStoreUpsertTests} suite.
+ *
+ * Unlike {@link PgVectorStoreIT}, this test needs no {@code OPENAI_API_KEY}:
+ * {@code doUpsert} never embeds, and read-back only needs a query vector, so a tiny
+ * deterministic local {@link EmbeddingModel} is wired instead of a hosted one. Only
+ * Testcontainers Postgres is required.
+ *
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+@Testcontainers
+public class PgVectorStoreUpsertIT extends AbstractVectorStoreUpsertTests {
+
+ private static final int EMBEDDING_DIMENSIONS = 4;
+
+ // Smaller than MULTI_BATCH_ENTRY_COUNT so the multi-entry upsert genuinely spans
+ // several write batches, exercising positional pairing across batch boundaries.
+ private static final int MAX_DOCUMENT_BATCH_SIZE = 5;
+
+ @Container
+ @SuppressWarnings("resource")
+ static PostgreSQLContainer> postgresContainer = new PostgreSQLContainer<>(PgVectorImage.DEFAULT_IMAGE)
+ .withUsername("postgres")
+ .withPassword("postgres");
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestApplication.class);
+
+ @Override
+ protected void executeTest(Consumer testFunction) {
+ this.contextRunner.run(context -> {
+ VectorStore vectorStore = context.getBean(VectorStore.class);
+ testFunction.accept(vectorStore);
+ });
+ }
+
+ @Override
+ protected int embeddingDimensions() {
+ return EMBEDDING_DIMENSIONS;
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
+ public static class TestApplication {
+
+ @Bean
+ public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
+ return PgVectorStore.builder(jdbcTemplate, embeddingModel)
+ .dimensions(EMBEDDING_DIMENSIONS)
+ .maxDocumentBatchSize(MAX_DOCUMENT_BATCH_SIZE)
+ .initializeSchema(true)
+ .indexType(PgIndexType.HNSW)
+ .removeExistingVectorStoreTable(true)
+ .build();
+ }
+
+ @Bean
+ public JdbcTemplate myJdbcTemplate(DataSource dataSource) {
+ return new JdbcTemplate(dataSource);
+ }
+
+ @Bean
+ public DataSourceProperties dataSourceProperties() {
+ DataSourceProperties properties = new DataSourceProperties();
+ properties.setUrl(postgresContainer.getJdbcUrl());
+ properties.setUsername(postgresContainer.getUsername());
+ properties.setPassword(postgresContainer.getPassword());
+ return properties;
+ }
+
+ @Bean
+ public HikariDataSource dataSource(DataSourceProperties dataSourceProperties) {
+ return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
+ }
+
+ @Bean
+ public EmbeddingModel embeddingModel() {
+ return new FixedDimensionEmbeddingModel(EMBEDDING_DIMENSIONS);
+ }
+
+ }
+
+}
diff --git a/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java
index 9eb9453d09..9c3611244a 100644
--- a/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java
+++ b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java
@@ -43,6 +43,7 @@
import org.springframework.ai.model.EmbeddingUtils;
import org.springframework.ai.observation.conventions.VectorStoreProvider;
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
@@ -201,6 +202,46 @@ public void doAdd(List documents) {
}
}
+ @Override
+ protected void doUpsert(List entries) {
+ // Whole-batch dimension pre-check before any write, so a mismatched vector fails
+ // fast and cannot partially write. The collection's vector size matches the
+ // embedding model dimensions. Non-empty and finiteness are already enforced by
+ // the EmbeddedDocument constructor.
+ int expected = this.embeddingModel.dimensions();
+ for (int i = 0; i < entries.size(); i++) {
+ int actual = entries.get(i).embedding().length;
+ if (actual != expected) {
+ throw new IllegalArgumentException("Embedding at index " + i + " has dimension " + actual
+ + " but the store expects dimension " + expected);
+ }
+ }
+
+ try {
+ List points = IntStream.range(0, entries.size()).mapToObj(i -> {
+ // Pair positionally: the document and its embedding come from the same
+ // entry, so there is no indexOf lookup to slip.
+ EmbeddedDocument entry = entries.get(i);
+ Document document = entry.document();
+ return PointStruct.newBuilder()
+ .setId(io.qdrant.client.PointIdFactory.id(UUID.fromString(document.getId())))
+ .setVectors(io.qdrant.client.VectorsFactory.vectors(entry.embedding()))
+ .putAllPayload(toPayload(document))
+ .build();
+ }).toList();
+
+ this.qdrantClient.upsertAsync(this.collectionName, points).get();
+ }
+ catch (InterruptedException e) {
+ // Restore the flag so a caller that interrupted us can still see it.
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Upsert was interrupted", e);
+ }
+ catch (ExecutionException e) {
+ throw new IllegalStateException("Could not upsert documents", e);
+ }
+ }
+
/**
* Deletes a list of documents by their IDs.
* @param documentIds The list of document IDs to be deleted.
diff --git a/vector-stores/spring-ai-qdrant-store/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreUpsertIT.java b/vector-stores/spring-ai-qdrant-store/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreUpsertIT.java
new file mode 100644
index 0000000000..d401b213fd
--- /dev/null
+++ b/vector-stores/spring-ai-qdrant-store/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreUpsertIT.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.vectorstore.qdrant;
+
+import java.util.function.Consumer;
+
+import io.qdrant.client.QdrantClient;
+import io.qdrant.client.QdrantGrpcClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.qdrant.QdrantContainer;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.test.vectorstore.AbstractVectorStoreUpsertTests;
+import org.springframework.ai.test.vectorstore.FixedDimensionEmbeddingModel;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Upsert verification for {@link QdrantVectorStore}, running the shared
+ * {@link AbstractVectorStoreUpsertTests} suite.
+ *
+ * Unlike {@link QdrantVectorStoreIT}, this needs no {@code OPENAI_API_KEY}:
+ * {@code doUpsert} never embeds, and read-back only needs a query vector, so a
+ * {@link FixedDimensionEmbeddingModel} is wired instead. Only Testcontainers Qdrant is
+ * required. Qdrant writes a batch as a single upsert call, so
+ * {@code pairingAcrossBatches} exercises positional pairing across all entries rather
+ * than across write-batch boundaries.
+ *
+ * @author Soby Chacko
+ * @since 2.1.0
+ */
+@Testcontainers
+public class QdrantVectorStoreUpsertIT extends AbstractVectorStoreUpsertTests {
+
+ private static final int EMBEDDING_DIMENSIONS = 4;
+
+ private static final String COLLECTION_NAME = "test_collection_upsert";
+
+ @Container
+ static QdrantContainer qdrantContainer = new QdrantContainer(QdrantImage.DEFAULT_IMAGE);
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestApplication.class);
+
+ @BeforeEach
+ void cleanCollection() throws Exception {
+ QdrantClient client = new QdrantClient(
+ QdrantGrpcClient.newBuilder(qdrantContainer.getHost(), qdrantContainer.getGrpcPort(), false).build());
+ try {
+ if (client.listCollectionsAsync().get().contains(COLLECTION_NAME)) {
+ client.deleteCollectionAsync(COLLECTION_NAME).get();
+ }
+ }
+ finally {
+ client.close();
+ }
+ }
+
+ @Override
+ protected void executeTest(Consumer testFunction) {
+ this.contextRunner.run(context -> {
+ VectorStore vectorStore = context.getBean(VectorStore.class);
+ testFunction.accept(vectorStore);
+ });
+ }
+
+ @Override
+ protected int embeddingDimensions() {
+ return EMBEDDING_DIMENSIONS;
+ }
+
+ @SpringBootConfiguration
+ public static class TestApplication {
+
+ @Bean
+ public QdrantClient qdrantClient() {
+ return new QdrantClient(
+ QdrantGrpcClient.newBuilder(qdrantContainer.getHost(), qdrantContainer.getGrpcPort(), false)
+ .build());
+ }
+
+ @Bean
+ public VectorStore vectorStore(EmbeddingModel embeddingModel, QdrantClient qdrantClient) {
+ return QdrantVectorStore.builder(qdrantClient, embeddingModel)
+ .collectionName(COLLECTION_NAME)
+ .initializeSchema(true)
+ .build();
+ }
+
+ @Bean
+ public EmbeddingModel embeddingModel() {
+ return new FixedDimensionEmbeddingModel(EMBEDDING_DIMENSIONS);
+ }
+
+ }
+
+}
diff --git a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java
index 3a011db400..09b393a85b 100644
--- a/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java
+++ b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/redis/RedisVectorStore.java
@@ -26,6 +26,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -56,6 +57,7 @@
import org.springframework.ai.observation.conventions.VectorStoreProvider;
import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric;
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
+import org.springframework.ai.vectorstore.EmbeddedDocument;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
@@ -316,6 +318,14 @@ public class RedisVectorStore extends AbstractObservationVectorStore implements
private final Set stopwords = new HashSet<>();
+ // Names of the metadata fields declared at build time. Redis only returns these on
+ // read, so anything else is stored and then dropped.
+ private final Set declaredMetadataFieldNames;
+
+ private final AtomicBoolean undeclaredMetadataWarned = new AtomicBoolean();
+
+ private final AtomicBoolean reservedMetadataKeyWarned = new AtomicBoolean();
+
protected RedisVectorStore(Builder builder) {
super(builder);
@@ -329,6 +339,9 @@ protected RedisVectorStore(Builder builder) {
this.vectorAlgorithm = builder.vectorAlgorithm;
this.distanceMetric = builder.distanceMetric;
this.metadataFields = builder.metadataFields;
+ this.declaredMetadataFieldNames = this.metadataFields.stream()
+ .map(MetadataField::name)
+ .collect(Collectors.toUnmodifiableSet());
this.initializeSchema = builder.initializeSchema;
this.hnswM = builder.hnswM;
this.hnswEfConstruction = builder.hnswEfConstruction;
@@ -362,6 +375,8 @@ public void doAdd(List documents) {
for (int i = 0; i < documents.size(); i++) {
Document document = documents.get(i);
+ warnOnUndeclaredMetadata(document);
+ warnOnReservedMetadataKeys(document);
var fields = new HashMap();
float[] embedding = embeddings.get(i);
@@ -370,9 +385,11 @@ public void doAdd(List documents) {
embedding = normalize(embedding);
}
+ // Metadata first, so a key that collides with the embedding or content
+ // field name cannot overwrite the vector or the text.
+ fields.putAll(document.getMetadata());
fields.put(this.embeddingFieldName, embedding);
fields.put(this.contentFieldName, document.getText());
- fields.putAll(document.getMetadata());
pipeline.jsonSetWithEscape(key(document.getId()), JSON_SET_PATH, fields);
}
List