Pranav Srivastava

8 lessons

0/8 done
Lesson 7 of 8·25 min·Intermediate

Building a RAG Pipeline End to End

What you will learn
  • Choose the right chunking strategy for your document type
  • Build a complete ingestion and retrieval pipeline in Python
  • Generate grounded answers with source citations

The four stages of RAG

RAG Pipeline — Ingest then Retrieve-and-Generate
  INGEST (run once, or on update)
  Documents
     |
     v
  Chunk (split into passages)
     |
     v
  Embed (encode each chunk)
     |
     v
  Index (store in vector DB)

  ---------------------------------

  QUERY (run per user question)
  User question
     |
     v
  Embed question
     |
     v
  Retrieve top-K chunks
     |
     v
  Build prompt [question + chunks]
     |
     v
  LLM generates answer with citations

Chunking strategies

Chunking is the most important decision in RAG. Too small: chunks lack context. Too large: irrelevant content pollutes the LLM context.

StrategyHow it worksBest for
Fixed-sizeSplit every N tokens with M overlapSimple docs, fastest to implement
RecursiveSplit by paragraph → sentence → wordGeneral prose, code
SemanticSplit at meaning boundaries (embed sentences, split on drops)Long-form documents with mixed topics
Late chunkingEmbed the full document, then pool chunk embeddingsWhen chunk context depends on surrounding text
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,       # characters per chunk
    chunk_overlap=64,     # overlap between chunks (context continuity)
    separators=["\n\n", "\n", ". ", " ", ""],  # try these in order
)

with open("document.txt") as f:
    text = f.read()

chunks = splitter.split_text(text)
print(f"{len(chunks)} chunks from {len(text)} chars")

Complete ingestion pipeline

import uuid
from pathlib import Path
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from langchain_text_splitters import RecursiveCharacterTextSplitter

client = QdrantClient(host="localhost", port=6333)
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)

client.recreate_collection(
    collection_name="knowledge_base",
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)


def ingest_file(path: Path):
    text = path.read_text(encoding="utf-8")
    chunks = splitter.split_text(text)
    print(f"  {path.name}: {len(chunks)} chunks")

    embeddings = model.encode(chunks, normalize_embeddings=True, show_progress_bar=False)

    points = [
        PointStruct(
            id=str(uuid.uuid4()),
            vector=emb.tolist(),
            payload={"text": chunk, "source": path.name, "chunk_index": i},
        )
        for i, (chunk, emb) in enumerate(zip(chunks, embeddings))
    ]
    client.upsert(collection_name="knowledge_base", points=points)


docs_dir = Path("./docs")
for doc in docs_dir.glob("*.txt"):
    ingest_file(doc)

print("Ingestion complete.")

Retrieval and generation with citations

import anthropic

anthropic_client = anthropic.Anthropic()


def retrieve(query: str, top_k: int = 5) -> list[dict]:
    q_vec = model.encode(query, normalize_embeddings=True).tolist()
    results = client.search(
        collection_name="knowledge_base",
        query_vector=q_vec,
        limit=top_k,
        with_payload=True,
    )
    return [{"text": r.payload["text"], "source": r.payload["source"], "score": r.score}
            for r in results]


def rag_answer(question: str) -> str:
    chunks = retrieve(question)

    context_parts = []
    for i, c in enumerate(chunks):
        context_parts.append(f"[{i+1}] (from {c['source']}, relevance {c['score']:.2f})\n{c['text']}")
    context = "\n\n".join(context_parts)

    prompt = f"""Answer the question using only the provided context. 
Cite sources by their number, e.g. [1] or [2][3].
If the context does not contain enough information, say so clearly.

Context:
{context}

Question: {question}"""

    response = anthropic_client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text


answer = rag_answer("How does MCP handle authentication?")
print(answer)
# -> "According to [1], MCP servers can implement OAuth 2.0..."

Query rewriting

Before retrieving, rewrite ambiguous queries to improve recall:

def rewrite_query(question: str) -> str:
    response = anthropic_client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=128,
        messages=[{"role": "user", "content":
            f"Rewrite this search query to be more specific and retrieval-friendly. "
            f"Return only the rewritten query, nothing else.\n\nOriginal: {question}"
        }]
    )
    return response.content[0].text.strip()


original = "how does it authenticate?"
rewritten = rewrite_query(original)
# -> "How does MCP server authentication work?"
Chapter summary
  • Chunking strategy is the most impactful decision in RAG — 512 chars with 64 overlap is a good start
  • Include source and chunk_index in payload so you can cite and trace answers
  • Pass retrieved chunks with source labels in the prompt to enable citation
  • Query rewriting improves recall for vague or ambiguous questions before retrieval
Check your understanding
  1. What is chunk overlap and why does it help?
  2. Why does the RAG prompt instruct the model to "answer using only the provided context"?
  3. What problem does query rewriting solve?

Finished this lesson?

Mark it done — your progress is saved automatically.