Engineering analysis — September 27, 2026. Based on the Spring AI milestone released September 25.
An enterprise search application can accept every vector, return plausible results, and still be wrong. The dangerous case is not necessarily a malformed request. It is a successful import of vectors that belong to a different embedding space.
Spring AI's latest milestone makes this a timely architecture topic. For Java teams connecting AI to business data, the practical question is increasingly who owns the contracts between ingestion, storage and retrieval.
In retrieval-augmented generation (RAG), an application retrieves relevant material to supply as context to a language model. Embeddings represent content as numerical vectors for similarity search. If those vectors and the query encoder disagree about their meaning, the wrong evidence can reach an otherwise functioning assistant.
Original Homann Software architecture diagram. The contract gate is an application design proposed in this article.
Why this is our topic for September 27
Spring AI 2.1.0-M1 was announced on September 25. It introduces ordered message parts, a Responses API integration, and ingestion of precomputed embeddings. It uses Spring Boot 4.2.0-M2 and remains a milestone whose APIs may change. These are release facts, not evidence of production readiness. Spring release announcement
Our editorial choice is the architectural consequence of external embeddings: a framework boundary becomes a data ownership boundary. That aligns closely with Homann Software's Java, Spring and domain-driven design experience. It also adds a concrete data-pipeline perspective to our recent coverage of agent authorization and verification.
Two other current signals deserve attention. Spring announced a consolidated monthly release train on September 21, with its next regular patch train scheduled for October 22. Azul announced a natural-language interface for querying Java estate licensing and security information on September 23. The former concerns dependency operations; the latter is a vendor product announcement, not independent proof of effectiveness. We chose Spring AI because it offers a recent, directly actionable architecture problem that can be demonstrated without a paid service. Spring release cadence · Azul announcement
This is a relevance-based editorial selection from the sources checked on September 27, not a measured ranking of the entire IT industry.
What actually changes for ingestion
The new VectorStore.upsert accepts an EmbeddedDocument containing a document and a supplied vector. Unlike add, it does not generate embeddings. The documentation lists pgvector, Redis, Elasticsearch and Qdrant support; other implementations must opt in. IDs are used for replacement, and some backends require UUID-shaped IDs. Spring AI 2.1 vector-store reference
That separation is useful when a team computes embeddings in a batch job, imports an existing corpus or owns a specialized preprocessing pipeline. But it introduces another independently deployable producer. Treat that producer as a service with an explicit contract, an owner and a migration strategy.
The documentation distinguishes vector width from vector-space compatibility. It also notes that query text is still embedded through the store's configured model. A compatible ingestion pipeline therefore needs a compatible query encoder. Batch dimension checks depend on whether the store knows the index width; otherwise validation is left to the database. None of that establishes general transaction atomicity for network or backend failures. Spring AI vector-store contract
Equal dimensions are not equal meaning
Imagine a product-support index with three-dimensional vectors in a deliberately tiny teaching example. Producer A sends a vector generated under model revision A1. A replacement producer sends three coordinates generated under revision A2. The request shape is identical. The application must nevertheless establish whether both revisions inhabit the same space before allowing a mixed index.
For this article, we propose an application-owned embedding contract with a stable identifier such as catalog/model-a@r1/preprocess-v1, plus an expected dimension. The identifier represents an approved configuration, not a free-form label invented by the request sender. Its registry should describe the model revision, relevant preprocessing, normalization and the corresponding query configuration.
Do not confuse this identifier with proof. A malicious or broken producer can attach the correct label to the wrong vector. In production, derive or verify provenance through a trusted ingestion path, restrict which producer may write each index, and keep enough lineage to investigate an import.
Tenant identity is a separate contract. Resolve it from authenticated application context. Metadata supplied by a caller is not authorization, and an ingestion check does not protect the retrieval path. Apply tenant constraints during retrieval and when fetching the underlying document, too.
A small Java boundary you can actually test
The accompanying EmbeddingGate is ordinary Java, independent of Spring AI. It demonstrates validation before a storage adapter is called. Its immutable entry snapshots prevent later mutation of the caller's vector list from changing an accepted entry.
The central method is shown below. The complete class and executable tests follow in the appendix.
public void ingest(String authorizedTenant, List<Entry> entries) {
requireText(authorizedTenant);
var batch = List.copyOf(entries);
for (var entry : batch) {
if (!authorizedTenant.equals(entry.tenant()))
throw new IllegalArgumentException("tenant mismatch");
if (!expected.equals(entry.contract()))
throw new IllegalArgumentException("embedding contract mismatch");
if (entry.vector().size() != expected.dimensions())
throw new IllegalArgumentException("vector width mismatch");
if (entry.vector().stream().anyMatch(v -> !Float.isFinite(v)))
throw new IllegalArgumentException("non-finite coordinate");
}
if (!batch.isEmpty()) sink.upsert(batch);
}
The ordering matters. A batch containing a valid entry followed by an invalid entry must produce zero storage calls. Validating and writing each entry inside the same loop would weaken that guarantee.
The boundary intentionally does not normalize vectors or reject zero vectors. Those decisions belong to the chosen model and similarity metric. Add them as explicit contract rules when required. Similarly, cap request sizes upstream: defensive copying is not a resource-limit policy.
An eventual Spring adapter should map validated entries into the framework's document representation, choose backend-compatible IDs, attach trusted lineage metadata and invoke the supported storage operation. That adapter is not included or claimed to be tested here. Document IDs in this demonstration are business keys, not ready-made pgvector or Qdrant IDs.
What the tests establish
The example was compiled with javac --release 21 -Xlint:all and executed on Temurin Java 21.0.11. All 26 checks passed. The package contains the source and captured output.
| Checked behavior | Why it matters |
|---|---|
| Valid batch reaches the sink unchanged | The boundary preserves accepted data. |
| A different model revision or preprocessing version is rejected despite equal width | Shape validation alone cannot enforce the declared contract. |
| Tenant, declared width and actual width mismatches are rejected | Independent constraints remain explicit. |
| Empty, NaN and infinite vectors are rejected | Invalid numeric inputs fail before storage. |
| A bad second entry causes zero sink calls | Whole-batch preflight precedes effects. |
| Caller mutation cannot change an entry snapshot | Accepted input is stable across the boundary. |
| Empty batches do not call storage; backend failures propagate | Operational behavior is visible to the caller. |
These are deterministic boundary checks against a recording sink. They do not establish retrieval quality, database transactions, retry correctness, tenant security across a deployed system or Spring AI integration compatibility. No model endpoint or vector database was called. There is no performance benchmark or cost-saving claim.
Stable IDs are only part of safe retries
Our production design recommendation is to distinguish document identity, source revision and index generation. Repeating the same import should target the same document key. A delayed older import should not overwrite a newer source revision merely because its retry finally succeeded.
That usually needs additional state: an authoritative revision check, a serialized ingestion workflow or backend-specific conditional writes. For chunked documents, also track a manifest. If a new document version produces four chunks instead of six, replacing four rows does not remove the two obsolete chunks.
Use a namespace that includes tenant and corpus identity. Avoid ambiguous concatenation when generating storage keys; encode structured fields or use a canonical length-delimited representation. Test the actual backend's ID restrictions and collision assumptions.
Migrate the index and query encoder together
For an embedding change, we recommend building a separate index generation rather than gradually mixing unverified spaces in the live corpus.
- Freeze the new contract and capture the source-data revision used for rebuilding.
- Build the replacement index through the trusted ingestion path, including deletions and updated access rules.
- Run representative queries with the corresponding query encoder. Evaluate relevance, missing evidence and cross-tenant rejection, not merely HTTP success.
- Route index generation and query encoder as one deployment decision. Preserve the old pair for a deliberate rollback window.
- Continue processing source changes during the transition, then retire the previous generation under a documented retention policy.
Choose acceptance thresholds from the use case. A technical documentation assistant and a customer-facing product search have different failure costs. Include examples where the right behavior is to return no evidence. Do not use fluent model answers as the sole retrieval-quality score.
What to do with the milestone now
Evaluate the new ingestion capability in an isolated branch, pin the complete dependency set and test your chosen backend. Keep milestone adoption separate from the routine patch process. A feature announcement is an invitation to evaluate; it does not replace your compatibility and operational evidence.
For a first experiment, select one corpus and one producer. Document who owns its contract, how query compatibility is enforced and how a bad import is rolled back. That exercise remains useful even if you defer the framework upgrade.
The durable architecture lesson is straightforward: externally generated embeddings are versioned business-system inputs. Give them explicit ownership, enforceable contracts and a tested migration path.
Reproduce the Java example
Save the two appendix listings as EmbeddingGate.java and EmbeddingGateTest.java in one directory. With a JDK 21 installed, run:
javac --release 21 -Xlint:all EmbeddingGate.java EmbeddingGateTest.java
java EmbeddingGateTest
Expected final line: 26 checks passed. A failed check throws an exception and terminates execution. The example uses no API keys or external libraries.
EmbeddingGate.java
import java.util.List;
import java.util.Objects;
/** Application-owned boundary; deliberately independent of Spring APIs. */
public final class EmbeddingGate {
public record Contract(String space, int dimensions) {
public Contract {
requireText(space);
if (dimensions < 1) throw new IllegalArgumentException("dimensions");
}
}
public record Entry(String tenant, String id, Contract contract,
List<Float> vector) {
public Entry {
requireText(tenant);
requireText(id);
Objects.requireNonNull(contract);
vector = List.copyOf(vector);
}
}
@FunctionalInterface
public interface Sink {
void upsert(List<Entry> entries);
}
private final Contract expected;
private final Sink sink;
public EmbeddingGate(Contract expected, Sink sink) {
this.expected = Objects.requireNonNull(expected);
this.sink = Objects.requireNonNull(sink);
}
public void ingest(String authorizedTenant, List<Entry> entries) {
requireText(authorizedTenant);
var batch = List.copyOf(entries);
for (var entry : batch) {
if (!authorizedTenant.equals(entry.tenant()))
throw new IllegalArgumentException("tenant mismatch");
if (!expected.equals(entry.contract()))
throw new IllegalArgumentException("embedding contract mismatch");
if (entry.vector().size() != expected.dimensions())
throw new IllegalArgumentException("vector width mismatch");
if (entry.vector().stream().anyMatch(v -> !Float.isFinite(v)))
throw new IllegalArgumentException("non-finite coordinate");
}
if (!batch.isEmpty()) sink.upsert(batch);
}
private static void requireText(String value) {
if (value == null || value.isBlank())
throw new IllegalArgumentException("missing identifier");
}
}
EmbeddingGateTest.java
import java.util.ArrayList;
import java.util.List;
public class EmbeddingGateTest {
private static int passed;
private static final EmbeddingGate.Contract V1 =
new EmbeddingGate.Contract("catalog/model-a@r1/preprocess-v1", 3);
private static final class RecordingSink implements EmbeddingGate.Sink {
int calls;
List<EmbeddingGate.Entry> received = List.of();
public void upsert(List<EmbeddingGate.Entry> entries) {
calls++;
received = entries;
}
}
private static EmbeddingGate.Entry entry(String tenant, EmbeddingGate.Contract c,
List<Float> vector) {
return new EmbeddingGate.Entry(tenant, "document-1/chunk-1", c, vector);
}
private static EmbeddingGate.Entry valid() {
return entry("tenant-a", V1, List.of(1f, 0f, -1f));
}
private static void check(boolean ok, String name) {
if (!ok) throw new AssertionError(name);
passed++;
System.out.println("PASS " + name);
}
private static void rejects(String name, Runnable action) {
try { action.run(); }
catch (IllegalArgumentException | NullPointerException expected) {
check(true, name); return;
}
throw new AssertionError(name + " was accepted");
}
private static void rejectsBeforeWrite(String name, EmbeddingGate.Entry bad) {
var sink = new RecordingSink();
var gate = new EmbeddingGate(V1, sink);
rejects(name, () -> gate.ingest("tenant-a", List.of(valid(), bad)));
check(sink.calls == 0, name + ": no sink call, even with valid first entry");
}
public static void main(String[] args) {
var sink = new RecordingSink();
var gate = new EmbeddingGate(V1, sink);
gate.ingest("tenant-a", List.of(valid()));
check(sink.calls == 1 && sink.received.equals(List.of(valid())), "valid batch delivered unchanged");
gate.ingest("tenant-a", List.of());
check(sink.calls == 1, "empty batch causes no call");
rejectsBeforeWrite("cross-tenant entry", entry("tenant-b", V1, List.of(1f, 0f, -1f)));
rejectsBeforeWrite("same width, different model revision", entry("tenant-a",
new EmbeddingGate.Contract("catalog/model-a@r2/preprocess-v1", 3), List.of(1f, 0f, -1f)));
rejectsBeforeWrite("same width, different preprocessing", entry("tenant-a",
new EmbeddingGate.Contract("catalog/model-a@r1/preprocess-v2", 3), List.of(1f, 0f, -1f)));
rejectsBeforeWrite("declared dimensions differ", entry("tenant-a",
new EmbeddingGate.Contract(V1.space(), 2), List.of(1f, 0f)));
rejectsBeforeWrite("actual dimensions differ", entry("tenant-a", V1, List.of(1f, 0f)));
rejectsBeforeWrite("empty vector", entry("tenant-a", V1, List.of()));
for (float bad : new float[]{Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY})
rejectsBeforeWrite("non-finite " + bad, entry("tenant-a", V1, List.of(1f, bad, -1f)));
rejects("blank identity", () -> gate.ingest(" ", List.of(valid())));
rejects("null batch", () -> gate.ingest("tenant-a", null));
rejects("invalid contract", () -> new EmbeddingGate.Contract("space", 0));
rejects("blank document id", () -> new EmbeddingGate.Entry("tenant-a", "", V1, List.of(1f)));
var vector = new ArrayList<>(List.of(1f, 0f, -1f));
var snapshot = entry("tenant-a", V1, vector);
vector.set(0, Float.NaN);
gate.ingest("tenant-a", List.of(snapshot));
check(sink.received.get(0).vector().get(0) == 1f, "caller mutation cannot alter vector snapshot");
var broken = new EmbeddingGate(V1, entries -> { throw new IllegalStateException("backend unavailable"); });
boolean propagated = false;
try { broken.ingest("tenant-a", List.of(valid())); }
catch (IllegalStateException expected) { propagated = true; }
check(propagated, "backend failure propagates to caller");
System.out.println(passed + " checks passed");
}
}