Retrieval-augmented generation (RAG) end to end
Build a small RAG assistant in Python: chunk your own files, embed them, retrieve the best passages and make the model answer only from them, with citations and an honest 'I don't know'.
What you'll build: a command-line assistant that answers questions from your own text files, shows which passages it used, and says "I don't know" when the files don't contain the answer. It is about 70 lines of Python.
Prerequisites: Python 3.10+, a free Gemini API key (set as GEMINI_API_KEY, see *Your first LLM API call*), and the idea of embeddings from *Embeddings and semantic search*. We use an in-memory index here; vector databases are in *Vector databases: pgvector, Chroma and friends*.
Why RAG exists
A language model knows what was in its training data. It does not know your college notes, your company wiki or your shop's refund policy, and if you ask, it may invent a confident answer. You cannot paste a whole wiki into every prompt either: it is too long, too slow and too costly.
Retrieval-augmented generation (RAG) fixes this in two steps. First, retrieve the few passages that are relevant to the question. Second, generate: give those passages to the model and ask it to answer from them. Think of an open-book exam: the model does not need to memorise the book, it only needs to find the right page and read it.
The RAG pipeline
| Stage | When it runs | What happens |
|---|---|---|
| Load and chunk | Once, offline | Read files, split them into small passages (chunks) |
| Embed and index | Once, offline | Turn each chunk into a vector and store it |
| Retrieve | Every question | Embed the question, find the closest chunks |
| Generate | Every question | Put the chunks in the prompt, ask the model to answer and cite |
Step 1: Set up the project
- 1Create a folder
rag-demoand open a terminal in it. - 2Create a virtual environment:
python -m venv .venv, then activate it (source .venv/bin/activateon macOS/Linux,.venv\Scripts\activateon Windows). - 3Install the libraries:
pip install google-genai numpy. - 4Set your key. macOS/Linux:
export GEMINI_API_KEY=your-key. Windows PowerShell:$env:GEMINI_API_KEY="your-key". - 5Create a folder
docsand add the three small text files below.
Brew & Bean is open every day from 8 am to 10 pm.
We do not deliver. Orders are pickup only: order on the website, then collect your order from the counter at your pickup time.
You can cancel an order only while its status is still received. Once it is preparing, it cannot be cancelled.Allergens: the Butter Croissant contains gluten and milk. The Banana Bread contains gluten, eggs and walnuts. The Cappuccino, Filter Coffee and Masala Chai contain milk.
The Espresso and the Cold Brew contain none of the common allergens.
Our kitchen handles nuts, milk and gluten, so we cannot promise that any item is completely free from traces.Oat milk can be used in any coffee or chai for an extra Rs 40.
We accept UPI, cards and cash at the counter.
The Cold Brew is steeped in cold water for 18 hours and served over ice.Start small on purpose
Real documents are longer, but start tiny. With three short files you can read every chunk yourself and see exactly why the system answered the way it did.
Step 2: The whole pipeline in one file
Create rag.py. Read the code once, then we go through each function. Three design choices matter: chunks are built from whole paragraphs (so a chunk is about one thing); documents and questions use different embedding task_type values (the model is tuned for both); and vectors are normalised to length 1, so a dot product equals cosine similarity.
import re
import sys
from pathlib import Path
import numpy as np
from google import genai
from google.genai import types
client = genai.Client() # reads GEMINI_API_KEY from the environment
EMBED_MODEL = "gemini-embedding-001"
GEN_MODEL = "gemini-flash-latest" # check the model names on ai.google.dev
def chunk_text(text, source, max_chars=600):
"""Split on blank lines, then pack paragraphs into chunks of about max_chars."""
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, current = [], ""
for p in paragraphs:
if current and len(current) + len(p) > max_chars:
chunks.append(current)
current = p
else:
current = current + "\n\n" + p if current else p
if current:
chunks.append(current)
return [{"source": source, "text": c} for c in chunks]
def embed(texts, task_type):
"""Turn a list of strings into a matrix of unit-length vectors."""
vectors = []
for i in range(0, len(texts), 50): # send at most 50 texts per request
resp = client.models.embed_content(
model=EMBED_MODEL,
contents=texts[i : i + 50],
config=types.EmbedContentConfig(task_type=task_type),
)
vectors.extend(e.values for e in resp.embeddings)
arr = np.array(vectors, dtype=np.float32)
return arr / np.linalg.norm(arr, axis=1, keepdims=True)
def build_index(folder):
chunks = []
for path in sorted(Path(folder).glob("*.txt")):
chunks += chunk_text(path.read_text(encoding="utf-8"), path.name)
matrix = embed([c["text"] for c in chunks], "RETRIEVAL_DOCUMENT")
return chunks, matrix
def retrieve(question, chunks, matrix, k=3):
q = embed([question], "RETRIEVAL_QUERY")[0]
scores = matrix @ q # cosine similarity, because all vectors have length 1
top = np.argsort(-scores)[:k]
return [(chunks[i], float(scores[i])) for i in top]
SYSTEM = (
"You answer questions for Brew & Bean, a coffee shop. Use ONLY the numbered "
"sources in the user message. If the sources do not contain the answer, say "
"'I don't know based on our documents.' Cite sources like [1] after each claim."
)
def answer(question, chunks, matrix):
hits = retrieve(question, chunks, matrix)
context = "\n\n".join(
f"[{n}] ({c['source']})\n{c['text']}" for n, (c, _) in enumerate(hits, 1)
)
prompt = f"Sources:\n{context}\n\nQuestion: {question}"
resp = client.models.generate_content(
model=GEN_MODEL,
contents=prompt,
config=types.GenerateContentConfig(system_instruction=SYSTEM, temperature=0.2),
)
return resp.text, hits
if __name__ == "__main__":
folder = sys.argv[1] if len(sys.argv) > 1 else "docs"
chunks, matrix = build_index(folder)
print(f"Indexed {len(chunks)} chunks. Ask a question (Ctrl+C to quit).")
while True:
q = input("\nYou: ")
text, hits = answer(q, chunks, matrix)
print("\nAnswer:", text)
print("\nRetrieved:")
for chunk, score in hits:
print(f" {score:.2f} {chunk['source']}: {chunk['text'][:60]!r}")Builds an index of every .txt file in a folder, then answers questions in a loop.
Step 3: What each part does
| Function | Job | Why it is written this way |
|---|---|---|
| chunk_text | Packs paragraphs into chunks of about 600 characters | Keeps ideas together. Splitting mid-sentence hurts retrieval |
| embed | Calls the embedding model in batches of 50 | Fewer requests, and free tiers have rate limits |
| build_index | Chunks every file and embeds all chunks once | Indexing is the slow part: do it once, not per question |
| retrieve | Embeds the question, scores every chunk, returns the top k | matrix @ q is cosine similarity for all chunks at once |
| answer | Builds numbered sources, asks the model to use only them | The numbers let the model cite, and let you check |
The most important line is the system instruction: *use ONLY the numbered sources* and *say you don't know*. Without it the model happily mixes retrieved facts with its own guesses, and you can no longer tell which is which.
Step 4: Run it
python rag.py docsIndexed 3 chunks. Ask a question (Ctrl+C to quit).
You: Can I get my coffee delivered to my room?
Answer: No, we do not deliver. Orders are pickup only: you order on the website and collect from the counter at your pickup time [1].
Retrieved:
0.71 policies.txt: 'Brew & Bean is open every day from 8 am to 10 pm.\n\nWe d'
0.55 extras.txt: 'Oat milk can be used in any coffee or chai for an extra Rs 4'
0.52 allergens.txt: 'Allergens: the Butter Croissant contains gluten and milk.Notice that the question said "delivered to my room" and the document said "We do not deliver". The words differ but the meaning matches. That is semantic search doing its job.
1. Does the banana bread have nuts?
2. How long is the cold brew steeped?
3. What is the CEO's name?What to expect: Questions 1 and 2 should be answered with a citation. Question 3 must get "I don't know based on our documents." If it invents a name, your system instruction is too weak.
Step 5: When RAG answers badly
RAG failures almost always fall into one of two groups, and the fix depends on which one you have. Always look at the Retrieved list first.
Debugging RAG
| Symptom | Likely cause | Fix |
|---|---|---|
| Right answer exists in docs, but the retrieved chunks are wrong | Retrieval problem | Better chunking, hybrid search, re-ranking, more chunks (next lesson) |
| Retrieved chunks are right, answer is still wrong or ignores them | Generation problem | Stronger instructions, lower temperature, fewer but better chunks |
| Answer is correct but says things not in the docs | Model mixing in its own knowledge | "Use ONLY the sources" and ask for citations |
| Answer to an unanswerable question is invented | No "I don't know" rule | Add the refusal rule and test it with questions you know are not in the docs |
Top-k never says 'nothing found'
Retrieval always returns something. With k=3 you always get three chunks, even when none is relevant. That is why the "say you don't know" rule matters. A common upgrade is to also check the best score and refuse when it is below a threshold you choose by testing.
Privacy
Embedding your documents sends their text to the API. Do not index confidential or personal data into a free-tier API. For private data, use a paid plan with suitable terms or a local model (see *Running open models locally with Ollama*).
How this comes up in interviews
Be ready to answer
- Explain RAG in two sentences and say when you would choose it over fine-tuning (fresh or private knowledge, need citations).
- Name the two failure groups: retrieval failures and generation failures, and how you tell them apart.
- Explain why you embed documents and queries with the same model.
- Describe how you would measure a RAG system (retrieval hit rate, then answer faithfulness: see *Evaluating AI apps (evals)*).
Try it: Add a score threshold
Change answer so that if the best score from retrieve is below a threshold, it returns "I don't know based on our documents." without calling the generation model. Find a sensible threshold by printing the best score for five questions that are in the docs and five that are not.
Try it: Index your own notes
Copy five pages of your own notes (as .txt) into a new folder and run python rag.py notes. Ask ten questions. For each bad answer, decide: was it a retrieval problem or a generation problem?
Key takeaways
- RAG = retrieve relevant chunks, then ask the model to answer only from them. It adds private or fresh knowledge without training.
- Index once (chunk, embed, store); at question time embed the query, rank by similarity, and build the prompt.
- Use the same embedding model for documents and queries, and normalise vectors so a dot product is cosine similarity.
- Tell the model to use only the sources, to cite them, and to say "I don't know" otherwise.
- When an answer is bad, read the retrieved chunks first: it separates retrieval problems from generation problems.
Check yourself
Finishing the quiz marks the lesson as done.
1. What problem does RAG mainly solve?
2. A RAG system answers wrongly. The retrieved chunks do not contain the answer, although the documents do. Where is the problem?
3. Why do we tell the model to answer 'only from the sources' and to say 'I don't know' otherwise?
4. Why normalise embedding vectors to length 1 in this lesson's code?
5. When is RAG usually a better choice than fine-tuning?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.