06 · Prompt Assembly & Generation¶
You can retrieve perfect chunks and still get a bad answer if the prompt is
sloppy. Prompt assembly is where retrieval meets generation: you format the
retrieved chunks into a context block, wrap them with instructions that keep
the model grounded, and make the API call. This lesson builds a solid
build_prompt() and generate() you'll reuse in lessons 7–10, including
source citations and an honest "I don't know" path.
API key needed for this lesson
The generation call uses the Anthropic API: pip install anthropic and
set the ANTHROPIC_API_KEY environment variable (create a key at
console.anthropic.com). This is the only paid/networked piece of the
pipeline — and the pattern is identical for any chat-completions API
(OpenAI, local models via Ollama, etc.): same prompt in, same answer out.
Assembling the context block¶
Give every chunk a numbered tag carrying its source metadata — the tags are what make citations possible:
def build_context(hits: list[dict]) -> str:
"""Format retrieved chunks into a numbered, source-tagged context block."""
blocks = []
for i, hit in enumerate(hits, start=1):
src = hit["meta"].get("source", "unknown")
blocks.append(f"[{i}] (source: {src})\n{hit['text']}")
return "\n\n".join(blocks)
Example output:
[1] (source: billing.md)
Annual plans can be refunded within 14 days of purchase or renewal.
[2] (source: billing.md)
Monthly plans are non-refundable but can be cancelled at any time.
The grounded prompt¶
Four ingredients, each doing a real job:
def build_prompt(question: str, hits: list[dict]) -> str:
context = build_context(hits)
return f"""You are a helpful assistant answering questions using ONLY the \
provided context.
Rules:
- Base your answer solely on the context below. Do not use outside knowledge.
- Cite the context passages you used, like [1] or [1][3].
- If the context does not contain the answer, say exactly: \
"I don't know based on the available documents." Do not guess.
- Keep the answer concise.
Context:
{context}
Question: {question}
Answer:"""
Why each rule earns its place:
- "ONLY the provided context" — this is the grounding instruction, the line between RAG and a model freestyling with decoration. Without it the model happily blends retrieved facts with trained-in guesses, and you can't tell which is which.
- Citations — force the model to tie claims to passages. Users can verify, you can debug, and the model itself gets more careful when it must point at evidence.
- The exact "I don't know" sentence — models are biased toward being helpful, and an unanchored "say if you're unsure" gets ignored. A verbatim escape phrase is easy for the model to emit and trivial for your code to detect.
- Concise — retrieved-context answers tend to bloat with restated context. Say what you want.
The generation call¶
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
def generate(prompt: str) -> str:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return "".join(block.text for block in response.content if block.type == "text")
Wire it to lesson 5's retriever and run the whole query phase:
question = "Can I get a refund on my annual plan?"
hits = retrieve(question, k=3) # lesson 5
answer = generate(build_prompt(question, hits))
print(answer)
Yes — annual plans can be refunded within 14 days of purchase or renewal [1].
Note that monthly plans are non-refundable [2].
That's a grounded, cited answer. Total new code in this lesson: ~40 lines.
Grounding vs. hallucination: prove it works¶
The test that matters is a question the context can't answer:
question = "Do you offer student discounts?"
hits = retrieve(question, k=3) # returns refund/billing chunks — nearest, not relevant
print(generate(build_prompt(question, hits)))
# I don't know based on the available documents.
Without the grounding rules, the same model given the same chunks will often produce something like "Many plans offer student discounts, contact support..." — fluent, plausible, invented. Run both versions once and the value of the four rules stops being theoretical.
Two caveats to stay honest about:
- Grounding is strong, not absolute. Models occasionally leak outside knowledge or over-summarize the context. Instructions cut hallucination dramatically; evaluation (lesson 8) is how you measure what remains.
- Garbage context in, garbage answer out. If retrieval returns junk, the
model must choose between junk and refusal. Combine the prompt rules with
lesson 5's distance threshold: when
retrieve_confident()returns nothing, skip the LLM call entirely and return the "I don't know" string yourself — cheaper and safer.
def answer_question(question: str) -> str:
hits = retrieve_confident(question, k=3, max_distance=0.8)
if not hits:
return "I don't know based on the available documents."
return generate(build_prompt(question, hits))
Cheat sheet¶
| Piece | Purpose |
|---|---|
Numbered context blocks [1] (source: x) |
Enable citations + debugging |
| "Answer ONLY from context" | The grounding instruction |
| "Cite passages like [1]" | Verifiable, evidence-tied claims |
| Exact "I don't know..." phrase | Reliable, detectable refusal path |
| "Keep it concise" | Prevents context-restating bloat |
client.messages.create(model=, max_tokens=, messages=) |
The generation call |
| Threshold before generate | Skip the LLM when retrieval found nothing |
| Provider-agnostic | Same prompt works on any chat-completions API |
How It Actually Works¶
Why position and instructions in the prompt causally change generation. An autoregressive LLM predicts each output token from a single probability distribution conditioned on the entire prompt so far, computed via self- attention across every prior token — but attention is not uniform: models are measurably better at using information near the start and end of the context than information buried in the middle (the "lost in the middle" effect, covered fully in lesson 9), and instructions phrased as explicit constraints ("only answer using the text below; say 'I don't know' if it isn't there") work because next-token prediction is highly sensitive to recently-seen patterns — an explicit refusal instruction raises the probability mass on refusal-shaped continuations whenever the retrieved context doesn't contain matching tokens to attend to. This is also why prompt order matters mechanically, not just stylistically: putting the instruction after the context (so it's the most recent thing the model "read") typically anchors behavior more reliably than an instruction buried before a long context block.
Grounding is a bet on attention over parametric memory, and it's not a sure bet. Concatenating retrieved chunks into context doesn't disable the model's trained-in knowledge — both sources of information compete during generation. When the retrieved text directly contains the answer, copying attended tokens is the path of least resistance and usually wins. When the retrieved text is present but doesn't actually answer the question (a near- miss chunk), the model can still blend it with parametric knowledge and produce a plausible-sounding but ungrounded answer — which is exactly why this lesson's "prove it works" exercise needs an explicit refusal instruction and still needs to be tested against edge cases: grounding is a strong bias induced by what's in the context window, not a hard guarantee enforced by the architecture.
Token budget is arithmetic, not vibes. Every chunk you concatenate
consumes tokens from the same fixed context window the model uses for
attention computation; the tokenizer (typically a byte-pair-encoding
variant) maps text to sub-word tokens at roughly 4 characters per token in
English, so top_k=5 chunks of 1000 characters is roughly 1,250 tokens
before the question, instructions, and expected answer are even counted —
the concrete reason lesson 9's "context overflow" failure mode exists and why
prompt assembly has to budget tokens explicitly rather than just concatenate
until it "looks like enough."
Exercise¶
Write an ablation test: take one answerable question and one unanswerable
question, and run each through (a) the full build_prompt above and (b) a
stripped prompt containing only the context and the question with no rules.
Compare the four outputs. Which rules changed the behavior, and on which
question? Then add a fifth rule of your own design (for example: "answer in a
single sentence" or "quote the exact policy wording") and verify the model
follows it.