Index Ai Academy
Phase 1 · Building with LLMs · Context & retrieval

Retrieval-augmented generation

Answer from your own documents instead of the model's memory.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A model knows the public internet up to its training date. It doesn't know your return policy. It doesn't know this season's prices, or what your brand guide says about exclamation marks. Retrieval-augmented generation (RAG) fixes that without retraining anything. Your code looks up the passages that matter and pastes them into the prompt. Then the model answers from them. It's the most common pattern in business AI, from support bots to "chat with your docs" tools.

What you'll be able to do

  • Explain the three steps of RAG: index, retrieve, generate
  • Write a grounded prompt that puts retrieved documents before the question
  • Tell the model to answer only from the documents and to say when they don't cover the question
  • Build a working RAG function that returns an answer and its sources

What it covers

  1. An open-book exam
  2. Step 2: retrieve
  3. Step 3: the grounded prompt
  4. Worked example: a complete RAG function
  5. When RAG is the right tool

An open-book exam

Think of two kinds of exam. In a closed-book exam, you answer from memory. If you never learned a fact, you guess. In an open-book exam, you look up the right page first and answer from it.

A model on its own sits a closed-book exam. RAG turns it into an open-book one. Before the model answers, your code finds the right pages and puts them on the desk. The model reads them and answers from what's in front of it.

The name says the same thing. Retrieval means fetching the pages. Augmented means added to. Generation is the model writing its reply. So RAG is "writing a reply with fetched pages added to the prompt."

Three steps

RAG joins the pieces from the last three lessons into one pipeline, which means a chain of steps where each feeds the next:

1. Index     (once, ahead of time)   chunk documents → embed each chunk → store vectors
2. Retrieve  (per question)          embed the question → find the top-k closest chunks
3. Generate  (per question)          prompt = instructions + retrieved chunks + question → model

Here's each step in plain words:

  1. Index. You prepare the library once. You split long documents into chunks, short pieces of a few paragraphs each. You turn each chunk into an embedding, a list of numbers that captures its meaning. Then you store those lists, usually in a vector database, which is a store built for searching such lists.
  2. Retrieve. A question comes in. You turn it into an embedding too. Then you find the chunks whose embeddings sit closest to it. You keep the best few, called the top-k (k is how many).
  3. Generate. You build a prompt from your instructions, the chunks you found and the question. You send it to the model, and it writes the answer.

The model never searches anything itself. Your code does the retrieval and hands the model a short reading list. That's why RAG works with any model. It's also why its answers can only be as good as what you retrieve.

Quick check
Which of the three RAG steps runs once, ahead of time, rather than for every question?
  1. Retrieve
  2. All three run for every question
  3. Generate
  4. Index
Show the answer

Index

You chunk, embed and store the documents once. Retrieve and generate happen again for each new question.

Step 2: retrieve

The practice API has already done step 1 for you. Its help center is indexed. The endpoint GET /v1/docs?q=... runs the embedding search: it embeds the query and returns the closest documents. That's the same thing your search function did in the similarity search lesson.

import requests

API = "https://api.gradient.test/v1"
question = "Can I return a gift card?"
hits = requests.get(f"{API}/docs", params={"q": question, "limit": 2}).json()["data"]
for hit in hits:
    print(hit["score"], hit["id"], hit["title"])
It runs right here in your browser, with nothing to install.

Each hit has three parts. The score says how close the document is to the question, and higher means closer. The id is the document's short name, and the title is its heading. The hits come back best first, and "limit": 2 keeps the top two.

Go deeper What the search does behind the scenes

The help-center articles are short, so each one is kept whole as a single chunk. The API turns each article's title and text into an embedding. In a real system, that's the index step, done once ahead of time.

When you search, the API embeds your question the same way. Then it compares the question's embedding with every article's embedding. The score is the cosine similarity from the similarity search lesson. For these embeddings it runs from 0, unrelated, to 1, pointing the same way.

The API sorts the articles by score and returns the top limit of them. A real system with millions of chunks would use a vector database to do this quickly.

Search results carry ids and scores, but not the full text. Fetch the full text of each hit before building the prompt:

import requests

API = "https://api.gradient.test/v1"
doc = requests.get(f"{API}/docs/doc_giftcards").json()
print(doc["title"])
print(doc["text"][:120], "...")

The [:120] keeps the printout short by showing the first 120 characters. Your prompt will use the whole text.

Quick check
In the first cell above, you change "limit": 2 to "limit": 3 and run it again. What changes in the printout?
  1. It prints three lines: the same two hits, then the third-closest document
  2. It prints the full text of three documents
  3. It prints the same two lines with higher scores
  4. Nothing, because the question is the same
Show the answer

It prints three lines: the same two hits, then the third-closest document

limit is k, the number of hits to keep. The order is still best first, so the top two stay the same and one more line follows.

Step 3: the grounded prompt

A grounded prompt is one that ties the model to the documents you hand it. How you present those documents decides whether the model uses them. Four habits work well:

  1. Wrap each document in tags with its id and title. Then the model can tell where one ends and cite it: <document id="doc_returns" title="Returns and refunds">...</document>.
  2. Put the documents first and the question last. Claude answers long-context questions better when the material comes before the question.
  3. Tell it to answer only from the documents. If they don't contain the answer, it should say so plainly. Without that rule, a model fills gaps from its general knowledge. That's how a support bot invents a 90-day return window.
  4. Ask for citations by document id. A citation points at the source of a claim, so you (and your customers) can check the answer.
system:  You answer customer questions for an outdoor clothing shop. Use only the documents
         provided. If they don't contain the answer, say you don't know and suggest contacting
         support. Cite the ids of the documents you used in square brackets, like [doc_returns].

