Spring AI Semantic Search with PGVector and OpenAI

A Spring Boot example that shows how to build semantic search over PDF documents using Spring AI, OpenAI embeddings and PostgreSQL + pgvector as the vector store. It also exposes a simple chat endpoint backed by an OpenAI chat model.

View source on GitHub Portfolio index

A Spring Boot example that shows how to build semantic search over PDF documents using Spring AI, OpenAI embeddings and PostgreSQL + pgvector as the vector store. It also exposes a simple chat endpoint backed by an OpenAI chat model.

The project is meant for learning. It walks through the core ideas behind Retrieval-Augmented Generation (RAG): generating embeddings, measuring cosine similarity, splitting documents into chunks, storing vectors, and retrieving the most relevant fragments for a query.


Table of Contents


Features

Tech Stack

Component Version / Detail
Java 24
Spring Boot 4.1.0
Spring AI 2.0.0
Embedding model OpenAI text-embedding-3-small (1536 d)
Vector database PostgreSQL 16 + pgvector (pgvector/pgvector:pg16)
Build tool Maven (wrapper included)
Container runtime Docker / Docker Compose

Architecture

                   ┌──────────────────────────────┐
                   │        PDF documents         │
                   │  (src/main/resources/...)    │
                   └──────────────┬───────────────┘
                                  │ PagePdfDocumentReader
                                  ▼
                   ┌──────────────────────────────┐
                   │   Documents (one per page)   │
                   └──────────────┬───────────────┘
                                  │ TokenTextSplitter
                                  ▼
                   ┌──────────────────────────────┐
                   │        Chunks (fragments)    │
                   └──────────────┬───────────────┘
                                  │ OpenAI Embedding Model
                                  ▼
                   ┌──────────────────────────────┐
                   │ PostgreSQL + pgvector        │
                   │ table: public.vector_store   │
                   │ HNSW index, cosine distance  │
                   └──────────────┬───────────────┘
                                  │ similaritySearch(query, topK, threshold)
                                  ▼
                   ┌──────────────────────────────┐
                   │   Most relevant chunks       │
                   └──────────────────────────────┘

Project Structure

src/main/java/com/mx/development/jose/said
├── ApiSpringAiDemoV1Application.java   # Spring Boot entry point
├── config
│   └── VectorStoreConfig.java          # Optional in-memory SimpleVectorStore (disabled)
├── controller
│   └── ChatController.java             # REST endpoint for chat
├── dto
│   ├── IndexingResult.java             # Pages read / chunks indexed
│   └── Resultado.java                  # Text + similarity score
├── runner
│   ├── PdfIndexingRunner.java          # Indexes and searches spring_ai.pdf (disabled)
│   ├── PdfIndexingRunner2.java         # Indexes and searches Proyecto_F3 PDF (active)
│   └── SemanticSearchRunner.java       # Embedding / cosine similarity experiments (disabled)
└── service
    ├── chat
    │   ├── ChatService.java
    │   └── ChatServiceImpl.java        # Uses ChatClient to call the OpenAI chat model
    └── pdf
        └── PdfIndexingService.java     # Indexing (read → split → store) and search

src/main/resources
├── application.yml                     # Port, app name, active profile, logging
├── application-openai.yml              # Datasource, OpenAI and pgvector settings
└── documents
    ├── Proyecto_F3._Completa_Realizar.pdf
    └── spring_ai.pdf

compose.yaml                            # pgvector PostgreSQL container

Prerequisites

Maven is not required; the project ships with the Maven wrapper (./mvnw).

Configuration

Environment variables

Variable Description
API_KEY Your OpenAI API key
export API_KEY=sk-...

application.yml

application-openai.yml

Property Value Meaning
spring.datasource.url jdbc:postgresql://localhost:5432/spring_ai_db PostgreSQL connection
spring.ai.openai.embedding.options.model text-embedding-3-small Embedding model
spring.ai.vectorstore.pgvector.initialize-schema true Creates the extension and table on startup
spring.ai.vectorstore.pgvector.index-type HNSW Approximate nearest-neighbor index
spring.ai.vectorstore.pgvector.distance-type COSINE_DISTANCE Similarity metric
spring.ai.vectorstore.pgvector.dimensions 1536 Must match the embedding model
spring.ai.vectorstore.pgvector.table-name vector_store Table that holds the vectors
spring.ai.vectorstore.pgvector.remove-existing-vector-store-table false Keeps data between restarts
app.indexing.enabled true Indexes the PDF on startup

