ailiteracynepal 🇳🇵
Text size

Chapter 05 · Section II · 28 min read

Building a RAG pipeline step by step

The complete code, walked one function at a time. Load, chunk, embed, retrieve, prompt, cite. About 200 lines that generalise to any RAG project.

This section is the code. We take the mental model from Section 1 and turn it into a working RAG pipeline for a corpus of Nepali documents. Every step is walked, every design decision named. By the end you’ll have around 200 lines of Python that can answer questions from any folder of documents you point it at, with source citations, in Nepali or English, honestly saying “I don’t know” when the documents don’t cover the question.

The full architecture

   ┌──────────────────────────────────────────────────────────────┐
   │  BUILD PHASE (once per corpus update)                        │
   ├──────────────────────────────────────────────────────────────┤
   │  1. Load documents from folder                               │
   │  2. Chunk each document into passages                        │
   │  3. Embed all passages (cached)                              │
   │  4. Save vectors + metadata to disk                          │
   └──────────────────────────────────────────────────────────────┘

   ┌──────────────────────────────────────────────────────────────┐
   │  QUERY PHASE (every user question)                           │
   ├──────────────────────────────────────────────────────────────┤
   │  1. Embed the user's query                                   │
   │  2. Retrieve top-k similar passages                          │
   │  3. Format them into the RAG prompt                          │
   │  4. Call the LLM                                             │
   │  5. Return the answer + cited sources                        │
   └──────────────────────────────────────────────────────────────┘

Build once, query many. Everything below is code.

Step 1 — data structures

Small, honest types for what we’re passing around:

from dataclasses import dataclass
from pathlib import Path

@dataclass
class Passage:
    text: str
    source: str          # filename
    chunk_index: int     # position within the source


@dataclass
class RetrievedResult:
    passage: Passage
    similarity: float


@dataclass
class RAGAnswer:
    answer: str
    sources: list[str]   # deduplicated source filenames used
    passages_used: list[RetrievedResult]
    prompt_tokens: int
    completion_tokens: int
    cost_usd: float

Simple and typed. Now every function has a clear input and output.

Step 2 — load and chunk

Reuse the chunker from Chapter 4 Section 3 without changes:

import re


def split_into_paragraphs(text: str) -> list[str]:
    parts = re.split(r"\n\s*\n", text.strip())
    return [p.strip() for p in parts if p.strip()]


