Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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  — unrelated

That 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

docker-compose.yml
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

pom.xml
<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

application.yml
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: 1000

The 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

DocumentIngestionService.java
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

RetrievalService.java
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

StoreReach for it whenWatch out for
SimpleVectorStoreTests and local developmentIn-memory; no persistence, no scale
pgvectorYou already run Postgres — the default choiceIndex build time on very large tables
QdrantDedicated store with strong filteringAnother service to operate
RedisYou already run Redis and want low latencyMemory cost at scale
ElasticsearchYou need hybrid keyword + vector searchHeavier operational footprint
Milvus / WeaviateHundreds of millions of vectorsReal 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:

  1. 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.
  2. Overlap. 10-20% of chunk size stops answers being cut in half at a boundary.
  3. Threshold. Around 0.7 for cosine similarity. Tune it against real queries.
  4. topK. More context is not better. Five good chunks beat twenty mediocre ones, and cost less.
  5. Metadata filters. Narrowing the candidate set before similarity ranking improves both precision and latency.
  6. 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

Frequently Asked Questions

Do I need a dedicated vector database, or is pgvector enough?
For most applications pgvector is enough. It handles millions of vectors comfortably on ordinary hardware, and keeping vectors beside your relational data means one backup story, one transaction boundary and one operational runbook. Move to a dedicated store when you outgrow that — very large corpora, very high query rates, or a need for features such as multi-vector search that Postgres does not offer.
What dimension should my vector column be?
Exactly the output dimension of your embedding model — 1536 for text-embedding-3-small, 3072 for text-embedding-3-large, 384 for all-MiniLM-L6-v2. The dimension is fixed when the table is created, so changing embedding model means re-creating the table and re-embedding every document.
Why does similaritySearch return irrelevant documents?
Usually one of three things: no similarity threshold, so you always get topK results however poor; chunks that are too large, diluting the signal; or a query phrased very differently from the source text. Set a threshold around 0.7, reduce chunk size, and consider hybrid search when exact terms matter.
How much do embeddings cost?
Far less than generation. Embedding models are priced per token at a small fraction of chat model rates, and you embed each document once. The recurring cost is embedding the query on every search, which is a handful of tokens. Cost is rarely the constraint here; re-ingest time is.
Can I filter vector search by metadata in Spring AI?
Yes. SearchRequest accepts a filter expression evaluated against document metadata, so you can restrict retrieval to a tenant, a document version or a date range. This is the correct way to enforce tenant isolation — never rely on similarity alone to keep tenants apart.

Related tutorials