Prompt Augmentation & Generation
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Examples on this page target Python 3.12, the requests library, Ollama 0.34 with llama3.1:8b,
langchain / langchain-core 1.4 / 1.6, and Spring Boot 4.1 with Spring AI 2.0.1. Retrieval only produces a
ranked list of chunks; prompt augmentation is the step that turns that list into text the model can actually
use — formatted, attributed and bounded by explicit instructions — before generation produces the answer.
This is a deliberate act of context engineering, and the
budget it has to work within is bounded by the model’s
context window.
Formatting retrieved context with source attribution
An LLM has no innate notion of where a paragraph of context came from, so if the prompt does not say, the
model cannot say either — and a RAG answer with no citations is much harder to verify against the
documentation it was supposedly built from. The fix is mechanical: wrap every retrieved chunk in a small,
consistent block that carries its provenance, using exactly the metadata DocsAssistant already stores on
each chunk (source_path, title, section):
def format_context(hits: list[dict]) -> str:
blocks = []
for i, hit in enumerate(hits, start=1):
blocks.append(
f"SOURCE {i}: {hit['title']} § {hit['section']}\n"
f"{hit['content']}"
)
return "\n\n".join(blocks)
Each hit is one of the dictionaries search_docs returns, carrying source_path, title, section and
content. See
Python: formatted string literals for the
f-string syntax used to build each block. Run against the DocsAssistant sample corpus from
Document Ingestion & Chunking, the resulting
text looks like:
SOURCE 1: HNSW indexes § Tuning parameters `M` bounds how many edges each node keeps per layer, trading memory for connectivity. ... `ef_search` is the same idea applied at query time: a larger candidate list improves recall at the cost of latency... SOURCE 2: pgvector basics § Operators and indexes `<->` computes Euclidean distance, `<#>` negative inner product, and `<=>` cosine distance; a query orders by whichever operator matches the embedding model's intended metric...
The system prompt then tells the model how to use those labels, both to answer and to cite:
Answer using only the numbered SOURCE blocks below. After every claim, cite the source it came from
in square brackets, e.g. "Increasing ef_search trades latency for recall [SOURCE 1]." If two sources
disagree, say so explicitly and cite both. Do not cite a source for a sentence it does not support.
Numbering the blocks rather than repeating each source_path inline keeps the citation markers short, while
the mapping from SOURCE n back to title / section / source_path lets the application render a real
link under each [SOURCE n] marker in the rendered answer, without asking the model to reproduce a path or
URL verbatim (a place small models routinely get wrong).
Ordering contexts by relevance
Where the most relevant chunk sits in the prompt is not a neutral choice. Transformer attention over a long context is not uniform: models tend to weigh the beginning and the end of the context window more heavily than the middle, a primacy and recency effect together with the "lost in the middle" degradation that RAG Fundamentals discusses for the context window as a whole. Two orderings follow from this directly:
-
Most relevant first puts the top hit where primacy favours it, which matches how a reader skims a results page and is the simpler default — most RAG pipelines, and Spring AI’s default prompt template described below, place retrieved documents in retrieval order, most relevant first.
-
Most relevant last, closest to the question puts the top hit right before the user’s own question, which recency also favours, and can help when the source list is long enough that anything placed first risks falling into the middle of the window by the time the question is reached.
Neither ordering is universally better across models and context lengths, and the difference only matters
once the context is long enough for the middle to genuinely degrade — for the DocsAssistant scenario’s
five-chunk, few-hundred-token contexts against llama3.1:8b’s 8192-token `num_ctx, either ordering keeps
every chunk close enough to an edge that the effect is unlikely to be visible; it becomes worth testing once
context lengths approach the low thousands of tokens.
Handling "no relevant context" explicitly
Retrieval sometimes returns nothing above a usable similarity threshold, or returns chunks that do not actually answer the question — and the prompt has to say what to do then, or the model will happily hallucinate an answer instead. The system prompt states this as an explicit branch, and the application checks for it before formatting any context at all:
NO_CONTEXT_INSTRUCTION = (
"If the SOURCE blocks below do not contain the answer to the question, "
"reply exactly: \"I don't have enough information in the documentation to answer that.\" "
"Do not guess, and do not answer from general knowledge."
)
def build_prompt(question: str, hits: list[dict]) -> str:
if not hits:
return f"{NO_CONTEXT_INSTRUCTION}\n\nQuestion: {question}"
return (
f"{NO_CONTEXT_INSTRUCTION}\n\n"
f"{format_context(hits)}\n\n"
f"Question: {question}"
)
The string build_prompt returns is what the next section sends as the user message’s content; see
Ollama API: Chat for that message format.
Stating the exact fallback sentence, rather than "say you don’t know," keeps the refusal recognisable and easy for the application to detect (for example to trigger a "search the web" or "open a support ticket" fallback) instead of parsing free-form hedging out of the model’s answer.
Popularity and score-bias considerations
Similarity search in a high-dimensional embedding space is subject to hubness: a small number of vectors end up unusually close to a disproportionate share of all queries, simply as a property of the geometry, not because they are more relevant to any one of those queries. A generic, broadly-worded chunk — an introduction paragraph, a glossary entry — can behave like a hub, showing up as a top-k hit for many unrelated questions and crowding out the chunk that actually answers each one. This is a different failure from ranking bias introduced deliberately (boosting a document because it was clicked often, or because it is newer), but the symptom looks the same in the final prompt: the same one or two sources keep appearing regardless of the question. Three mitigations, in increasing cost order:
-
raise the similarity-score floor a hit must clear before it is included at all, so a hub vector that is merely somewhat close to everything stops qualifying for queries it is not a strong match for;
-
re-rank the fused candidate list with a cross-encoder (see Hybrid Search & Re-ranking), which scores the query and each candidate jointly instead of by embedding proximity alone, and is far less prone to hubness than bi-encoder similarity;
-
if the application does track usage signals (click-through, thumbs up/down), treat them as a secondary re-ranking signal applied after a relevance floor, never as the primary retrieval score — a purely popularity-driven ranking optimises for what was clicked before, not for what answers the current question.
Domain-specific prompts
The same context-formatting and citation rules support very different tones. Two templates for the
DocsAssistant scenario, both consuming format_context() from above, show the range.
Technical documentation assistant
You are a documentation assistant for an internal engineering knowledge base. Answer precisely and
concisely, using terminology exactly as it appears in the sources. Prefer short, direct sentences over
narrative explanation. Cite every factual claim as [SOURCE n]. If the sources give conflicting parameter
values or steps, present both and say which source each came from -- do not silently pick one.
{context}
Question: {question}
Answer:
Conversational assistant
You are a friendly assistant helping a colleague understand the documentation. Explain concepts in plain
language and feel free to paraphrase, but every paraphrase must still be traceable to a source -- add
[SOURCE n] after each claim exactly as in the technical template. If something in the sources is unclear or
incomplete, say so rather than filling the gap yourself.
{context}
Question: {question}
Answer:
The conversational template relaxes register, not evidence: both still require a citation per claim and
both still fall back to the "not enough information" sentence above. {context} and {question} above
are template placeholders the application substitutes before the request is sent; see
PostgreSQL & pgvector for where hits comes from and
Retrieval Strategies for how the top-k list is chosen and
ordered before it reaches either template.
Confidence and faithfulness instructions
Citations alone do not stop a model from stating an inference as if it were a fact the source stated directly. Two further instructions, added to either template above, target that gap specifically:
Distinguish what the sources state directly from anything you infer. If you are inferring rather than
quoting a stated fact, say "based on the sources, it appears that..." instead of stating it plainly.
Never state a specific number, parameter name, or code identifier that does not appear verbatim in a
SOURCE block -- if you are unsure of the exact value, say the sources do not give one.
This is faithfulness: every claim traces to the retrieved context, not to the model’s parametric knowledge,
which is the property RAG is meant to buy over an ungrounded chat model in the first place. It does not make
the model’s citations correct by construction — a small local model can still cite the wrong SOURCE n for
a true statement — which is why
RAG Fundamentals and a later evaluation pass (golden questions
against reference answers) remain necessary even once every answer carries citations.
Local LLMs with Ollama
DocsAssistant generates answers with Ollama’s llama3.1:8b at temperature 0.1 (favouring the most
likely token over creative variation, which is what a grounded, citation-carrying answer wants) and num_ctx
8192 (the context window in tokens; a prompt plus context that exceeds it is silently truncated from the
front, which is why the "lost in the middle" ordering choice above interacts with num_ctx at all). Both are
set per request under options on Ollama’s /api/chat endpoint, not as server-wide defaults (the server-wide
and Modelfile alternatives, and how to check the context Ollama actually allocated, are on
Ollama — context length):
import json
import requests
def ask_docsassistant(question: str, hits: list[dict], stream: bool = False):
prompt = build_prompt(question, hits)
response = requests.post(
"http://localhost:11434/api/chat",
json={
"model": "llama3.1:8b",
"messages": [
{"role": "system", "content": "You are a documentation assistant. " + NO_CONTEXT_INSTRUCTION},
{"role": "user", "content": prompt},
],
"options": {"temperature": 0.1, "num_ctx": 8192},
"stream": stream,
},
stream=stream,
)
response.raise_for_status()
if not stream:
return response.json()["message"]["content"]
answer = ""
for line in response.iter_lines():
if not line:
continue
chunk = json.loads(line)
delta = chunk["message"]["content"]
print(delta, end="", flush=True)
answer += delta
if chunk.get("done"):
break
return answer
/api/chat takes a messages list (roles system / user / assistant) rather than a single prompt
string, which is what makes it the endpoint to use once DocsAssistant also needs conversation history (see
Agent Memory & Semantic Cache) instead of a
one-shot question. With stream: true, the response body is newline-delimited JSON objects, one per token (or
small token batch); each carries a message.content delta and a final object with "done": true and
timing/token-count fields. Streaming token-by-token is what lets a chat UI show the answer as it is generated
instead of waiting for the full response, which matters more the larger the answer and the smaller (slower)
the local model. See Ollama API: Chat for the full request/response
schema, and
Ollama: valid parameters and values for the
other options fields (top_p, top_k, repeat_penalty, seed) not needed here.
In LangChain and Spring AI
LangChain
ChatPromptTemplate composes a RAG prompt from typed message parts instead of one hand-formatted string,
keeping the system instructions, the formatted context and the user question as separate, independently
reusable pieces:
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_ollama import ChatOllama
def format_documents(docs: list[Document]) -> str:
# adapt LangChain Documents to the dicts format_context expects
return format_context([{**d.metadata, "content": d.page_content} for d in docs])
SYSTEM_INSTRUCTIONS = (
"Answer using only the SOURCE blocks below. Cite every claim as [SOURCE n]. "
+ NO_CONTEXT_INSTRUCTION
)
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_INSTRUCTIONS),
("human", "{context}\n\nQuestion: {question}"),
])
llm = ChatOllama(model="llama3.1:8b", temperature=0.1, num_ctx=8192)
rag_chain = (
{"context": retriever | format_documents, "question": RunnablePassthrough()}
| prompt
| llm
)
answer = rag_chain.invoke("what does ef_search control in an HNSW index")
retriever is the PGVector-backed retriever from
RAG Fundamentals. The
{"context": retriever | format_documents, "question": RunnablePassthrough()} mapping runs the retriever,
adapts the list[Document] it returns into the dictionaries the format_context helper above expects (each
Document’s `metadata — title, section, source_path — plus its page_content as content), and
passes the raw question through unchanged, before both are substituted into the template’s {context} /
{question} placeholders — the
same composition RAG Fundamentals's hand-rolled/LangChain/
Spring AI pipeline comparison builds on. See
LangChain:
ChatPromptTemplate reference, part of
LangChain’s RAG chain
composition.
Spring AI
Spring AI’s QuestionAnswerAdvisor and the newer ContextualQueryAugmenter (used inside a
RetrievalAugmentationAdvisor pipeline) both wrap the same idea as ChatPromptTemplate above: a configurable
prompt template with one placeholder for the user’s question and one for the retrieved context, which the
advisor fills in before the request reaches the model. The two use different placeholder names, though:
QuestionAnswerAdvisor expects {query} and {question_answer_context}, while ContextualQueryAugmenter
expects {query} and {context} and rejects a template missing either one when it is built.
QuestionAnswerAdvisor is the simpler, single-step form:
var questionAnswerTemplate = PromptTemplate.builder()
.template("""
Answer using only the context below. Cite the section for every claim.
If the context does not contain the answer, say so instead of guessing.
Context:
{question_answer_context}
Question:
{query}
""")
.build();
Advisor questionAnswerAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().similarityThreshold(0.50).topK(5).build())
.promptTemplate(questionAnswerTemplate)
.build();
See
Spring
AI: QuestionAnswerAdvisor for its builder options and default template.
ContextualQueryAugmenter plugs the same kind of template into the modular RetrievalAugmentationAdvisor
pipeline from Retrieval Strategies, as the stage that
turns retrieved Document`s into the final augmented prompt, and its `allowEmptyContext(…) flag is exactly
the "no relevant context" branch from earlier in this page, expressed as a builder option instead of prose in
the template. It needs its own template, written against {context} and {query}:
var augmenterTemplate = PromptTemplate.builder()
.template("""
Answer using only the context below. Cite the section for every claim.
If the context does not contain the answer, say so instead of guessing.
Context:
{context}
Question:
{query}
""")
.build();
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.similarityThreshold(0.50)
.vectorStore(vectorStore)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.promptTemplate(augmenterTemplate)
.build())
.build();
With allowEmptyContext(false) (the default), when no document was retrieved the augmenter discards the
user’s query and sends the model its empty-context prompt instead — by default an instruction to tell the
user politely that the question is outside its knowledge base, replaceable through
emptyContextPromptTemplate(…) (for example with the exact fallback sentence from NO_CONTEXT_INSTRUCTION
above). That is a stricter, advisor-level version of the NO_CONTEXT_INSTRUCTION branch: the model never sees
the original question, so it cannot answer it from general knowledge. Setting it to true instead passes the
original, un-augmented query through unchanged, useful when a question might be answerable from the model’s
own instructions alone (a greeting, a meta-question about the assistant) rather than from retrieved
documentation. See
Spring
AI: ContextualQueryAugmenter, part of the Spring AI RAG reference that documents both advisors and of
Spring AI’s RAG advisors.