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
- Architecture
- Project Structure
- Prerequisites
- Configuration
- Running the Application
- How It Works
- REST API
- Learning Runners
- Switching to an In-Memory Vector Store
- Notes and Tips
Features
- PDF ingestion: reads PDF files page by page with
PagePdfDocumentReader. - Chunking: splits documents into token-based fragments with
TokenTextSplitter. - Embeddings: generates 1536-dimension vectors with OpenAI
text-embedding-3-small. - Persistent vector store: stores the vectors in PostgreSQL using the
pgvectorextension with an HNSW index and cosine distance. - Semantic search: retrieves the top-K most similar chunks above a configurable similarity threshold.
- Chat endpoint: a REST endpoint that forwards a question to the OpenAI chat model through Spring AI's
ChatClient. - Docker Compose integration: Spring Boot starts the pgvector container automatically.
- Embedding playground: optional runners that compare words and sentences by cosine similarity, to build intuition about embeddings.
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
- JDK 24
- Docker (Docker Desktop or similar) running locally
- An OpenAI API key – get one at https://platform.openai.com/api-keys
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
- Server port: 8088
- Active profile: openai
- DEBUG logging for
org.springframework.aiandorg.springframework.web.client, so you can see the requests sent to OpenAI.
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:
- Database:
spring_ai_db - User / password:
postgres/postgres - Port:
5432 - Persistent volume:
pgvector-data
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:
embedding– generates one embedding and prints its dimension and first 10 values.embeddingCompare– compares sentences (Spring AI vs. Java framework vs. pizza vs. dog) by cosine similarity.embedding2– similarity matrix for AI-related terms in Spanish and English.embedding3– similarity matrix for vehicle-related words plus an unrelated word.embedding4– ranks a list of words by similarity to a query (for example, the misspelled"Pinsa"vs."Pizza").
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:
- Uncomment
@BeaninVectorStoreConfig. - Remove (or exclude) the
spring-ai-starter-vector-store-pgvectordependency so there is only oneVectorStorebean.
This is handy for quick experiments without Docker, but the data is lost when the application stops.
Notes and Tips
- Avoid duplicate indexing. Because
remove-existing-vector-store-tableisfalse, each startup withapp.indexing.enabled=trueadds the same chunks again. After the first run, setapp.indexing.enabled: falseto only run searches. - Reset the vector store by dropping the table or removing the Docker volume:
bash docker compose down -v - Tune the threshold. A high
similarityThreshold(e.g. 0.50+) returns fewer but more precise results; a lower one (e.g. 0.20) returns more, less strict matches. - Dimensions must match. If you change the embedding model, update
dimensionsand recreate the table. - Inspect the data directly in PostgreSQL:
bash docker exec -it pgvector-spring-ai psql -U postgres -d spring_ai_db \ -c "SELECT id, metadata, left(content, 80) FROM vector_store LIMIT 5;" - Costs. Every indexing run and every search calls the OpenAI API, which is billed per token.