top of page

Tutorial 5: Build a Basic RAG System

  • Contributor
  • 4 days ago
  • 3 min read

RAG (Retrieval-Augmented Generation) grounds an LLM's answers in your own data. The model doesn't make things up; it answers from what you provide. This tutorial builds the core pattern.

What You'll Build

A RAG system: user question → retrieved context → grounded answer.

Step 1: The RAG Loop (5 min)

1. User asks a question
2. Retrieve relevant documents (via embeddings)
3. Build a prompt with the docs as context
4. LLM answers using the context
5. Return answer with source citations

That's it. Everything else is refinement.

Step 2: Prep Your Corpus (15 min)

From Tutorial 4, you have documents with embeddings. For RAG, also split long docs into chunks:

def chunk_document(text, chunk_size=500, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap
    return chunks

# Process each doc
for doc in documents:
    chunks = chunk_document(doc["text"])
    for i, chunk in enumerate(chunks):
        store_chunk({
            "doc_id": doc["id"],
            "chunk_index": i,
            "text": chunk,
            "embedding": embed(chunk),
        })

Chunks are 500 chars with 50-char overlap. Adjust to your domain.

Step 3: Retrieval (10 min)

Get the top-k most similar chunks:

def retrieve_chunks(query, top_k=5):
    query_embedding = embed(query)
    
    with conn.cursor() as cur:
        cur.execute("""
            SELECT doc_id, chunk_index, text, 1 - (embedding <=> %s) as score
            FROM chunks
            ORDER BY embedding <=> %s
            LIMIT %s
        """, (query_embedding, query_embedding, top_k))
        return cur.fetchall()

Step 4: Build the Prompt (15 min)

def build_rag_prompt(question, retrieved_chunks):
    context = "\n\n".join([
        f"[Source {i+1}, doc {c['doc_id']}]\n{c['text']}"
        for i, c in enumerate(retrieved_chunks)
    ])
    
    return f"""
You are a helpful assistant. Answer the question using only the provided sources.

If the sources don't contain the answer, say so.
Cite the source number when stating facts.

Sources:
{context}

Question: {question}

Answer:
"""

The prompt explicitly grounds in the retrieved content.

Step 5: Generate (10 min)

def rag_answer(question):
    chunks = retrieve_chunks(question, top_k=5)
    prompt = build_rag_prompt(question, chunks)
    
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return {
        "answer": response.content[0].text,
        "sources": [{"doc_id": c["doc_id"], "chunk": c["chunk_index"]} for c in chunks]
    }

Returns the answer plus sources. Critical for trust.

Step 6: Test It (15 min)

result = rag_answer("How do I reset my password?")
print(result["answer"])
print("Sources:", result["sources"])

Check:

  • Does the answer make sense?

  • Does it cite sources?

  • Are the cited sources actually relevant?

  • Does it say "I don't know" when appropriate?

Step 7: Handle "Not Found" (10 min)

When retrieval doesn't find relevant content:

def rag_answer(question, min_similarity=0.5):
    chunks = retrieve_chunks(question, top_k=5)
    
    # Check if anything is actually relevant
    if not chunks or chunks[0]["score"] < min_similarity:
        return {
            "answer": "I don't have information about that in my knowledge base.",
            "sources": []
        }
    
    # ... rest

Better to say "I don't know" than hallucinate.

Step 8: Iterate on Chunk Strategy (varies)

Chunking dramatically affects quality:

  • Too small: chunks lack context

  • Too large: precision drops; less focused retrieval

  • Wrong boundaries: cut mid-thought

Experiment. For documentation: 500-1000 chars with section-aware splitting often best. For code: function-level chunks. For chat logs: turn-level.

Step 9: Improve Retrieval Quality (varies)

Common improvements:

  • Re-ranking: retrieve top-20; rerank with a cross-encoder; keep top-5

  • Query expansion: generate query variations; retrieve for each

  • Hybrid search: semantic + keyword (Tutorial 4)

  • Filtering: by date, doc type, etc., before semantic search

Each improves precision; trade off complexity.

Step 10: Evaluate End-to-End (30 min)

Eval set:

TEST_CASES = [
    {
        "question": "How do I reset my password?",
        "expected_source_id": "doc_pwd_reset",
        "expected_answer_contains": ["click", "email"],
    },
    # ...
]

for case in TEST_CASES:
    result = rag_answer(case["question"])
    
    # Source check
    sources_correct = case["expected_source_id"] in [s["doc_id"] for s in result["sources"]]
    
    # Content check
    content_check = all(
        keyword in result["answer"].lower()
        for keyword in case["expected_answer_contains"]
    )
    
    print(f"Source: {sources_correct}, Content: {content_check}")

Track over time; catch regressions.

What You Just Did

You built a working RAG system. Questions get answered from your data, with citations. The model doesn't have to know everything — it just has to reason over what you give it.

Common Failure Modes

Top-1 retrieval. Misses cases where the answer spans multiple chunks.

No similarity threshold. Returns "answers" even when no relevant content.

No source citation. User can't verify.

Static chunking. One-size-fits-all chunking; some docs degrade badly.

No evaluation. Don't know if it's working.

bottom of page