def approximate_token_count(text: str) -> int:
    return max(1, len(text) // 3)   # rough heuristic


def chunk_document(text: str, target_size: int = 400, max_size: int = 700) -> list[str]:
    paragraphs = split_into_paragraphs(text)
    chunks: list[str] = []
    current: list[str] = []
    current_size = 0

    for p in paragraphs:
        p_size = approximate_token_count(p)
        if current and (current_size + p_size > max_size):
            chunks.append("\n\n".join(current))
            current = [p]
            current_size = p_size
            continue
        current.append(p)
        current_size += p_size
        if current_size >= target_size:
            chunks.append("\n\n".join(current))
            current = []
            current_size = 0

    if current:
        chunks.append("\n\n".join(current))
    return chunks


def load_corpus(folder: str) -> list[Passage]:
    passages: list[Passage] = []
    for path in sorted(Path(folder).glob("*.txt")):
        text = path.read_text(encoding="utf-8")
        for i, chunk in enumerate(chunk_document(text)):
            passages.append(Passage(text=chunk, source=path.name, chunk_index=i))
    return passages

Step 3 — embedding + persistence

Same pattern as Chapter 4 Section 3, packaged as one class:

import hashlib
import json
import numpy as np
from openai import OpenAI

_openai = OpenAI()


class EmbeddingIndex:
    def __init__(self, cache_path: str = "data/embed_cache.json"):
        self.cache_path = Path(cache_path)
        self.cache: dict[str, list[float]] = {}
        if self.cache_path.exists():
            with open(self.cache_path, encoding="utf-8") as f:
                self.cache = json.load(f)
        self.passages: list[Passage] = []
        self.vectors: np.ndarray = np.empty((0, 1536))

    @staticmethod
    def _key(text: str) -> str:
        return hashlib.sha256(text.encode("utf-8")).hexdigest()

    def _embed(self, texts: list[str], batch_size: int = 100) -> list[list[float]]:
        results = [None] * len(texts)
        needed_i = []
        needed_texts = []
        for i, t in enumerate(texts):
            k = self._key(t)
            if k in self.cache:
                results[i] = self.cache[k]
            else:
                needed_i.append(i)
                needed_texts.append(t)

        for start in range(0, len(needed_texts), batch_size):
            batch = needed_texts[start:start + batch_size]
            response = _openai.embeddings.create(
                model="text-embedding-3-small",
                input=batch,
            )
            for offset, item in enumerate(response.data):
                idx_in_needed = start + offset
                overall_i = needed_i[idx_in_needed]
                results[overall_i] = item.embedding
                self.cache[self._key(needed_texts[idx_in_needed])] = item.embedding

        return results

    def build(self, passages: list[Passage]):
        self.passages = passages
        vectors = self._embed([p.text for p in passages])
        self.vectors = np.array(vectors)
        self._save_cache()

    def _save_cache(self):
        with open(self.cache_path, "w", encoding="utf-8") as f:
            json.dump(self.cache, f, ensure_ascii=False)

    def search(self, query: str, k: int = 5) -> list[RetrievedResult]:
        query_vec = np.array(self._embed([query])[0])
        norms = np.linalg.norm(self.vectors, axis=1) * np.linalg.norm(query_vec)
        sims = self.vectors @ query_vec / norms
        top_idx = np.argsort(sims)[-k:][::-1]
        return [RetrievedResult(self.passages[i], float(sims[i])) for i in top_idx]

Step 4 — the RAG prompt

The system prompt tells the LLM to answer only from the passages, cite sources, and say “I don’t know” when needed:

RAG_SYSTEM_PROMPT = """You are a helpful assistant that answers questions
using ONLY the retrieved passages provided.

Rules:
1. Answer only using information from the passages. Do not use outside
   knowledge, even if you know the topic.
2. If the passages don't contain the answer, respond with: "I don't have
   information about that in the provided documents." Do not guess.
3. Cite the sources you used by their [Source: filename] tag. Every
   factual claim should be traceable to a source.
4. Respond in the same language the user asked in (English or Nepali).
5. Keep the answer concise: 2-5 sentences unless the question requires
   more.

If the question and passages are in different languages, translate
your understanding but respond in the user's language.
"""


def format_passages_for_prompt(results: list[RetrievedResult]) -> str:
    lines = []
    for i, r in enumerate(results, 1):
        lines.append(f"[Source: {r.passage.source}]")
        lines.append(r.passage.text)
        lines.append("")   # blank line
    return "\n".join(lines)


def build_rag_prompt(question: str, results: list[RetrievedResult]) -> str:
    passages = format_passages_for_prompt(results)
    return f"""Retrieved passages:

{passages}

---

Question: {question}

Answer:"""

Step 5 — the answer function

Tie it all together with a clean API:

import anthropic

_anthropic = anthropic.Anthropic()

HAIKU_INPUT_PER_M  = 0.25
HAIKU_OUTPUT_PER_M = 1.25


def answer_question(
    question: str,
    index: EmbeddingIndex,
    k: int = 5,
    model: str = "claude-haiku-4-5-20251001",
) -> RAGAnswer:
    results = index.search(question, k=k)
    prompt = build_rag_prompt(question, results)

    response = _anthropic.messages.create(
        model=model,
        max_tokens=800,
        temperature=0.0,   # deterministic for factual Q&A
        system=RAG_SYSTEM_PROMPT,
        messages=[{"role": "user", "content": prompt}],
    )

    answer_text = response.content[0].text
    sources_used = sorted({r.passage.source for r in results})
    cost = (
        response.usage.input_tokens  / 1_000_000 * HAIKU_INPUT_PER_M +
        response.usage.output_tokens / 1_000_000 * HAIKU_OUTPUT_PER_M
    )

    return RAGAnswer(
        answer=answer_text,
        sources=sources_used,
        passages_used=results,
        prompt_tokens=response.usage.input_tokens,
        completion_tokens=response.usage.output_tokens,
        cost_usd=cost,
    )

Step 6 — a working CLI

def main():
    print("Building RAG index over data/corpus/ ...")
    passages = load_corpus("data/corpus/")
    print(f"Loaded {len(passages)} passages.")

    index = EmbeddingIndex(cache_path="data/embed_cache.json")
    index.build(passages)
    print("Index ready.\n")

    while True:
        try:
            q = input("Question (or 'quit'): ").strip()
        except (KeyboardInterrupt, EOFError):
            print()
            break

        if q.lower() in {"quit", "exit"}:
            break
        if not q:
            continue

        result = answer_question(q, index)

        print(f"\n{result.answer}\n")
        print(f"[Sources: {', '.join(result.sources)}]")
        print(f"[Cost: ${result.cost_usd:.5f} — "
              f"{result.prompt_tokens} in / {result.completion_tokens} out]\n")


if __name__ == "__main__":
    main()

Running it

Put some .txt documents in data/corpus/. Nepali government-style circulars, company policies, whatever you have.

python rag.py
Building RAG index over data/corpus/ ...
Loaded 143 passages.
Index ready.

Question: What are the disclosure timelines for listed companies?

The bot fires off a semantic search, retrieves the top 5 passages, hands them to Claude with the “answer only from these” instruction, and prints:

Listed companies must disclose material information within 24 hours of
occurrence, and file quarterly financial reports within 30 days of
quarter-end. Certain events (mergers, capital changes) require immediate
disclosure regardless of the 24-hour window.

[Sources: nepse_listing_bylaw_2024.txt, disclosure_amendments_2025.txt]
[Cost: $0.00082 — 1,850 in / 210 out]

Question: What's the meaning of life?
I don't have information about that in the provided documents.

[Sources: nepse_listing_bylaw_2024.txt, disclosure_amendments_2025.txt]
[Cost: $0.00048 — 1,320 in / 60 out]

Two things to note:

  • The answer is grounded, cited, and specific.
  • When asked something the corpus doesn’t cover, the bot says so honestly. This is the behaviour the RAG system prompt engineered.

Common failure modes and their fixes

Six things that go wrong with real RAG systems, and their fixes:

1. Retrieval misses the right passage. → Improve chunking, embed with a better model, or add hybrid keyword search.

2. The LLM ignores the “answer only from passages” rule and hallucinates. → Strengthen the system prompt with capitalised MUSTs, and add a validator that flags responses without any citation.

3. The LLM refuses even when the answer is in the passages. → Usually a prompt phrasing issue; test with the retrieved passages inserted directly and see whether the model can answer at all.

4. Answer is technically correct but too long. → Add explicit length constraint in the prompt.

5. Sources are listed but not actually cited in the text. → Adjust the prompt to require inline [Source] markers.

6. Costs balloon. → Reduce k (fewer retrieved passages), use a smaller model, or cache answers to common questions.

Every one of these is a prompt or configuration change, not a fundamental redesign. Iteration is fast.

Check your understanding

Quick check

A teammate's RAG pipeline sometimes says 'I don't have information about that' even when the answer is clearly in one of the retrieved passages. Investigation shows the passage does contain the answer verbatim. What's the most likely cause?

Quick check

A RAG pipeline is answering with `[Cost: $0.05]` per question — much higher than expected. Investigation shows the pipeline is retrieving k=20 passages, most of which are long. What are the two-part fix?

What comes next

You have a working RAG pipeline. The last section of Chapter 5 wraps it in a specific project: a question-answering bot for local Nepali content — the kind of thing a small business, NGO, or agency could actually deploy. We add a small web UI, per-conversation memory, and honest failure handling.