Spring AI RAG in Java: Complete Code Tour for Spring Boot 4 and Spring AI 2.0
A complete Spring Boot 4.1 and Spring AI 2.0.1 RAG project, file by file: the pgvector schema, configuration, wiring, endpoints and a real end-to-end run. The tests use scripted models, and the page says so wherever an answer appears.
Once RAG makes sense as an idea, the next question is the boring one: what does the project actually look like? This page is the answer: a complete Spring Boot 4.1 application that ingests PDFs into PostgreSQL with pgvector, retrieves and reranks chunks, answers through OpenAI, checks the answer, and reports metrics, with every file shown or linked and a run you can repeat. It is the code tour that goes with the explanation in Production-Grade RAG with Spring AI 2.0, which is where to go for why each stage exists and what breaks when it is left out. This page replaces an earlier one whose project could not be built on current Spring AI; the section “What changed from the 1.x version” lists the differences.
The project is the rag module of asmhatre/spring-ai, rag module. The application code is under src/main/java, the schema and compose file sit at the module root, and the tests write the transcripts quoted below into docs/output.
Versions. Spring Boot 4.1.1, Spring AI 2.0.1, JDK 25 (Temurin 25.0.4.1), Maven, PostgreSQL 16.13 with pgvector 0.6.0 (Debian packages). Retrieved 21 September 2026.
What was and was not run. The tests and the end-to-end run use a real PostgreSQL, real PDFs and real HTTP, with scripted stand-ins for the chat and embedding models, so nothing on this page shows how OpenAI answers. The application was never run against the OpenAI API. docker-compose.yml was read but not started here: every transcript ran on a PostgreSQL installed from Debian packages by scripts/pg-up.sh.
Run it
You need a JDK (25 was used; nothing here was tried on an older one), Maven, and a PostgreSQL 16 with the pgvector extension. Docker is the easy route to the database; a local install works as well. Then two things can be run: the test suite, which needs no API key and regenerates every transcript, and the application itself, which needs one.
# 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
That block is from the module README. The docker compose route has not been run for this page; the pg-up.sh route (pg-up.sh) is the one that produced every transcript. Two HTTP endpoints do the work, and a third exposes the metrics:
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"})
Returns the answer, the chunks used with their rerank scores, grounded, and a status of answered, ungrounded or no_context.
GET /actuator/prometheus
The rag_* meters.
curl -F [email protected] -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
(The two curl lines are from the same README.) The rest of this page walks the files in the order a request meets them.
Starting PostgreSQL with no Docker, including the two things that trip people up on a server without a matching time zone database: pg-up.sh and the comment in pom.xml.
The module is small on purpose: five classes do the work and one configuration class wires them. The picture shows who calls whom. The controller hands an upload to the ingestion service and a question to the query service. Ingestion writes through VectorStore, which is PgVectorStore here. The query service asks a chat client that carries the retrieval advisor, which searches the same vector store, and then asks a judge whether the answer is supported. The two model interfaces sit at the bottom because they are the only things that leave the machine.
Spring builds all of it from one configuration class plus component scanning. This is the list of beans the end-to-end test found in the running application context, with the shipped configuration (transcript 01):
stage / role bean name actual class
DocumentTransformer chunker TokenTextSplitter
EmbeddingModel embeddingModel HashingEmbeddingModel
VectorStore vectorStore PgVectorStore
RetrievalAugmentationAdvisor retrievalAdvisor RetrievalAugmentationAdvisor
LlmReranker reranker LlmReranker
ChatModel chatModel FakeChatModel
ChatClient ragChatClient DefaultChatClient
FactCheckingEvaluator factChecker FactCheckingEvaluator
IngestionService ingestionService IngestionService
RagQueryService ragQueryService RagQueryService
Two of those rows say Hashing and Fake because that run used the scripted models; with an OpenAI key the same two rows name the OpenAI classes. Everything else is what a production start would build.
(From docker-compose.yml; not run for this page.) The schema is one table and one index:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE SCHEMA IF NOT EXISTS rag;
-- metadata is json, the type Spring AI's PgVectorStore writes and its filter queries cast from.
-- 1536 is the size of a text-embedding-3-small vector; change it together with the model.
CREATE TABLE IF NOT EXISTS rag.document_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
content text,
metadata json,
embedding vector(1536)
);
-- HNSW with cosine distance, matching index-type and distance-type in application.yml.
CREATE INDEX IF NOT EXISTS document_chunks_embedding_idx
ON rag.document_chunks USING hnsw (embedding vector_cosine_ops);
(From init.sql.) Three details matter. The metadata column is json, the type PgVectorStore writes and casts from when it filters. The vector has 1,536 dimensions because that is the size of a text-embedding-3-small vector, so changing the embedding model means changing this number and rebuilding the table. And the application never creates any of it: initialize-schema is false in the configuration below, so the schema lives in a file you review, not in whatever the framework decides at start-up.
How the index and the distance function interact with filtered queries, which this repository did not test at scale: the callout in the tenant section of Production-Grade RAG with Spring AI 2.0.
Spring AI is not part of Spring Boot’s dependency management, so the project imports the Spring AI bill of materials itself and keeps the two compatible by hand. The two blocks below are from pom.xml, first the version properties and then the dependencies:
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
<spring-ai.version>2.0.1</spring-ai.version>
<!-- PgVectorStore needs a JdbcTemplate; the pgvector starter does not bring one. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Chat + embeddings from OpenAI. The starter was spring-ai-openai-spring-boot-starter in 1.x. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- The pgvector vector store. It was spring-ai-pgvector-store-spring-boot-starter in 1.x. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<!-- RetrievalAugmentationAdvisor and the modular RAG pieces. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
<!-- QuestionAnswerAdvisor. Its 1.x artifact, spring-ai-advisors-vector-store, stops at 2.0.0-M8. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
The comments in the file mark three places where this project differs from the earlier one. The artifact names for the OpenAI and pgvector starters changed. QuestionAnswerAdvisor moved to spring-ai-vector-store-advisor. And spring-boot-starter-jdbc has to be listed explicitly, because PgVectorStore needs a JdbcTemplate and the pgvector starter does not bring one. The dependency tree shows where spring-jdbc comes from (transcript 13):
## where spring-jdbc comes from (it is not under any Spring AI artifact)
41:+- org.springframework.boot:spring-boot-starter-jdbc:jar:4.1.1:compile
42:| +- org.springframework.boot:spring-boot-jdbc:jar:4.1.1:compile
47:| | \- org.springframework:spring-jdbc:jar:7.0.9:compile
48:| \- com.zaxxer:HikariCP:jar:7.0.2:compile
Every Spring AI artifact that ends up on the classpath, and what the pgvector starter brings: transcript 13.
The old and new starter names, checked against Maven Central: transcript 14.
Everything tunable is in one file. The datasource and the OpenAI key come from environment variables; the numbers under rag: are the ones the pipeline reads.
spring:
application:
name: spring-ai-rag
threads:
virtual:
enabled: true
datasource:
url: ${RAG_PG_URL:jdbc:postgresql://localhost:5432/ragdb}
username: ${RAG_PG_USER:raguser}
password: ${RAG_PG_PASSWORD:ragpass}
ai:
openai:
api-key: ${OPENAI_API_KEY}
# In 2.0.1 the "options" level (chat.options.model) is marked deprecated in the jars' metadata; the
# settings now sit directly under chat: and embedding:. Spring Boot ignores keys it does not know,
# so a misspelt key fails nothing: ConfigKeysTest checks every spring.ai key against the jars.
chat:
model: gpt-4o
temperature: 0.1
embedding:
model: text-embedding-3-small
vectorstore:
pgvector:
schema-name: rag
table-name: document_chunks
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
# false: the schema comes from init.sql, not from the application.
initialize-schema: false
rag:
retrieval:
top-k: 20
similarity-threshold: 0.0
rerank:
top-n: 5
chunking:
chunk-size: 512
min-chunk-size-chars: 350
management:
endpoints:
web:
exposure:
include: health, prometheus
(The whole of application.yml.) Under spring.ai.openai the settings sit directly under chat: and embedding:. The 1.x spelling, chat.options.model, is marked deprecated in the 2.0.1 metadata, and the repository has a test that checks every spring.ai key in this file against the metadata inside the jars (transcript 15).
The shipped threshold is 0.0 on purpose, and that is a starting point.rag.retrieval.similarity-threshold is 0.0, which accepts every chunk the search returns. That makes the demo work with any embedding model, and it also switches off the “nothing found” path entirely. Before real users, set it from questions your documents cannot answer, as Production-Grade RAG with Spring AI 2.0 explains in the retrieval section. The top-k of 20 and top-n of 5 are also starting points; nothing in this repository measured which values are best.
Why ddl-auto, JPA and the rest of the earlier configuration are gone: see “What changed from the 1.x version” below.
Wiring: one bean per stage
The configuration class turns the pipeline into beans. Each method is one stage, and a stage can be replaced by defining a different bean, without the others noticing:
/** Stage 1, chunking. Swap in {@code RecursiveChunker} or {@code SemanticChunker} here. */
@Bean
DocumentTransformer chunker(RagProperties properties) {
return TokenTextSplitter.builder()
.withChunkSize(properties.chunking().chunkSize())
.withMinChunkSizeChars(properties.chunking().minChunkSizeChars())
.build();
}
/** Stage 3, reranking. Built from the builder so it gets its own client, not the RAG one. */
@Bean
LlmReranker reranker(ChatClient.Builder builder, RagProperties properties, MeterRegistry metrics) {
return new LlmReranker(builder.build(), properties.rerank().topN(), metrics);
}
/** Stages 2 to 4: retrieve, rerank, and put the survivors into the prompt. */
@Bean
RetrievalAugmentationAdvisor retrievalAdvisor(VectorStore vectorStore, LlmReranker reranker,
RagProperties properties) {
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(properties.retrieval().topK())
.similarityThreshold(properties.retrieval().similarityThreshold())
.build())
.documentPostProcessors(reranker)
.queryAugmenter(ContextualQueryAugmenter.builder().allowEmptyContext(false).build())
.build();
}
/** The chat client that answers questions. The advisor runs on every call it makes. */
@Bean
ChatClient ragChatClient(ChatClient.Builder builder, RetrievalAugmentationAdvisor advisor) {
return builder.defaultAdvisors(advisor).build();
}
/** The judge for the faithfulness check. */
@Bean
FactCheckingEvaluator factChecker(ChatClient.Builder builder) {
return FactCheckingEvaluator.builder(builder).build();
}
(From RagConfig.java.) Read it top to bottom. The chunker is Spring AI’s TokenTextSplitter, built with the builder because the constructor the earlier article used no longer exists. The reranker is written for this repository. The advisor combines a retriever (topK and threshold from the configuration), the reranker as a post-processor, and a query augmenter with allowEmptyContext(false), the setting that refuses to send a bare question to the model when nothing was found. ragChatClient is the client that answers questions, with that advisor attached, and the judge is Spring AI’s FactCheckingEvaluator built on the same model. Each of these stages has a section in Production-Grade RAG with Spring AI 2.0.
What each Spring AI class in this file does, and the surprising defaults of the splitter, the retriever and the augmenter: Production-Grade RAG with Spring AI 2.0.
The controller has two methods. The upload takes the tenant and document type as form fields and passes them on as metadata, so every chunk from that file carries them. The query takes the tenant as a plain value and builds the filter with FilterExpressionBuilder:
@PostMapping("/ingest")
public IngestionResult ingest(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "default") String tenantId,
@RequestParam(defaultValue = "general") String docType) throws IOException {
return ingestion.ingestPdf(new ByteArrayResource(file.getBytes()), file.getOriginalFilename(),
Map.of("tenant_id", tenantId, "doc_type", docType));
}
@PostMapping("/query")
public RagResponse query(@RequestBody QueryRequest request) {
// The tenant goes in as a value of an expression, never as text glued into a filter string.
var filter = request.tenantId() == null ? null : new FilterExpressionBuilder()
.eq("tenant_id", request.tenantId()).build();
return queries.ask(request.question(), filter);
}
(From RagController.java.) The earlier version of this project accepted a whole filter string from the client in a metadataFilter field and passed it to the store. A client can write anything in a string, including a filter that reaches another tenant’s documents; the tenant section of Production-Grade RAG with Spring AI 2.0 shows that happening on PostgreSQL and shows that a value handed to the builder does not. Note that this demo trusts the tenant id in the request body. In a real service it comes from the authenticated caller (Spring Security 7.1 JWT Authentication).
The rest of the ingestion path, the hash and the chunk-id bookkeeping that make an upload idempotent, is the subject of the PDF section of the explanation; it is deliberately not repeated here.
What one upload and one re-upload do to the table: transcript 05.
Asking a question
The query service is where the stages come together. It starts a timer, calls the chat client (which runs retrieval, reranking and prompt assembly inside the advisor), reads the answer and the chunks that were used, decides a status, and records what happened whatever the outcome:
(The ask method from RagQueryService.java.) The status is no_context when nothing was retrieved, and otherwise the judge decides between answered and ungrounded. The returned object carries the answer, the sources with their rerank scores, and both the boolean and the status, so a caller can show or hide an unsupported answer.
The end-to-end test starts the application as shipped on a random port, uploads two PDFs over HTTP, reads the table back with SQL, asks two questions and fetches the metrics. It writes what it saw to transcript 10, and the same test asserts the numbers, so the transcript cannot drift from the code. First the uploads. Acme’s handbook becomes four chunks, Globex’s manual one, and uploading the Acme file a second time writes nothing:
--- POST /api/ingest (acme, then globex, then acme again) ---
{"filename":"acme-handbook.pdf","status":"ingested","chunksWritten":4,"chunksReplaced":0}
{"filename":"globex-manual.pdf","status":"ingested","chunksWritten":1,"chunksReplaced":0}
{"filename":"acme-handbook.pdf","status":"skipped","chunksWritten":0,"chunksReplaced":0}
Then the table (transcript 10). Five rows, split by tenant, and the metadata of one row shows what each chunk carries: the tenant, the file, the page, and the position of the chunk in its file. That metadata is what the query filter works on.
--- what is in the table ---
rows: 5
rows per tenant: [acme=4, globex=1]
metadata of one row: {"doc_type": "general", "tenant_id": "globex", "chunk_index": 0, "page_number": 1, "source_file": "globex-manual.pdf", "total_chunks": 1}
Now the same question, “how many days of annual leave”, asked for each tenant. Read the two sources lists rather than the answers. Acme’s answer is built from four Acme chunks; Globex’s from its single Globex chunk. Neither list contains a file from the other tenant, on a real PostgreSQL, which is the point of the filter. The rerankScore is the rating the reranker gave each chunk (transcript 10).
--- POST /api/query ---
tenantId acme: {"answer":"Full-time employees are entitled to 20 working days\nof annual leave per calendar year.","sources":[{"file":"acme-handbook.pdf","page":2,"rerankScore":8,"preview":"4.1 Annual Leave Entitlement. Full-time employees are entitled to 20 working day"},{"file":"acme-handbook.pdf","page":3,"rerankScore":6,"preview":"4.3 Requesting Leave. All leave requests must be submitted through the HR portal"},{"file":"acme-handbook.pdf","page":1,"rerankScore":6,"preview":"Acme Employee Handbook 2026\n\n3.5 Probationary Period. During the three month pro"},{"file":"acme-handbook.pdf","page":4,"rerankScore":4,"preview":"6.1 Expenses. Receipts are required for every expense above 50 euros.\nClaims mus"}],"grounded":true,"status":"answered"}
tenantId globex: {"answer":"Full-time staff are entitled to 25 working days\nof annual leave per calendar year.","sources":[{"file":"globex-manual.pdf","page":1,"rerankScore":6,"preview":"Globex Staff Manual 2026\n\n4.1 Annual Leave Entitlement. Full-time staff are enti"}],"grounded":true,"status":"answered"}
Last, the counters that record all of that:
--- GET /actuator/prometheus (only the rag_ series; the timer's sum and max are left out because they change every run) ---
rag_chunks_ingested_total 5.0
rag_context_chunks_count 2
rag_context_chunks_sum 5.0
rag_context_chunks_max 4.0
rag_ingestion_skipped_total 1.0
rag_queries_total{status="answered"} 2.0
rag_query_duration_seconds_count{status="answered"} 2
(All four blocks are quoted from transcript 10.) Five chunks were ingested, one upload was skipped, two questions were answered, and the two questions retrieved four and one chunks, which is why the rag_context_chunks sum is 5. This is the series to graph and alert on once the service is real, and chapter 6 says which movements matter.
These answers are scripted, and one demo from the earlier page cannot be repeated. The sentences in answer and the numbers in rerankScore were produced by the scripted chat model described in the next section, not by OpenAI, so they say nothing about how good a real model’s answers or ratings would be. The earlier version of this page also showed a question that matched nothing and got a polite refusal. With the shipped threshold of 0.0 that cannot happen: transcript 06 shows a question about the capital of Mongolia still retrieving all four chunks, and the refusal only appears when the threshold is raised to 0.3, where the same question retrieves none. Setting the threshold is the first thing to do with a real embedding model.
A RAG application has exactly two things that need the internet: the embedding model and the chat model. The test suite replaces those two and keeps everything else real. The picture shows the seam. On the left, what runs for real: HTTP, the application’s own configuration, the Spring AI classes and a PostgreSQL with pgvector. On the right, the two scripted beans that take the place of the OpenAI models.
What the picture is saying is that the tests run this project’s own glue code exactly as it ships, and none of what a model does. Turning the models off takes two properties, and the two replacements are ordinary beans, both shown here from EndToEndTest.java:
@TestConfiguration
static class Fakes {
@Bean
FakeChatModel chatModel() {
return new FakeChatModel();
}
@Bean
HashingEmbeddingModel embeddingModel() {
return new HashingEmbeddingModel(Stores.DIMS);
}
}
HashingEmbeddingModel turns each word into a slot of a 1,536-number vector, so two texts that share words end up close together (HashingEmbeddingModel.java). FakeChatModel plays three roles and tells them apart by what the prompt says, so one bean serves the answering client, the reranker and the judge:
private String reply(String text) {
if (text.contains("Rate how well the PASSAGE")) {
return forcedRating != null ? forcedRating : rating(text);
}
if (text.contains("Evaluate whether or not the following claim is supported")) {
if (failFactChecks) {
throw new IllegalStateException("simulated: 429 Too Many Requests from the judge model");
}
return forcedVerdict != null ? forcedVerdict : verdict(text);
}
if (forcedAnswer != null) {
return forcedAnswer;
}
return answer(text);
}
(From FakeChatModel.java, the method that picks the role.) A rating prompt gets a number from the share of question words found in the passage. A fact-check prompt gets yes when every content word of the claim appears in the document. Anything else is a question, and the reply is the sentence of the supplied context that shares the most words with it, or a refusal when there is no context. The fake also records every prompt it receives, which is what several tests assert on: what Spring AI sent is the part of a RAG pipeline that Spring AI is responsible for. That makes for a precise list of what these tests can and cannot tell you:
Question
Answer from this repository
Evidence
What does Spring AI send to the model, and what happens on an empty context?
Why a fake embedding model still gives a useful retrieval test, and where it stops being one: chapter 1.
Moving from the fake models to OpenAI
The application itself has no fake in it. Start it with a key and it uses the OpenAI models named in the configuration:
# 3. Run the application against OpenAI
export OPENAI_API_KEY=sk-...
mvn spring-boot:run
(Step 3 of the module README.) This has not been run. The api-key setting has no default in application.yml, so set the variable before starting. Because nothing here was measured against a real model, treat the first hour as tuning, in this order:
The similarity threshold. Ask five questions your documents can answer and five they cannot, print the scores, and put the threshold between the two groups. Until then the “nothing found” path is switched off.
The reranker’s replies. The reranker asks for a bare integer and treats anything else as zero, counting the failure in rag_rerank_failures_total. A chatty model would make every rating zero without an error, so watch that counter.
The judge. Run answers you know are right and answers you know are wrong through it, and watch rag_faithfulness_judge_failures_total. The exact-match trap in its verdict is described in the judge section of Production-Grade RAG with Spring AI 2.0.
Dimensions and chunk size. The table is sized for text-embedding-3-small. A different embedding model needs a different vector(...) size in init.sql and a matching dimensions setting, and the table rebuilt. The chunk size of 512 tokens is a starting value, not a result.
Build an evaluation set while you do this: a list of real questions with the chunks that should answer them. It is the only thing that can tell you whether a change to the threshold, the chunk size, the reranker or the judge made the service better, and it is the one thing this repository does not have.
The production checklist, including the limits of the in-memory ingestion tracker: chapter 6.
How the query embedding, the reranker and the answer calls add up per question: the query and reranking sections of Production-Grade RAG with Spring AI 2.0.
The earlier page’s project could not be built on current Spring AI, and the reasons are listed in transcript 14: two of them are real changes in 2.0 (the TokenTextSplitter constructor and the deprecated chat.options keys), and two were broken before 2.0 existed (the starter artifact ids and a formatter lambda that never compiled). The whole set of differences, in one table:
Area
Earlier version
This version
Spring Boot / Java
3.4.5 / 21
4.1.1 / 25
Spring AI
1.1.0 in the pom (the title said 1.0)
2.0.1
Starters
spring-ai-openai-spring-boot-starter and the pgvector equivalent, which were not resolvable
spring-ai-starter-model-openai and spring-ai-starter-vector-store-pgvector
Database access
spring-boot-starter-data-jpa with ddl-auto: none, only to obtain a JdbcTemplate
spring-boot-starter-jdbc
Web starter
spring-boot-starter-web
spring-boot-starter-webmvc
Model settings
chat.options.model, embedding.options.model
chat.model, embedding.model
Chunker
new TokenTextSplitter(512, 128, 5, 10_000, true), commented as 128 tokens of overlap
The builder. The second argument was the minimum chunk size in characters, and TokenTextSplitter has no overlap
PDF text tidy-up
A formatter lambda that did not compile
A method in IngestionService
Similarity threshold
0.60, hard-coded
rag.retrieval.similarity-threshold, shipped as 0.0
Tenant filter
A metadataFilter string sent by the client
A tenantId value, turned into a filter with FilterExpressionBuilder
Reranker
CrossEncoderReranker, which asked a chat model for ratings, using parallelStream
LlmReranker, named for what it does, on virtual threads
Answer check
HallucinationGuard
Spring AI’s FactCheckingEvaluator, called from RagQueryService
Not for the tests. scripts/run-all.sh runs the whole suite and rewrites every transcript against a real PostgreSQL with scripted models (run-all.sh). To run the application itself you need a key, because ingestion embeds every chunk, and each question makes one embedding call, one rating call per retrieved chunk (up to 20 with the shipped top-k), one answering call and one judging call.
Can I use a different model provider?
In principle, yes. No class in src/main/java refers to OpenAI; the code is written against Spring AI’s chat client and embedding interfaces, and the provider appears only in the pom and application.yml. It was not tried. A different provider means a different starter, different settings, and almost certainly a different vector size, which means changing init.sql. The reranker and judge prompts were only ever answered by the scripted model, so retest both.
Why does a question about something unrelated still get an answer?
Because the shipped similarity threshold is 0.0, which accepts every retrieved chunk, and the refusal path only fires when nothing is retrieved. Raise the threshold as described above. The callout in the configuration section and the end-to-end section both point at the same setting.
Why is there no JPA, and do I need spring.threads.virtual.enabled?
PgVectorStore needs a JdbcTemplate, which spring-boot-starter-jdbc provides; the earlier project pulled in JPA only to get one. The virtual-threads flag is separate: the reranker creates its own virtual-thread executor (LlmReranker.java), so it does not depend on it. The flag moves Spring Boot’s own request handling onto virtual threads, and nothing here measured whether that helps, so turn it off if you would rather not carry the change (Virtual Threads vs Platform Threads).
Conclusion
This is a RAG service that builds on Spring Boot 4.1 and Spring AI 2.0.1, starts against a real PostgreSQL, and has fifteen transcripts and a passing suite behind it. What it does not have is a real model, and the honest way to finish is to say so plainly: every answer on this page was scripted. Run it against your own key, set the threshold from your own questions, and build an evaluation set before you believe any of the numbers. The explanation of why each stage is there is in Production-Grade RAG with Spring AI 2.0.
Should you start from this project? If you want a small, working example of ingestion, filtered retrieval, reranking and a fact check in Spring Boot 4, yes: every file is short and every claim about behaviour has a transcript. If you want a service to put in front of customers next week, no: the ingestion tracker is in memory, the threshold is a placeholder, the reranker costs a model call per chunk, and nothing here was checked against a real model’s output. Take the shape, and replace those four things with your own measurements.
No Comments yet!