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 responses = pipeline.syncAndReturnAll(); @@ -385,6 +402,96 @@ 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. 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 (Pipeline pipeline = this.jedisClient.pipelined()) { + 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(); + warnOnUndeclaredMetadata(document); + warnOnReservedMetadataKeys(document); + var fields = new HashMap(); + float[] embedding = entry.embedding(); + + // Normalize embeddings for COSINE distance metric + if (this.distanceMetric == DistanceMetric.COSINE) { + 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()); + pipeline.jsonSetWithEscape(key(document.getId()), JSON_SET_PATH, fields); + } + List responses = pipeline.syncAndReturnAll(); + Optional errResponse = responses.stream().filter(Predicate.not(RESPONSE_OK)).findAny(); + if (errResponse.isPresent()) { + String message = MessageFormat.format("Could not upsert document: {0}", errResponse.get()); + logger.error(message); + throw new IllegalStateException(message); + } + } + } + + /** + * Warns when a document carries metadata that was not declared through + * {@code metadataFields}. Redis stores every key, but a search only returns the + * declared ones, so the rest come back missing. Warns once per store so the loss is + * visible without flooding the log on every write. + * @param document the document about to be written + */ + /** + * Warns when a document's metadata contains a key that collides with the embedding or + * content field name. Those two fields are written last and win, so the colliding + * metadata entry is dropped rather than corrupting the row. + * @param document the document about to be written + */ + private void warnOnReservedMetadataKeys(Document document) { + if (this.reservedMetadataKeyWarned.get()) { + return; + } + Map metadata = document.getMetadata(); + if ((metadata.containsKey(this.embeddingFieldName) || metadata.containsKey(this.contentFieldName)) + && this.reservedMetadataKeyWarned.compareAndSet(false, true)) { + logger.warn("Document '" + document.getId() + "' has metadata using the reserved field name '" + + this.embeddingFieldName + "' or '" + this.contentFieldName + + "'. The store's own values win, so that metadata entry is not stored."); + } + } + + private void warnOnUndeclaredMetadata(Document document) { + if (this.undeclaredMetadataWarned.get() || document.getMetadata().isEmpty()) { + return; + } + List undeclared = document.getMetadata() + .keySet() + .stream() + .filter(key -> !this.declaredMetadataFieldNames.contains(key)) + .sorted() + .toList(); + if (!undeclared.isEmpty() && this.undeclaredMetadataWarned.compareAndSet(false, true)) { + logger.warn("Document '" + document.getId() + "' carries metadata fields that were not declared " + + "at build time: " + undeclared + ". Redis stores them, but a similarity search will not " + + "return them. Declare them with metadataFields(...) to get them back."); + } + } + private String key(String id) { return this.prefix + id; } diff --git a/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreUpsertIT.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreUpsertIT.java new file mode 100644 index 0000000000..aa6a907f24 --- /dev/null +++ b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/redis/RedisVectorStoreUpsertIT.java @@ -0,0 +1,103 @@ +/* + * 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.redis; + +import java.util.function.Consumer; + +import com.redis.testcontainers.RedisStackContainer; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import redis.clients.jedis.RedisClient; + +import org.springframework.ai.document.DocumentMetadata; +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.redis.RedisVectorStore.MetadataField; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +/** + * Upsert verification for {@link RedisVectorStore}, running the shared + * {@link AbstractVectorStoreUpsertTests} suite. + *

+ * Like {@link RedisVectorStoreIT}, this needs only Testcontainers Redis Stack and no + * hosted embedding provider: {@code doUpsert} never embeds, and read-back only needs a + * query vector, so a {@link FixedDimensionEmbeddingModel} is wired instead. Redis writes + * a batch in a single pipeline, 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 RedisVectorStoreUpsertIT extends AbstractVectorStoreUpsertTests { + + private static final int EMBEDDING_DIMENSIONS = 4; + + @Container + static RedisStackContainer redisContainer = new RedisStackContainer( + RedisStackContainer.DEFAULT_IMAGE_NAME.withTag(RedisStackContainer.DEFAULT_TAG)); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestApplication.class); + + @BeforeEach + void cleanDatabase() { + this.contextRunner.run(context -> context.getBean(RedisVectorStore.class).getJedisClient().flushAll()); + } + + @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 RedisVectorStore vectorStore(EmbeddingModel embeddingModel) { + return RedisVectorStore + .builder(RedisClient.builder() + .hostAndPort(redisContainer.getHost(), redisContainer.getFirstMappedPort()) + .build(), embeddingModel) + // Redis returns only the fields declared here, so both the suite's "tag" + // and the content_ref pointer have to be declared to survive a read. + .metadataFields(MetadataField.tag("tag"), MetadataField.tag(DocumentMetadata.CONTENT_REF.value())) + .initializeSchema(true) + .build(); + } + + @Bean + public EmbeddingModel embeddingModel() { + return new FixedDimensionEmbeddingModel(EMBEDDING_DIMENSIONS); + } + + } + +}