Docker Compose

compose.yaml defines a pgvector/pgvector:pg16 container:

Thanks to spring-boot-docker-compose, the container starts automatically when you run the application. You can also start it manually:

docker compose up -d

Running the Application

# 1. Set your OpenAI API key
export API_KEY=sk-...

# 2. Run the application (Docker must be running)
./mvnw spring-boot:run

On Windows:

set API_KEY=sk-...
mvnw.cmd spring-boot:run

On startup, PdfIndexingRunner2 indexes Proyecto_F3._Completa_Realizar.pdf (when app.indexing.enabled=true) and then runs a sample semantic search. The console shows something like:

INDEXACIÓN DEL DOCUMENTO 2
------------------------------
Documentos leídos  : 12
Chunks indexados   : 20
Estado             : Finalizado

BÚSQUEDA SEMÁNTICA
------------------------------
Consulta: Trabajando con CSV 2
Resultados encontrados: 5

RESULTADO 1
------------------------------
Similitud : 0.6123
Metadatos : {page_number=4, file_name=Proyecto_F3._Completa_Realizar.pdf, ...}
Contenido : ...

(The numbers above are illustrative.)

How It Works

All the document logic lives in PdfIndexingService.

Step 1 – Indexing (run once per document)

PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(pdfResource);
List<Document> documentos = pdfReader.get();                 // one Document per page

TokenTextSplitter textSplitter = TokenTextSplitter.builder().build();
List<Document> fragmentos = textSplitter.apply(documentos);  // split into chunks

vectorStore.add(fragmentos);                                  // embed + store in pgvector

vectorStore.add(...) calls the OpenAI embedding model for each chunk and saves the text, metadata and vector in the vector_store table.

Step 2 – Searching (run as many times as needed)

SearchRequest searchRequest = SearchRequest.builder()
        .query(consulta)
        .topK(top)                          // max number of results
        .similarityThreshold(similarity)    // 0 = different, 1 = identical
        .build();

return vectorStore.similaritySearch(searchRequest);

The query is embedded with the same model, and pgvector returns the closest chunks by cosine distance. Each returned Document includes its text, metadata (file name, page number) and similarity score.

REST API

Chat

GET /api/v1/chat-service/chat?question={question}

Sends the question to the OpenAI chat model through ChatClient and returns the answer as plain text.

curl "http://localhost:8088/api/v1/chat-service/chat?question=What%20is%20Spring%20AI%3F"

This endpoint calls the chat model directly. It does not use the vector store (it is not RAG yet), so it is a natural next step to extend.

Learning Runners

The runner package contains CommandLineRunner examples. Enable or disable them by adding or removing @Component / @Bean annotations.

Runner Status What it does
PdfIndexingRunner2 Active Indexes Proyecto_F3._Completa_Realizar.pdf and searches for "Trabajando con CSV 2" (top 5, threshold 0.20).
PdfIndexingRunner Disabled Indexes spring_ai.pdf and searches for "¿Por qué usar Spring AI?" (top 1, threshold 0.50).
SemanticSearchRunner Disabled Embedding experiments (see below).

SemanticSearchRunner includes several experiments, each defined as a CommandLineRunner bean:

They all use a hand-written cosineSimilarity method:

similarity(A, B) = (A · B) / (‖A‖ · ‖B‖)

To try one, add @Component to SemanticSearchRunner and make sure only the desired method has @Bean.

Switching to an In-Memory Vector Store

VectorStoreConfig contains a SimpleVectorStore bean that keeps vectors in memory instead of PostgreSQL. It is disabled by default. To use it:

  1. Uncomment @Bean in VectorStoreConfig.
  2. Remove (or exclude) the spring-ai-starter-vector-store-pgvector dependency so there is only one VectorStore bean.

This is handy for quick experiments without Docker, but the data is lost when the application stops.

Notes and Tips