Files

119 lines
7.7 KiB
Markdown

# 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](https://ankurm.com/production-rag-spring-ai-java/) and
[the code tour](https://ankurm.com/spring-ai-rag-complete-example/)), 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](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
**Every number in the articles and in `docs/` comes from a file in [`docs/output/`](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`](src/test/java/com/ankurm/rag/support/HashingEmbeddingModel.java): words hashed into a vector | OpenAI `text-embedding-3-small` |
| Chat model (answers, reranker ratings, judge verdicts) | [`FakeChatModel`](src/test/java/com/ankurm/rag/support/FakeChatModel.java): 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
```bash
# 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 |
```bash
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](docs/01-the-shape-of-a-rag-pipeline.md) | stages, the smallest thing that works, what is not tested |
| [2. Chunking](docs/02-chunking.md) | `TokenTextSplitter` measured, a recursive chunker with overlap, a semantic chunker |
| [3. Ingestion](docs/03-ingestion.md) | PDF pages, whitespace, metadata, uploading twice |
| [4. Retrieval and reranking](docs/04-retrieval-and-reranking.md) | threshold, empty context, tenant filter and injection, LLM reranking |
| [5. Generation and the faithfulness check](docs/05-generation-and-the-faithfulness-check.md) | the judge, its four outcomes |
| [6. Observability and a production checklist](docs/06-observability-and-production-checklist.md) | 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](docs/output/01-pipeline-beans.txt) | the beans of the running application | `EndToEndTest` |
| [02-token-text-splitter.txt](docs/output/02-token-text-splitter.txt) | what `TokenTextSplitter` does to a fixed document | `ChunkingTest` |
| [03-recursive-and-semantic-chunkers.txt](docs/output/03-recursive-and-semantic-chunkers.txt) | the two chunkers of ours | `ChunkingTest` |
| [04-pdf-pages-and-metadata.txt](docs/output/04-pdf-pages-and-metadata.txt) | a PDF page as read, and tidied | `IngestionTest` |
| [05-ingestion-idempotency.txt](docs/output/05-ingestion-idempotency.txt) | upload twice, edit, re-export, naive `add()` (PostgreSQL) | `IngestionTest` |
| [06-retrieval-threshold-and-prompt.txt](docs/output/06-retrieval-threshold-and-prompt.txt) | threshold, augmented prompt, empty context | `RetrievalTest` |
| [07-tenant-filter-and-injection.txt](docs/output/07-tenant-filter-and-injection.txt) | tenant filter, filter-string injection, both stores | `TenantFilterTest` |
| [08-reranking.txt](docs/output/08-reranking.txt) | reranker calls, latency, parse failure | `RerankTest` |
| [09-faithfulness-check.txt](docs/output/09-faithfulness-check.txt) | the judge and the four outcomes | `FaithfulnessTest` |
| [10-end-to-end.txt](docs/output/10-end-to-end.txt) | HTTP ingest and query, rows in PostgreSQL, metrics | `EndToEndTest` |
| [11-question-answer-advisor.txt](docs/output/11-question-answer-advisor.txt) | the smallest RAG and its prompts | `SimpleAdvisorTest` |
| [12-api-facts.txt](docs/output/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](docs/output/13-dependencies.txt) | which artifact brings what | `scripts/capture-dependencies.sh` |
| [14-legacy-1x.txt](docs/output/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](docs/output/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.