Grounding Answers in Your Own Data: Embeddings, Vector Stores & RAG
Stop the bot from guessing at policy questions. Embed your own documents, store them in a vector database, and wire retrieval into ChatClient with QuestionAnswerAdvisor.
The Confidently Wrong Refund Policy
Three weeks after the memory-enabled support bot from the last guide went live, an escalation lands on the fintech team's desk: the bot told a customer their premium-tier refund window was "30 days from purchase." Acme Fintech's actual policy, updated two months ago, gives premium-tier customers a 90-day window. The bot wasn't lying — it simply answered from whatever it learned during training, which has no idea Acme Fintech exists, let alone what its refund policy says today.
This is the gap every guide so far has quietly left open. ChatClient, PromptTemplate, ChatMemory — none of them give the model access to facts outside its training data. That's what Retrieval-Augmented Generation (RAG) is for: look up the relevant part of your documents first, then hand it to the model as context, so it answers from what's actually true today instead of what it remembers from training.
1. Embeddings: Turning Text Into Something Searchable
You can't run a SQL WHERE clause against "does this paragraph talk about refunds?" — but you can compare meaning. An embedding model converts text into a dense vector of floating-point numbers (1536 of them, for OpenAI's text-embedding-3-small) positioned in space such that semantically similar text ends up as nearby vectors, regardless of the exact words used. "Refund window" and "how long do I have to get my money back" land close together; "refund window" and "password reset" don't.
@Autowired
EmbeddingModel embeddingModel;
float[] vector = embeddingModel.embed("What is the refund window for premium customers?");
// vector.length == 1536 for OpenAI's text-embedding-3-smallYou'll rarely call .embed() directly like this — VectorStore (next section) does it for you on the way in and on the way out. The one-liner above just makes the abstraction concrete: an embedding is a coordinate, and "search" becomes "find the nearest coordinates," typically measured with cosine similarity.
Ollama can serve embedding models too, not just chat models — ollama pull nomic-embed-text gets you a capable local embedding model, so this entire phase, like the last two, can be followed without an API key.
2. Ingesting Documents: Read, Then Chunk
RAG starts with getting your documents into the pipeline. Spring AI's DocumentReader implementations handle the parsing; TextSplitter implementations handle breaking the result into chunks small enough to embed and retrieve individually — you don't want "the entire 40-page policy PDF" as a single search result.
// build.gradle
implementation 'org.springframework.ai:spring-ai-pdf-document-reader'PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
"classpath:/policies/refund-policy.pdf",
PdfDocumentReaderConfig.builder()
.withPagesPerDocument(1)
.build()
);
List<Document> pages = pdfReader.read();
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800)
.withMinChunkSizeChars(350)
.build();
List<Document> chunks = splitter.apply(pages);Each Document carries its text plus a Map<String, Object> of metadata. Stamp useful metadata on the way in — it's what lets you filter or cite results later:
chunks.forEach(chunk -> chunk.getMetadata().putAll(Map.of(
"source", "refund-policy.pdf",
"tier", "premium",
"updatedAt", "2026-06-01"
)));For anything that isn't a PDF — Word docs, HTML, PowerPoint — reach for TikaDocumentReader (spring-ai-tika-document-reader) instead of hunting for a format-specific reader. It wraps Apache Tika and handles most office and web formats through the same DocumentReader interface.
3. Vector Stores: Where the Chunks Live
A VectorStore embeds each Document and stores the vector alongside the original text and metadata, then answers "give me the K chunks closest to this query" on demand. Spring AI's VectorStore interface is the same shape regardless of backend — Chroma for a quick local Docker container, Pinecone if you want fully managed, or PGVector if you already run Postgres and would rather not add a new piece of infrastructure just for this.
// build.gradle
implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'# application.yml
spring:
ai:
vectorstore:
pgvector:
initialize-schema: true
datasource:
url: jdbc:postgresql://localhost:5432/acme
username: acme
password: ${DB_PASSWORD}initialize-schema: true is not the default. Skip it and PGVector has no table to write to — your first vectorStore.add(...) call fails with an obscure SQL error that has nothing to do with your actual code. This is the single most common first-run PGVector mistake.
Loading and querying is two method calls against the same auto-configured bean:
@Autowired
VectorStore vectorStore;
void loadRefundPolicy(List<Document> chunks) {
vectorStore.add(chunks);
}
List<Document> findRelevant(String question) {
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.7)
.build();
return vectorStore.similaritySearch(request);
}similarityThreshold(0.7) matters as much as topK(5) — without it, a topK search always returns something, even if the closest chunk is a poor match for a question about a topic your documents don't cover at all. A threshold gives the retriever permission to come back with nothing rather than force-feeding the model an irrelevant chunk.
4. RAG: Wiring Retrieval Into ChatClient
Recall from the last guide: advisors are how ChatClient grows new capabilities without changing your method signatures. RAG is just another advisor — QuestionAnswerAdvisor retrieves relevant chunks from a VectorStore and inserts them into the prompt as context, automatically, on every call it's attached to.
@Service
public class PolicyQaService {
private final ChatClient chatClient;
public PolicyQaService(ChatClient.Builder builder, VectorStore vectorStore) {
this.chatClient = builder
.defaultSystem("""
You answer questions about Acme Fintech's policies.
Only answer using the provided context. If the context doesn't
contain the answer, say you don't have that information —
never guess.
""")
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}That system prompt instruction — "if the context doesn't contain the answer, say you don't have that information" — is doing real work. QuestionAnswerAdvisor gives the model the opportunity to answer correctly; it doesn't stop the model from ignoring the context and answering from training data anyway. Telling it explicitly not to guess is what actually closes the escalation from the top of this guide.
QuestionAnswerAdvisor is the fast path to a working RAG pipeline, and it's genuinely enough for a single, well-scoped document set like this one. It does not do query rewriting, re-ranking, or multi-query retrieval on its own — Spring AI's RetrievalAugmentationAdvisor composes those in as separate, swappable pieces (a QueryTransformer, a DocumentPostProcessor for re-ranking, and so on) once a single similarity search stops being accurate enough. Same advisor-attachment pattern, more pieces — worth knowing it exists, not worth reaching for on day one.
Combining memory and RAG on the same ChatClient
Advisors compose. Attach both, and the bot remembers the conversation and answers from your documents in the same call:
this.chatClient = builder
.defaultSystem("Answer Acme Fintech policy questions using only the provided context.")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build()
)
.build();A customer can now ask "what's the refund window for my tier?", get a grounded answer from the actual policy document, then follow up with "and what about for a joint account?" — memory carries the first question's context, RAG re-retrieves fresh chunks for the follow-up.
5. Putting It Together
@Service
public class PolicyQaService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public PolicyQaService(ChatClient.Builder builder, VectorStore vectorStore, ChatMemory chatMemory) {
this.vectorStore = vectorStore;
this.chatClient = builder
.defaultSystem("""
You answer questions about Acme Fintech's policies for customer tier {tier}.
Only answer using the provided context. If the context doesn't
contain the answer, say you don't have that information — never guess.
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build()
)
.build();
}
public void ingest(List<Document> chunks) {
vectorStore.add(chunks);
}
public String ask(String conversationId, String question) {
return chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
}One ingestion path for getting policy documents in, one ask() method that's simultaneously stateful (memory) and grounded (RAG) — and neither advisor knows the other one exists.
What's Next
The bot now answers from your own documents instead of guessing. The next guide gives it the ability to act, not just answer — calling real Java methods and REST APIs mid-conversation with tool calling, interoperating with other agents over MCP, and evaluating whether the answers it gives (retrieved or otherwise) actually hold up.
Frequently asked questions
Do I need a vector database in production, or can PGVector on my existing Postgres handle real traffic?
PGVector is a genuine production option, not just a dev convenience — plenty of teams run it at real scale, especially if their document set is in the low millions of chunks and they'd rather not operate a separate vector database. Reach for a dedicated store like Pinecone or Weaviate when you need features PGVector doesn't have (fully managed scaling, specialized ANN indexes) rather than by default.
What happens if similaritySearch finds nothing above the threshold?
It returns an empty list, and QuestionAnswerAdvisor inserts an empty context block into the prompt. This is exactly why the system prompt explicitly says not to guess — without that instruction, the model will often answer from training data anyway when given no useful context, which is the original hallucination problem this guide set out to fix.
Should I re-embed and re-add a document every time it changes?
Yes, and you'll want a way to remove the old chunks first — most VectorStore implementations support delete-by-metadata (e.g. delete everything with source=refund-policy.pdf) so a document update doesn't leave stale chunks sitting alongside the new ones, silently competing in search results.
Can I filter retrieval to only search documents a specific customer tier is allowed to see?
Yes — the metadata you stamped on each chunk during ingestion (like tier: "premium" in this guide) is exactly what SearchRequest's metadata filtering is for. Filter before the similarity search runs, not after, so a lower-tier customer's query never even considers premium-only content as a candidate.