Your first RAG pipeline in pure Python
Chunk, vectorise, rank and build a cited prompt in ~150 lines of standard-library Python, test it with pytest, then swap in a real Embedding model and LLM without touching the rest.
A developer types into your API docs assistant: “What happens if I exceed the rate limit?” The answer lives in one sentence of rate-limits.md: HTTP 429 with a Retry-After header. Getting that sentence in front of a model, with its source id attached, is the whole job of a RAG pipeline.
Most tutorials start by installing a vector database, an embedding library and an SDK, and the parts that decide quality disappear inside other people's code. This one builds every stage in standard-library Python first, so you can see and test each decision, then replaces exactly two functions with a real embedding model and a real LLM call.
Two phases
Indexing runs once per document change: split into chunks, turn each chunk into a vector. Query time runs per question: vectorise the question, score chunks, keep the best few, assemble a prompt, call the model.
The full program
Save as rag.py and run python3 rag.py (Python 3.10+, no installs).
"""A complete RAG retrieval pipeline in standard-library Python (3.10+)."""
from __future__ import annotations
import math
import re
from collections import Counter
from dataclasses import dataclass, field
DOCS: dict[str, str] = {
"rate-limits.md": (
"Each API key may send 600 requests per minute. "
"Requests over the limit receive HTTP 429 with a Retry-After header. "
"Batch endpoints count as one request per call, not per item."
),
"webhooks.md": (
"Webhook deliveries time out after 10 seconds. "
"Failed deliveries are retried with exponential backoff for up to 72 hours. "
"Each delivery carries an HMAC-SHA256 signature in the X-Signature header."
),
"auth.md": (
"API keys are scoped to a single workspace. "
"Keys can be rotated from the dashboard without downtime. "
"OAuth access tokens expire after 60 minutes."
),
}
STOPWORDS = {"a", "an", "the", "and", "or", "to", "of", "in", "is", "are", "can", "do",
"does", "we", "you", "any", "for", "after", "how", "what", "i", "my", "per",
"with", "not", "from", "up", "one", "each", "many", "long", "much"}
@dataclass
class Chunk:
id: str
text: str
vec: dict[str, float] = field(default_factory=dict)
def tokenize(text: str) -> list[str]:
words = re.findall(r"[a-z0-9]+", text.lower())
return [w for w in words if w not in STOPWORDS]
def split_sentences(text: str) -> list[str]:
return [s for s in re.split(r"(?<=[.!?])\s+", text.strip()) if s]
def chunk(doc_id: str, text: str, max_words: int = 25, overlap_sentences: int = 1) -> list[Chunk]:
"""Pack whole sentences into chunks of at most ~max_words.
Overlap is counted in sentences, not words, so every chunk starts at a
sentence boundary. A single sentence longer than max_words becomes its own chunk.
"""
sentences = split_sentences(text)
pieces: list[list[str]] = []
current: list[str] = []
for sentence in sentences:
words_now = sum(len(s.split()) for s in current)
if current and words_now + len(sentence.split()) > max_words:
pieces.append(current)
carried = current[-overlap_sentences:] if overlap_sentences else []
# Drop the carry if it would leave no room for the new sentence.
if sum(len(s.split()) for s in carried) + len(sentence.split()) > max_words:
carried = []
current = list(carried)
current.append(sentence)
if current:
pieces.append(current)
return [Chunk(id=f"{doc_id}#{i}", text=" ".join(p)) for i, p in enumerate(pieces)]
def build_idf(token_lists: list[list[str]]) -> dict[str, float]:
n = len(token_lists)
df: Counter[str] = Counter()
for tokens in token_lists:
df.update(set(tokens))
# Smoothed IDF: a word in every chunk still gets weight 1, never 0.
return {term: math.log((1 + n) / (1 + count)) + 1 for term, count in df.items()}
def tfidf_vector(tokens: list[str], idf: dict[str, float]) -> dict[str, float]:
counts = Counter(tokens)
return {t: c * idf[t] for t, c in counts.items() if t in idf}
def cosine(a: dict[str, float], b: dict[str, float]) -> float:
dot = sum(v * b.get(t, 0.0) for t, v in a.items())
norm_a = math.sqrt(sum(v * v for v in a.values()))
norm_b = math.sqrt(sum(v * v for v in b.values()))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
@dataclass
class Index:
chunks: list[Chunk]
idf: dict[str, float]
def build_index(docs: dict[str, str], max_words: int = 25) -> Index:
"""Indexing: runs once, ahead of time."""
chunks = [c for doc_id, text in docs.items() for c in chunk(doc_id, text, max_words)]
token_lists = [tokenize(c.text) for c in chunks]
idf = build_idf(token_lists)
for c, tokens in zip(chunks, token_lists):
c.vec = tfidf_vector(tokens, idf)
return Index(chunks, idf)
def retrieve(index: Index, question: str, k: int = 2, min_score: float = 0.05) -> list[tuple[float, Chunk]]:
"""Query time: score every chunk, keep the best k above a floor."""
q_vec = tfidf_vector(tokenize(question), index.idf)
scored = sorted(((cosine(q_vec, c.vec), c) for c in index.chunks),
key=lambda pair: pair[0], reverse=True)
return [(s, c) for s, c in scored[:k] if s >= min_score]
def build_prompt(question: str, hits: list[tuple[float, Chunk]]) -> str | None:
if not hits:
return None
sources = "\n".join(f"[{c.id}] {c.text}" for _, c in hits)
return (
"Answer using ONLY the sources below. Cite the source id in brackets after each claim. "
"If the sources do not contain the answer, reply exactly: I don't know.\n\n"
f"Sources:\n{sources}\n\nQuestion: {question}\nAnswer:"
)
def call_llm(prompt: str) -> str:
"""Stub. The second listing in this post replaces it with a real model call."""
return "(the model's cited answer would appear here)"
def main() -> None:
index = build_index(DOCS)
for question in ["What happens if I exceed the rate limit?",
"How long are failed webhook deliveries retried?",
"Do you support SAML?"]:
hits = retrieve(index, question)
print("Q:", question)
for score, c in hits:
print(f" {score:.2f} {c.id:<16} {c.text[:52]}...")
prompt = build_prompt(question, hits)
print(" ->", "I don't know (no source cleared the floor)." if prompt is None else call_llm(prompt))
print()
if __name__ == "__main__":
main()Output:
Q: What happens if I exceed the rate limit?
0.23 rate-limits.md#1 Requests over the limit receive HTTP 429 with a Retr...
0.23 rate-limits.md#0 Each API key may send 600 requests per minute. Reque...
-> (the model's cited answer would appear here)
Q: How long are failed webhook deliveries retried?
0.62 webhooks.md#0 Webhook deliveries time out after 10 seconds. Failed...
0.30 webhooks.md#1 Failed deliveries are retried with exponential backo...
-> (the model's cited answer would appear here)
Q: Do you support SAML?
-> I don't know (no source cleared the floor).Step 1: chunking on sentence boundaries
chunk() splits a document into sentences and packs whole sentences into chunks of at most 25 words. When a chunk fills up, the next one starts by repeating its last sentence. That repeat is chunk overlap, and counting it in sentences rather than words is the important design choice: a word-count overlap of five would start the next chunk with a fragment like “in the original packaging”, which is exactly the mid-sentence boundary that chunking is supposed to avoid. Here every chunk starts at the start of a sentence; the tests below assert it.
Overlap has a price, and the first query shows it. rate-limits.md#0 and #1 both contain the 429 sentence, so they take both top-2 slots with near-identical scores, crowding out anything else. Production systems de-duplicate adjacent chunks from the same document after retrieval, or use parent-document retrieval so overlap is not needed at all.
Step 2: text to vectors
TF-IDF weights each word by how often it appears in the chunk times how rare it is across all chunks. It is classic sparse retrieval: the vector is a dict of non-zero weights because almost every word in the language scores zero for any given chunk. Its production descendant, BM25, adds length normalisation and term-frequency saturation, and is still the best scorer for exact identifiers like 429 or X-Signature.
TF-IDF cannot match “go over my request quota” to “requests over the limit” beyond the shared word requests. That gap is what dense retrieval with a learned embedding closes, and tfidf_vector() is the seam where it plugs in.
Step 3: cosine similarity
cosine() is the dot product divided by both vector lengths, so it ranges from 0 (no shared weighted words) to 1. Normalising by length means a long chunk does not win just by containing more words. This is cosine similarity, and with unit-length vectors it reduces to a plain dot product, which is what the real version below exploits.
Step 4: top-k and the floor
retrieve() scores every chunk, sorts, and keeps the top k=2 whose score clears min_score=0.05. Scanning every chunk is fine at 7 chunks and still fine at ~100k with NumPy; beyond that you need an approximate nearest-neighbour index, and the function signature does not change.
The floor is how the SAML question gets an honest “I don't know” without calling a model: no chunk shares a word with it. But change the question to “Do you support SAML single sign-on?” and auth.md#0 scores 0.23, matching on the word single (“scoped to a single workspace”), the same score as a genuine rate-limit hit. A fixed floor cannot separate those two. That is the general lesson of abstention: the threshold has to be tuned on labelled questions, and the prompt must still let the model decline when retrieval lets junk through.
Step 5: assembling the prompt
build_prompt() is context assembly. Each chunk carries a stable id the model must cite, the instructions restrict it to the sources, and it is given an exact phrase for declining, which makes declines easy to count in logs. Keep the ids stable across re-indexing (document path plus chunk number, or a content hash) or yesterday's citations stop resolving.
Step 6: tests before swapping anything
Five tests pin the behaviour you care about: sentence-aligned chunks, exactly one sentence of overlap, oversized sentences handled, a known question finding its source, and an unanswerable question abstaining. Save next to rag.py and run pytest -q; it also runs as python3 test_rag.py with no installs.
# test_rag.py: run with "pytest -q" next to rag.py (or "python3 test_rag.py").
from rag import DOCS, build_index, build_prompt, chunk, retrieve, split_sentences
def test_every_chunk_starts_on_a_sentence_boundary() -> None:
for doc_id, text in DOCS.items():
starts = {s.split()[0] for s in split_sentences(text)}
for c in chunk(doc_id, text, max_words=25):
assert c.text.split()[0] in starts, c.id
def test_overlap_repeats_exactly_one_whole_sentence() -> None:
first, second = chunk("rate-limits.md", DOCS["rate-limits.md"], max_words=25)[:2]
shared = split_sentences(first.text)[-1]
assert second.text.startswith(shared)
def test_oversized_sentence_becomes_its_own_chunk() -> None:
long_sentence = " ".join(["word"] * 40) + "."
pieces = chunk("x.md", long_sentence + " Short one.", max_words=25)
assert [p.text for p in pieces] == [long_sentence, "Short one."]
def test_known_question_retrieves_its_source() -> None:
hits = retrieve(build_index(DOCS), "How long are failed webhook deliveries retried?")
assert hits and hits[0][1].id.startswith("webhooks.md")
def test_unanswerable_question_abstains() -> None:
hits = retrieve(build_index(DOCS), "Do you support SAML?")
assert hits == [] and build_prompt("Do you support SAML?", hits) is None
if __name__ == "__main__":
for name, fn in list(globals().items()):
if name.startswith("test_"):
fn()
print("ok", name)The last two tests are a miniature golden set. Grow them to 50 real questions with known source chunks and the pass rate becomes recall@k, the number every later change has to move.
Step 7: the same pipeline with a real embedding model and LLM
This version needs network access and an ANTHROPIC_API_KEY; it will not run offline. It reuses chunk(), DOCS and build_prompt() unchanged and replaces the two seams: TF-IDF becomes BAAI/bge-small-en-v1.5 (33M parameters, 384 dimensions, comfortable on a laptop CPU), and the stub becomes a Claude call through the Anthropic Python SDK.
# rag_real.py: needs network, an API key and two packages. Not runnable offline.
# pip install "anthropic==1.13.0" "sentence-transformers==3.3.1"
# export ANTHROPIC_API_KEY=... (never hard-code it)
from __future__ import annotations
import anthropic
from sentence_transformers import SentenceTransformer
from rag import DOCS, Chunk, build_prompt, chunk
EMBEDDER = SentenceTransformer("BAAI/bge-small-en-v1.5") # 33M params, 384 dims, runs on CPU
QUERY_PREFIX = "Represent this sentence for searching relevant passages: " # BGE's query instruction
CLIENT = anthropic.Anthropic(timeout=30.0, max_retries=3) # reads ANTHROPIC_API_KEY; retries 429/5xx with backoff
MODEL = "claude-opus-5-5"
def build_dense_index(docs: dict[str, str]) -> tuple[list[Chunk], list[list[float]]]:
chunks = [c for doc_id, text in docs.items() for c in chunk(doc_id, text)]
vectors = EMBEDDER.encode([c.text for c in chunks], normalize_embeddings=True)
return chunks, vectors.tolist()
def retrieve_dense(chunks: list[Chunk], vectors: list[list[float]], question: str,
k: int = 2, min_score: float = 0.55) -> list[tuple[float, Chunk]]:
q = EMBEDDER.encode([QUERY_PREFIX + question], normalize_embeddings=True)[0].tolist()
# Unit-length vectors, so the dot product IS the cosine similarity.
scored = sorted(((sum(a * b for a, b in zip(q, v)), c) for v, c in zip(vectors, chunks)),
key=lambda pair: pair[0], reverse=True)
return [(s, c) for s, c in scored[:k] if s >= min_score] # tune the floor on a golden set
def call_llm(prompt: str) -> str:
response = CLIENT.messages.create(
model=MODEL,
max_tokens=4096,
output_config={"effort": "low"}, # short grounded answers do not need deep thinking
messages=[{"role": "user", "content": prompt}],
)
if response.stop_reason == "max_tokens":
raise RuntimeError("answer truncated: raise max_tokens rather than ship half an answer")
if response.stop_reason == "refusal":
return "I can't help with that one."
return "".join(block.text for block in response.content if block.type == "text")
def main() -> None:
chunks, vectors = build_dense_index(DOCS)
for question in ["What happens if I go over my request quota?", "Do you support SAML?"]:
hits = retrieve_dense(chunks, vectors, question)
prompt = build_prompt(question, hits)
print("Q:", question)
print(" ->", "I don't know." if prompt is None else call_llm(prompt))
if __name__ == "__main__":
main()Four details in it are the difference between a demo and something you can operate:
- Pinned versions and an env-var key. SDK minor versions change response shapes; the key never appears in source control.
- Timeout and retries.
timeout=30.0bounds a hung connection;max_retries=3retries 429 and 5xx responses with exponential backoff inside the SDK, so your code only sees errors that survived four attempts. - Stop-reason check.
max_tokensmeans the answer was cut off mid-sentence and must not be shown as complete;refusalmeans the model declined. Readingcontentwithout checking ships both bugs. - A different floor. Dense cosine scores for unrelated text rarely drop near zero; with BGE models they bunch between roughly 0.3 and 0.9. The 0.55 here is a starting guess. Re-tune it on your golden set every time you change embedding model.
Key takeaways
- Overlap must respect the same boundaries as the chunks themselves; a sentence-aligned overlap costs a duplicated sentence, a word-aligned one costs coherence.
- The two seams worth designing for are vectorisation and generation; if chunking, prompt assembly and ids stay fixed, swapping either becomes a measurable experiment.
- A score floor is a model-specific calibration, not a constant: 0.05 for this TF-IDF, ~0.55 to start for BGE, and either can be fooled by one shared word.
- Write the tests before the swap, so “the real model is better” is a recall@k number rather than an impression.
Sources
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — Lewis et al., 2020paper
- The Probabilistic Relevance Framework: BM25 and Beyond — Robertson & Zaragoza, 2009paper
- C-Pack: Packaged Resources To Advance General Chinese Embedding (BGE models) — Xiao et al., 2023paper
- Tool use with Claude / Messages API docs — Anthropic, 2026docs