1. Overview
While we can wire an embedding model and a vector store, populate the store with real documents through an ingestion pipeline, and search the store to retrieve the closest-matching chunks, this can’t answer questions. Raw chunks aren’t an answer; they’re a starting point.
In this lesson, we’ll close that loop. We’ll add a component to the chat pipeline that intercepts the user’s question, retrieves the most relevant document chunks from the vector store, and asks the model to generate an answer grounded in those chunks. This results in a working Q&A endpoint backed by ingested documents.
The relevant module we need to import when starting this lesson is: qa-advisor-start.
If we want to reference the fully implemented lesson, we can import: qa-advisor-end.
2. The Advisor Pattern
Every call we make through ChatClient shares the same cross-cutting concerns. We may want to ground the prompt on retrieved documents, attach prior conversation turns, or log requests and responses for observability. Sprinkling that logic into controllers means re-implementing the same plumbing for every endpoint that talks to the model.
Spring AI’s Advisor pattern is the framework’s answer: a chain of interceptors that wraps the model call, allowing each interceptor to inspect or modify the request on the way in and the response on the way out. The Spring AI advisor pattern provides the CallAdvisor interface, which wraps the synchronous .call() path, and the StreamAdvisor interface, which wraps the .stream() path.
Additionally, advisors can be attached to a ChatClient through ChatClient.Builder.defaultAdvisors(), ensuring that every call made through that client picks them up automatically.
3. Introducing QuestionAnswerAdvisor
Spring AI’s QuestionAnswerAdvisor is the out-of-the-box advisor for RAG. It intercepts the user prompt, runs a similarity search against a configured vector store, and rewrites the prompt so the model answers grounded on the retrieved chunks. It bundles the retrieve-augment-generate pipeline into a single component, eliminating much of the wiring that would otherwise have to be done manually.
The canonical way to construct it is through its builder, which takes the vector store as its only required argument:
QuestionAnswerAdvisor.builder(vectorStore)
.build();
Built this way, the advisor uses three defaults out of the box: a SearchRequest with topK set to 4 and similarityThreshold set to 0.0 (accept every match regardless of score), a default prompt template, and a default position of 0 in the advisor chain.
The default prompt template is where the augmentation step actually happens. It defines two placeholders: {query} for the user’s question and {question_answer_context} for the retrieved chunks. At runtime, the advisor fills both placeholders and hands the resulting prompt to the model. Simply put, augmentation is the process of populating a prompt template with retrieved search results.
5. Building the Q&A Endpoint
Now that we understand the advisor, let’s wire it into our application end to end by defining a @Bean method that produces the configured QuestionAnswerAdvisor, and exposing a new /qa endpoint on the ChatController that attaches the advisor to the ChatClient and returns the model’s grounded answer.
5.1. Wiring the Advisor Bean
The advisor’s only dependency is the VectorStore bean that already lives in VectorStoreConfig, so that’s the natural home for the new @Bean. Let’s open the VectorStoreConfig file and add QuestionAnswerAdvisor bean:
@Configuration
public class VectorStoreConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel)
.build();
}
@Bean
public QuestionAnswerAdvisor questionAnswerAdvisor(VectorStore vectorStore) {
return QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(4)
.similarityThreshold(0.6)
.build())
.build();
}
}
Here, the new bean accepts the VectorStore by constructor injection, configures the SearchRequest with tuned values, and returns a fully wired QuestionAnswerAdvisor. The bean is a singleton: one advisor instance lives for the lifetime of the application, the same way the store does.
In the code above, the topK limits how many matching chunks the advisor pulls from the store. A small topK keeps the prompt tight (fewer distractors, more focus on the closest match), but if the right chunk isn’t in the top few, the model never sees it and answers out of thin air. A larger topK broadens recall at the cost of context.
Notably, the augmented prompt grows with topK, and a very large value can push the prompt past the model’s context window, at which point the call fails or silently drops content.
similarityThreshold is a score floor between 0.0 and 1.0. Any candidate whose similarity to the query falls below the floor is discarded before reaching the prompt, even if it would otherwise have made the topK cut. A higher threshold means the advisor refuses to ground on weak matches, which often produces a more honest “I don’t know” than a confidently wrong answer. A lower threshold is more permissive and lets thinner matches through.
The two options compose: the threshold filters candidates first, then topK caps what survives. A high threshold paired with a high topK can still hand the model zero chunks if nothing in the store clears the floor, in which case the model falls back to its own knowledge.
The advisor builder also exposes .promptTemplate(…) for cases where the default augmentation template isn’t what we want, but customizing the prompt template is a prompt-engineering concern best handled in a later module.
5.2. Exposing the /qa Endpoint
Next, let’s update the constructor to accept the advisor and attach it via defaultAdvisors() on the ChatClient builder:
@RestController
public class ChatController {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public ChatController(ChatClient.Builder builder, VectorStore vectorStore,
QuestionAnswerAdvisor questionAnswerAdvisor) {
this.chatClient = builder
.defaultAdvisors(questionAnswerAdvisor)
.build();
this.vectorStore = vectorStore;
}
// existing /chat and /search methods
}
The defaultAdvisors() call wires the advisor into every call made through this client. With that single wiring line in place, the calling code doesn’t change at all. A normal call through the chat client now retrieves, augments, and generates without the controller doing any of that work explicitly.
Notice the runtime sequence: the user’s question reaches the advisor first; the advisor calls similaritySearch against the store, fills {query} and {question_answer_context} in the template, and only then hands the augmented prompt to the model. The model’s reply flows back out through the same chain.
Moving on, let’s define a new endpoint named qa:
@GetMapping("/qa")
public String qa(@RequestParam String q) {
return chatClient.prompt()
.user(q)
.call()
.content();
}
Since the defaultAdvisors() call wires the advisor into every call, the /qa handler is then a plain call; the retrieve-augment-generate loop runs automatically through the advisor.
Before we try it out, it’s worth remembering where this data comes from: the chunks the advisor retrieves are the ones we loaded into the vector store in the previous lesson, when we built the ingestion pipeline that split and embedded policies.pdf.
Let’s test the endpoint by visiting the following URL in our browser or using a client such as Postman:
http://localhost:8080/qa?q=who%20approves%20campaign%20budgets
Here’s an illustrative example of the response: the model is non-deterministic, so the exact wording can vary between runs:
Based on the provided context:
* Campaign budgets above ten thousand euros require approval from the **Finance team**.
* Campaign budgets above twenty thousand euros require approval from the **Finance team** and a **Director**.
The response is the grounded answer, drawn from the chunks the advisor retrieved from the store.
6. Comparing Plain Chat vs RAG-Grounded Answers
The simplest way to observe the payoff of the wiring we just did is to send the same question to both endpoints. Before we do that, we need to make one adjustment to the controller.
Right now, the advisor is wired as a default on the ChatClient, which means every call, including /chat, goes through the retrieve-augment-generate loop. For a meaningful comparison, we need /chat to answer from the model alone and /qa to be the grounded endpoint. To achieve that, let’s remove the defaultAdvisors(…) call from the builder and instead apply the advisor per-request, only on the /qa handler.
First, let’s update the constructor so the client is built without a default advisor, and keep the advisor as a field:
@RestController
public class ChatController {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final QuestionAnswerAdvisor questionAnswerAdvisor;
public ChatController(ChatClient.Builder builder, VectorStore vectorStore,
QuestionAnswerAdvisor questionAnswerAdvisor) {
this.chatClient = builder.build();
this.vectorStore = vectorStore;
this.questionAnswerAdvisor = questionAnswerAdvisor;
}
// existing /chat and /search methods
}
Next, let’s update the /qa method to apply the advisor on a per-request basis, using the fluent chain’s advisors() method:
@GetMapping("/qa")
public String qa(@RequestParam String q) {
return chatClient.prompt()
.advisors(questionAnswerAdvisor)
.user(q)
.call()
.content();
}
With this in place, /chat goes directly to the model and /qa runs the retrieve-augment-generate loop.
Let’s try a question that’s specific to the internal policies in our documents, something the model couldn’t possibly know from training data, such as the budget-approval rules defined for our project. First, against the plain endpoint:
http://localhost:8080/chat?message=who%20approves%20campaign%20budgets
Budget approval usually depends on an organization's internal structure and policies. In many companies, campaign or project budgets are signed off by a finance department, a budget owner, or senior management, but the exact approvers vary from one organization to another.
The model has no way to know how our project handles budget approvals, so it falls back to generic possibilities. Now let’s hit the grounded endpoint with the same question:
http://localhost:8080/qa?q=who%20approves%20campaign%20budgets
Campaign budgets are approved by:
* **The Finance team** for budgets above ten thousand euros.
* **The Finance team and a Director** for budgets above twenty thousand euros.
The advisor pulled the relevant chunks from the policies document we ingested earlier, the augmentation step folded them into the prompt, and the model answered from that material. Same model, same fluent chain, same user question; the only difference is the advisor, and the answer goes from a generic disclaimer to a specific, source-grounded statement. That’s the payoff RAG is built around, and the advisor is what makes it a one-line wiring change.
7. Filtering Retrieval by Metadata
As we know, every Document in the store carries metadata. For instance, PagePdfDocumentReader populated a file_name entry holding the name of the file the chunk came from, alongside a page_number entry for the page it originated on. So far, the advisor has been searching across every chunk regardless of source. When the store mixes content from multiple files, such as a policies document alongside other internal files, we often want to constrain the retrieval to a specific file.
The SearchRequest exposes .filterExpression() for exactly this purpose. It accepts an SQL-like predicate over the document metadata. Let’s narrow the advisor’s retrieval to chunks from the policies file only:
@Bean
public QuestionAnswerAdvisor questionAnswerAdvisor(VectorStore vectorStore) {
return QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(4)
.similarityThreshold(0.6)
.filterExpression("file_name == 'policies.pdf'")
.build())
.build();
}
The filter is applied at retrieval time, before the topK cut, so chunks from any other file never reach the prompt.
8. Conclusion
One advisor applied to the chat call, pointed at a populated vector store, closes the RAG loop end to end. The controller stops looking like retrieval code and goes back to looking like a controller, with the grounding work pushed down into a single, configurable component along the call path.