AI Engineering: Build AI-Powered Apps
Hands-onIntermediate 40 min· RAG: AI over your own data · 8 of 19

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

StageWhen it runsWhat happens
Load and chunkOnce, offlineRead files, split them into small passages (chunks)
Embed and indexOnce, offlineTurn each chunk into a vector and store it
RetrieveEvery questionEmbed the question, find the closest chunks
GenerateEvery questionPut the chunks in the prompt, ask the model to answer and cite

Step 1: Set up the project

  1. 1Create a folder rag-demo and open a terminal in it.
  2. 2Create a virtual environment: python -m venv .venv, then activate it (source .venv/bin/activate on macOS/Linux, .venv\Scripts\activate on Windows).
  3. 3Install the libraries: pip install google-genai numpy.
  4. 4Set your key. macOS/Linux: export GEMINI_API_KEY=your-key. Windows PowerShell: $env:GEMINI_API_KEY="your-key".
  5. 5Create a folder docs and add the three small text files below.
docs/policies.txt
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.
docs/allergens.txt
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.
docs/extras.txt
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.

rag.py
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

FunctionJobWhy it is written this way
chunk_textPacks paragraphs into chunks of about 600 charactersKeeps ideas together. Splitting mid-sentence hurts retrieval
embedCalls the embedding model in batches of 50Fewer requests, and free tiers have rate limits
build_indexChunks every file and embeds all chunks onceIndexing is the slow part: do it once, not per question
retrieveEmbeds the question, scores every chunk, returns the top kmatrix @ q is cosine similarity for all chunks at once
answerBuilds numbered sources, asks the model to use only themThe 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

Terminal
python rag.py docs
Your exact scores and wording will differ
Indexed 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.

Prompt · Try these three questions
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

SymptomLikely causeFix
Right answer exists in docs, but the retrieved chunks are wrongRetrieval problemBetter chunking, hybrid search, re-ranking, more chunks (next lesson)
Retrieved chunks are right, answer is still wrong or ignores themGeneration problemStronger instructions, lower temperature, fewer but better chunks
Answer is correct but says things not in the docsModel mixing in its own knowledge"Use ONLY the sources" and ask for citations
Answer to an unanswerable question is inventedNo "I don't know" ruleAdd 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

  1. Explain RAG in two sentences and say when you would choose it over fine-tuning (fresh or private knowledge, need citations).
  2. Name the two failure groups: retrieval failures and generation failures, and how you tell them apart.
  3. Explain why you embed documents and queries with the same model.
  4. 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

  1. RAG = retrieve relevant chunks, then ask the model to answer only from them. It adds private or fresh knowledge without training.
  2. Index once (chunk, embed, store); at question time embed the query, rank by similarity, and build the prompt.
  3. Use the same embedding model for documents and queries, and normalise vectors so a dot product is cosine similarity.
  4. Tell the model to use only the sources, to cite them, and to say "I don't know" otherwise.
  5. 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.

Start free