How to Build a RAG Pipeline in Python
A RAG pipeline has four stages: chunk your documents, embed them, retrieve the most relevant chunks by cosine similarity, and stuff them into an LLM prompt as context. Each stage is simple - wiring them together correctly is the skill.
The four-stage pipeline
Retrieval-Augmented Generation (RAG) grounds an LLM in a specific corpus so it answers from your data rather than from training memory. Every RAG system - from a basic prototype to a production knowledge tool - follows the same four stages:
- Chunk - split documents into retrievable pieces (~200 chars each)
- Index - embed all chunks into vectors (TF-IDF or dense embeddings)
- Retrieve - vectorize the question, rank chunks by cosine similarity, return top-k
- Generate - pass the retrieved chunks as a system message and call the LLM
Build it in Python
The simplest production-valid implementation uses TfidfVectorizer from
scikit-learn. TF-IDF is offline, deterministic, and requires no external
embedding API - a solid first choice for small-to-medium corpora.
import numpy as np
import requests
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
LLM_URL = "http://llm-service:8080/v1/chat/completions"
_vectorizer = None
_matrix = None
_chunks = []
def chunk(text):
"""Split a document into ~paragraph-sized chunks."""
return [p.strip() for p in text.split("\n\n") if p.strip()] or [text.strip()]
def index(corpus):
"""Fit TF-IDF on all chunks and store the matrix."""
global _vectorizer, _matrix, _chunks
_chunks = [c for doc in corpus for c in chunk(doc)]
_vectorizer = TfidfVectorizer().fit(_chunks)
_matrix = _vectorizer.transform(_chunks)
def retrieve(question, k=2):
"""Return the top-k chunks most similar to the question."""
qv = _vectorizer.transform([question])
sims = cosine_similarity(qv, _matrix)[0]
idxs = np.argsort(sims)[::-1][:k]
return [_chunks[i] for i in idxs]
def answer(question):
"""Retrieve context and call the LLM."""
context = "\n\n".join(retrieve(question, k=2))
payload = {
"model": "my-model",
"messages": [
{"role": "system", "content": f"Answer using ONLY the context below.\n\nContext:\n{context}"},
{"role": "user", "content": question},
],
"temperature": 0,
"max_tokens": 80,
}
r = requests.post(LLM_URL, json=payload, timeout=60)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
Call index(corpus) once at startup, then answer(question) for every query.
Why each stage matters
- Chunking - chunks that are too large dilute the similarity signal; too small and you lose context. Paragraph splits (~200 chars) are a reliable starting point.
- Cosine similarity -
np.argsort(sims)[::-1][:k]is the full retrieval path: sort descending, take top-k. It runs in milliseconds on a few thousand chunks. - The system message - putting retrieved context in
"role": "system"keeps it separate from the user's question and reduces hallucination. The instruction "answer using ONLY the context below" is load-bearing.
Upgrading the pipeline
Once this shape is working, each stage upgrades independently:
- Embed - swap
TfidfVectorizerforsentence-transformersoropenai.embeddingsfor dense vectors that understand semantics. - Store - move from an in-memory numpy matrix to pgvector, Pinecone, or Qdrant for millions of chunks.
- Retrieve - add a reranker (Cohere rerank, cross-encoder) between retrieve and generate to boost precision.
- Eval - measure answer quality with RAGAS (faithfulness, context recall) before shipping to production.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Chunking a document corpus and fitting a TF-IDF index
- Retrieving top-k chunks by cosine similarity and passing them as LLM context
- Wiring all four stages - chunk, index, retrieve, answer - into a single pipeline function
FAQ
What is a RAG pipeline?
Retrieval-Augmented Generation (RAG) is a pattern where you retrieve relevant chunks from a corpus - by embedding the question and ranking by similarity - and pass those chunks as context to an LLM. The model answers from the retrieved text rather than training memory, which reduces hallucination and keeps answers grounded in your data.
How do I retrieve the most relevant chunks for a question?
Embed all chunks at index time (TF-IDF or dense vectors), embed the question at query time with the same vectorizer, compute cosine similarity between the question vector and the chunk matrix, then take the top-k chunks by score. In scikit-learn this is cosine_similarity(qv, matrix)[0] followed by argsort.
How do I pass retrieved context to an LLM?
Join the top-k chunks into a single string and send them as a system message: {"role": "system", "content": "Answer using ONLY the context below.\n\nContext:\n" + context}. Putting context in the system message keeps it separate from the user question and makes it easier to instruct the model to stay grounded.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →