Spring AI Embeddings & Vector Stores
How embeddings and vector stores work in Spring AI, with a complete pgvector Spring Boot setup — schema, indexes, metadata filtering, dimensions and the mistakes that force a re-ingest.
On this page
Vector search is the foundation under RAG, semantic search, deduplication and recommendation. The concept takes two minutes; the operational details are where projects go wrong.
What an embedding actually is
An embedding model maps text to a fixed-length array of floats. The useful property is that texts with similar meaning produce vectors that are close together in that space.
float[] a = embeddingModel.embed("How do I reset my password?");
float[] b = embeddingModel.embed("I forgot my login credentials");
float[] c = embeddingModel.embed("What is the refund policy?");
// cosineSimilarity(a, b) ≈ 0.85 — different words, same intent
// cosineSimilarity(a, c) ≈ 0.20 — unrelatedThat is the whole idea. Keyword search finds documents that share words; vector search finds documents that share meaning. Both have failure modes: vector search misses exact identifiers such as error codes and part numbers, which is why serious systems combine the two.
Setting up pgvector
pgvector is a PostgreSQL extension adding a vector column type with similarity operators. For
teams already running Postgres it removes an entire piece of infrastructure.
Run it
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: javaagentic
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d javaagentic']
interval: 5s
retries: 10
volumes:
pgdata:Dependencies
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/javaagentic
username: app
password: secret
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small # 1536 dimensions
vectorstore:
pgvector:
# Convenient in development; manage the schema with Flyway or
# Liquibase in production so migrations are reviewable.
initialize-schema: true
# Must equal the embedding model's output dimension.
dimensions: 1536
# HNSW: fast, accurate, higher build cost. The right default.
index-type: HNSW
# COSINE_DISTANCE pairs with normalised embeddings, which is what
# the common hosted models produce.
distance-type: COSINE_DISTANCE
max-document-batch-size: 1000The schema it creates
Knowing the shape helps when you need to debug a query or write a migration by hand:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS vector_store (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
content text,
metadata jsonb,
embedding vector(1536)
);
CREATE INDEX ON vector_store USING hnsw (embedding vector_cosine_ops);Metadata is jsonb, which is why filter expressions can be arbitrarily structured — and why you
should add a GIN index on it if you filter heavily.
Ingesting documents
package com.javaagentic.demo.rag;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DocumentIngestionService {
private final VectorStore vectorStore;
private final TokenTextSplitter splitter;
public DocumentIngestionService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
// Chunk sizing is the single highest-impact tuning knob in retrieval.
// ~400 tokens with ~80 overlap suits prose documentation; code and
// tables usually want larger chunks that keep structures intact.
this.splitter = TokenTextSplitter.builder()
.withChunkSize(400)
.withMinChunkSizeChars(200)
.build();
}
@Transactional
public int ingest(String sourceId, String tenantId, String text) {
Document document = new Document(text, Map.of(
// Metadata written at ingest time is the only thing you can
// filter on later. Err on the side of recording more.
"sourceId", sourceId,
"tenantId", tenantId,
"ingestedAt", java.time.Instant.now().toString()));
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks); // embeds and persists in one call
return chunks.size();
}
}Searching
package com.javaagentic.demo.rag;
import java.util.List;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
@Service
public class RetrievalService {
private final VectorStore vectorStore;
public RetrievalService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public List<Document> retrieve(String query, String tenantId) {
return vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.topK(5)
// Without a threshold you always get five results, however
// irrelevant — and irrelevant context makes answers worse,
// not merely unhelpful.
.similarityThreshold(0.7)
// Tenant isolation belongs in the filter, not in a prompt
// instruction. Similarity is not an access control mechanism.
.filterExpression("tenantId == '%s'".formatted(tenantId))
.build());
}
}Choosing a store
| Store | Reach for it when | Watch out for |
|---|---|---|
| SimpleVectorStore | Tests and local development | In-memory; no persistence, no scale |
| pgvector | You already run Postgres — the default choice | Index build time on very large tables |
| Qdrant | Dedicated store with strong filtering | Another service to operate |
| Redis | You already run Redis and want low latency | Memory cost at scale |
| Elasticsearch | You need hybrid keyword + vector search | Heavier operational footprint |
| Milvus / Weaviate | Hundreds of millions of vectors | Real distributed-systems overhead |
The honest advice: start with pgvector. Most teams that adopted a dedicated vector database early would have been fine without one, and they carry the operational cost anyway.
Tuning retrieval quality
Retrieval quality dominates the quality of any RAG system. In rough order of impact:
- Chunk size. Too large and the relevant sentence is diluted by surrounding text; too small and it loses the context that makes it meaningful. 300-600 tokens is a sensible starting range. Measure rather than guess.
- Overlap. 10-20% of chunk size stops answers being cut in half at a boundary.
- Threshold. Around 0.7 for cosine similarity. Tune it against real queries.
topK. More context is not better. Five good chunks beat twenty mediocre ones, and cost less.- Metadata filters. Narrowing the candidate set before similarity ranking improves both precision and latency.
- Re-ranking. A cross-encoder over the top 20-50 candidates is the largest single quality win available once the basics are right.
Common failure modes
Dimension mismatch. expected 1536 dimensions, got 3072 — the embedding model changed but the
table did not. Re-create and re-ingest.
Everything scores 0.99. Usually a normalisation or distance-type mismatch. Confirm
distance-type suits your embedding model.
Ingest is slow. Embedding calls are being made one document at a time. Raise
max-document-batch-size and batch on your side too.
Index not used. Postgres will ignore an HNSW index if statistics are stale or the query shape
does not match. Check with EXPLAIN ANALYZE.
Good retrieval, bad answers. The problem is downstream in the prompt, not in retrieval — see building a RAG pipeline with Spring Boot.
Next
- Building a RAG pipeline with Spring Boot — using this store to answer questions
- Vector databases deep dive — how HNSW actually works
Frequently Asked Questions
Do I need a dedicated vector database, or is pgvector enough?
What dimension should my vector column be?
Why does similaritySearch return irrelevant documents?
How much do embeddings cost?
Can I filter vector search by metadata in Spring AI?
Related tutorials
- Prompt Engineering for Java DevelopersPrompt engineering explained for engineers, not marketers: system prompts, few-shot, delimiters, output contracts and grounding — each as testable Spring AI code, not vibes.
- Building a RAG Pipeline with Spring BootBuild a production RAG pipeline in Spring Boot: document ingestion, chunking, pgvector retrieval, the QuestionAnswerAdvisor, citations, evaluation and the failure modes nobody warns you about.
- The Spring AI ChatClient APIMaster the Spring AI ChatClient: system messages, prompt templates, streaming with SSE, chat memory, advisors and per-call options — with complete Spring Boot code.
- Spring AI Function Calling & @ToolHow Spring AI function calling works, with complete @Tool examples: registering tools, typed parameters, error handling, the agent loop, and how to stop a tool-using model doing damage.