user:    <documents>
         <document id="doc_giftcards" title="Gift cards">Digital gift cards are ...</document>
         <document id="doc_returns" title="Returns and refunds">You can return ...</document>
         </documents>

         Question: Can I return a gift card?

The rules go in the system prompt because they apply to every question. The documents and the question change each time, so they go in the user message.

Watch out: retrieved text is data, not instructions. If a document says "ignore your rules," the model might follow it. You'll learn how to limit that in the prompt injection lesson.

Try it below. The two documents are shortened copies of the articles this question retrieves.

Quick check
What does this print?
passages = ["Refunds take 5 days.", "Shipping is free."]
context = "\n\n".join(passages)
prompt = f"Answer only from this:\n{context}"
print("Refunds" in prompt)
  1. False
  2. Refunds
  3. True
  4. None
Show the answer

True

join and the f-string put both passages into prompt, so the word Refunds is in it, and in gives True. That's RAG: the retrieved passages go right into the prompt, and the model answers from them, not from memory.

Try it now

A grounded request. Send it. Then try a question the documents don't cover, like "Who founded the company?" See whether the reply admits it doesn't know.

Quick check
Your RAG bot answered a shipping question with a delivery time that isn't in any of your documents. What's the most likely fix?
  1. Tell the model to use only the provided documents and to say when they don't contain the answer
  2. Move the question before the documents
  3. Retrieve fewer documents
  4. Remove the document ids
Show the answer

Tell the model to use only the provided documents and to say when they don't contain the answer

Without a clear rule to stay inside the documents, models fill gaps from their general knowledge.

Worked example

Worked example: a complete RAG function

This puts the steps together: retrieve, fetch, format, ask. The reply comes from the academy's server. It's a real model if a key is set, and otherwise the labeled simulator.

import requests

API = "https://api.gradient.test/v1"
SYSTEM = ("You answer customer questions for an outdoor clothing shop. Use only the documents provided. "
          "If they don't contain the answer, say you don't know and suggest contacting support. "
          "Cite the ids of the documents you used in square brackets, like [doc_returns].")

def ask(system, prompt, max_tokens=400):
    response = requests.post("https://api.anthropic.com/v1/messages",
        headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"},
        json={"model": "claude-opus-5-5", "max_tokens": max_tokens, "system": system,
              "messages": [{"role": "user", "content": prompt}]}, timeout=60)
    response.raise_for_status()
    return "".join(b["text"] for b in response.json()["content"] if b["type"] == "text")

def answer(question, k=2):
    hits = requests.get(f"{API}/docs", params={"q": question, "limit": k}).json()["data"]
    docs = [requests.get(f"{API}/docs/{h['id']}").json() for h in hits]
    context = "\n".join(f'<document id="{d["id"]}" title="{d["title"]}">{d["text"]}</document>' for d in docs)
    prompt = f"<documents>\n{context}\n</documents>\n\nQuestion: {question}"
    return ask(SYSTEM, prompt), [d["id"] for d in docs]

reply, sources = answer("How long does express shipping take?")
print("sources:", sources)
print(reply)
In the lesson you can change this and run it.

Here's what answer did, line by line:

  1. hits holds the top k search results for the question.
  2. docs fetches the full text of each hit.
  3. context wraps each document in tags, one per line.
  4. prompt puts the documents first and the question last.
  5. It returns two things: the model's reply and the list of document ids it retrieved.

Returning the sources has a practical benefit. Your app can show "Based on: Shipping and delivery" under the reply, even if the model forgets to cite. A real model replies along the lines of "1 to 2 business days if you order before 1pm Eastern [doc_shipping]".

Quick check
In the worked example, you call answer("How long does express shipping take?", k=3) instead. How many ids does sources hold?
  1. However many the model cites
  2. 1, the best match only
  3. 2, because the function's default is k=2
  4. 3
Show the answer

3

k=3 overrides the default of 2, so the search keeps three hits. sources lists one id for each fetched document, whatever the model cites.

When RAG is the right tool

RAG is not always the answer. A small shop with a one-page price list doesn't need a librarian. It can hand the model the whole page every time.

Use RAG whenConsider something else when
Answers live in documents that change (policies, catalogs, reports)The knowledge fits in the prompt anyway: paste it all in
You need citations people can checkYou need a style or format, not facts: use examples (few-shot)
The collection is too big for the context windowThe task is calculation or live data: use tools (coming up)

The context window is the most text a model can read in one request. A useful rule follows from it. Say your whole knowledge base is under about 100,000 tokens and doesn't change often. Then try putting all of it in the prompt before you build retrieval.

Pair that with prompt caching, which is a feature that lets the API reuse a long, unchanging start of a prompt across requests. Repeat requests then cost less and come back faster. You'll meet it in the cost and latency lesson. RAG earns its extra moving parts when the material is large or changes daily.

Quick check
Your shop's whole help center is 30 short articles, about 15,000 tokens in total. It changes twice a year. What should you try first?
  1. Build a RAG pipeline with a vector database
  2. Put all the articles in the prompt, with prompt caching
  3. Ask the model without any articles
  4. Split the articles into smaller chunks first
Show the answer

Put all the articles in the prompt, with prompt caching

The whole set fits well under the rough 100,000-token line and rarely changes. Pasting it all in is simpler, and caching keeps the repeated text cheap. RAG pays off when the material is large or changes often.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

In a RAG system, who searches the documents?

  1. Your code, before calling the model
  2. The model, during generation, whenever it notices that it doesn't know a fact
  3. The embedding model, after the answer, to check each claim against the documents
  4. Nobody: the model memorizes them when you first send them in

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson