Files

Spring AI 2.0 RAG: ingest PDFs, retrieve, rerank, answer, check

A retrieval-augmented-generation service on Spring Boot 4.1.1, Spring AI 2.0.1, Java 25 and PostgreSQL with pgvector. It is the companion code for the two articles on ankurm.com (the explanation and the code tour), and it replaces the 1.x code those articles used to carry. For upgrading an existing Spring AI 1.x project, see the migration guide.

Every number in the articles and in docs/ comes from a file in docs/output/, and the tests that write those files also assert them. The one big caveat is what is not real: the chat and embedding models in the tests are scripted stand-ins (next section).

What is real and what is scripted

Piece In the tests In the shipped application
PDF reading, chunking, PgVectorStore, PostgreSQL + pgvector, metadata filters real real
RetrievalAugmentationAdvisor, QuestionAnswerAdvisor, FactCheckingEvaluator, ChatClient, HTTP layer, Micrometer real real
Embedding model HashingEmbeddingModel: words hashed into a vector OpenAI text-embedding-3-small
Chat model (answers, reranker ratings, judge verdicts) FakeChatModel: scripted from the prompt OpenAI gpt-4o

So the tests show what Spring AI sends to the model and how this code handles each kind of reply. They do not show how a real model answers, what similarity scores or thresholds suit a real embedding model, whether reranking improves answers, or how well a real judge catches an invented claim. There is no relevance benchmark here. The application itself was never run against the OpenAI API in authoring.

Versions

Java 25
Spring Boot 4.1.1
Spring AI 2.0.1 (spring-ai-bom)
PostgreSQL / pgvector 16 / 0.6.0 used for every transcript (Debian package). docker-compose.yml uses pgvector/pgvector:pg16, which was not run here

Run it

# 1. PostgreSQL with pgvector, either:
docker compose up -d                      # localhost:5432, runs init.sql
#    or, with no Docker (Debian/Ubuntu, needs postgresql-16 and postgresql-16-pgvector packages):
scripts/pg-up.sh                          # 127.0.0.1:5439; prints the RAG_PG_URL to export

# 2. Regenerate every transcript (no API key needed)
export RAG_PG_URL=jdbc:postgresql://localhost:5432/ragdb    # or the one pg-up.sh printed
scripts/run-all.sh

# 3. Run the application against OpenAI
export OPENAI_API_KEY=sk-...
mvn spring-boot:run
Endpoint What it does
POST /api/ingest (multipart: file, tenantId, docType) reads, chunks, embeds and stores a PDF; returns ingested, updated or skipped
POST /api/query ({"question": "...", "tenantId": "acme"}) answer, the chunks used with their rerank scores, grounded, and status (answered, ungrounded, no_context)
GET /actuator/prometheus rag_* metrics
curl -F file=@handbook.pdf -F tenantId=acme localhost:8080/api/ingest
curl -H 'Content-Type: application/json' -d '{"question":"How many days of annual leave?","tenantId":"acme"}' localhost:8080/api/query

Chapters

1. The shape of a RAG pipeline stages, the smallest thing that works, what is not tested
2. Chunking TokenTextSplitter measured, a recursive chunker with overlap, a semantic chunker
3. Ingestion PDF pages, whitespace, metadata, uploading twice
4. Retrieval and reranking threshold, empty context, tenant filter and injection, LLM reranking
5. Generation and the faithfulness check the judge, its four outcomes
6. Observability and a production checklist metrics, alerts, checklist

Captured output

Written by scripts/run-all.sh. Files 01-11 and 15 are written by the tests (the same test asserts the numbers); 12-14 by the scripts.

File Shows Written by
01-pipeline-beans.txt the beans of the running application EndToEndTest
02-token-text-splitter.txt what TokenTextSplitter does to a fixed document ChunkingTest
03-recursive-and-semantic-chunkers.txt the two chunkers of ours ChunkingTest
04-pdf-pages-and-metadata.txt a PDF page as read, and tidied IngestionTest
05-ingestion-idempotency.txt upload twice, edit, re-export, naive add() (PostgreSQL) IngestionTest
06-retrieval-threshold-and-prompt.txt threshold, augmented prompt, empty context RetrievalTest
07-tenant-filter-and-injection.txt tenant filter, filter-string injection, both stores TenantFilterTest
08-reranking.txt reranker calls, latency, parse failure RerankTest
09-faithfulness-check.txt the judge and the four outcomes FaithfulnessTest
10-end-to-end.txt HTTP ingest and query, rows in PostgreSQL, metrics EndToEndTest
11-question-answer-advisor.txt the smallest RAG and its prompts SimpleAdvisorTest
12-api-facts.txt javap of the Spring AI classes used, and a count of any SemanticSearchCache class on the classpath scripts/capture-javap.sh
13-dependencies.txt which artifact brings what scripts/capture-dependencies.sh
14-legacy-1x.txt the 1.x article's code and configuration keys against 1.1.0 and 2.0.1 scripts/capture-legacy-compile.sh
15-config-keys.txt every spring.ai.* key against the jars' metadata ConfigKeysTest

Layout

init.sql                  the schema (rag.document_chunks, HNSW cosine index); the application never creates it
docker-compose.yml        PostgreSQL 16 + pgvector
src/main/java/.../
  ingest/                 IngestionService, IngestionTracker
  chunk/                  RecursiveChunker, SemanticChunker (TokenTextSplitter is Spring AI's)
  query/                  LlmReranker, RagQueryService
  config/                 RagConfig (the wiring), RagProperties
  web/                    RagController
src/test/java/.../        one test class per chapter topic; support/ holds the scripted models and the sample PDFs
legacy-1x/                the 1.x article's dependency block and ingestion calls, kept only to show they do not build
scripts/                  run-all.sh, pg-up.sh, capture-*.sh

Known limits

  • The ingestion tracker is in memory (chapter 3).
  • The reranker parses a bare integer and degrades silently when the model does not reply with one (chapter 4).
  • Filtered queries were not tested at a size where pgvector's approximate index matters (chapter 4).
  • The application has no authentication; tenantId in the request body is trusted, which is a demonstration, not a design.
  • Not run against a real model, real embeddings, or the pgvector/pgvector Docker image.