From toy RAG to a real one: embeddings, a vector store and an LLM
Take the stdlib pipeline from the first RAG post and swap in a real Embedding model, a persistent vector store and an LLM, with pinned versions, timeouts, retries, batching and tests.
The first RAG post in this handbook built a complete pipeline in pure Python: TF-IDF vectors, cosine scoring and a stubbed model call. It is honest about its limits. This post replaces each toy part with the real one and adds the plumbing that separates a demo from something you can leave running: pinned dependencies, keys from the environment, timeouts, retries that only retry what should be retried, batched embedding calls, an index that survives a restart, and tests that run without a network.
What changes, part by part
| Toy | Production | Why |
|---|---|---|
| tfidf_vector() | Embedding API | Matches meaning, not words |
| List of dicts | Chroma on disk | Persists; HNSW index |
| score > 0 | Distance threshold | Dense scores are never 0 |
| call_llm() stub | Anthropic Messages API | Real answers |
| Runs once | Content-hash ids | Re-runs skip done work |
| No tests | pytest, no network | Safe to refactor |
The concrete choices: OpenAI's text-embedding-3-small for embeddings (1,536 dimensions), Chroma as a local vector store (it builds an HNSW index for approximate nearest-neighbour search), and Claude through the Anthropic SDK for generation. Each is one function behind a seam, so swapping any of them later is a small change. As of Oct 2026 all three are mainstream defaults for a first production build; pgvector inside an existing Postgres is the usual next step when you need transactions or metadata filters joined to business data.
Pin versions and keep keys out of code
SDKs for model providers release often, sometimes weekly, and occasionally rename things. Pin exact versions so the code you tested is the code that runs, and upgrade deliberately with the tests below as your safety net.
# requirements.txt: exact pins, upgraded on purpose (as of Oct 2026)
openai==3.20.0
anthropic==1.13.0
chromadb==1.5.5
pytest==9.0.2python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export OPENAI_API_KEY="..." # from your secrets manager, never committed
export ANTHROPIC_API_KEY="..."The offline core: everything that can be tested without a network
Split the code in two. rag_core.py holds the logic that does not need a network: chunking, stable ids, batching, retry policy, the relevance threshold and the prompt. It is standard-library only, so it runs with python3 -I rag_core.py and its tests run in milliseconds. rag_prod.py holds the three network calls and nothing clever.
"""Offline, stdlib-only helpers for the production RAG port. Run: python3 -I rag_core.py"""
import hashlib
import random
import re
import time
def chunk(doc_id, text, max_words=200, overlap=40):
"""Sentence-aware packing, same idea as the toy, with production-sized defaults."""
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
chunks, current = [], []
for sentence in sentences:
words = sentence.split()
if current and len(current) + len(words) > max_words:
chunks.append(" ".join(current))
current = current[-overlap:]
current.extend(words)
if current:
chunks.append(" ".join(current))
return [
{"id": chunk_id(doc_id, c), "doc_id": doc_id, "n": i, "text": c}
for i, c in enumerate(chunks)
]
def chunk_id(doc_id, text, model="text-embedding-3-small"):
"""Stable id: same doc + same text + same embedding model -> same id, so re-runs skip work."""
digest = hashlib.sha256(f"{model}|{doc_id}|{text}".encode()).hexdigest()
return digest[:32]
def batched(items, size):
"""Yield lists of at most `size` items (itertools.batched needs 3.12+)."""
for start in range(0, len(items), size):
yield items[start:start + size]
def with_retries(fn, retryable, attempts=5, base=0.5, cap=20.0, sleep=time.sleep):
"""Call fn(); on a retryable exception wait base*2^n (+ jitter, capped) and try again."""
for attempt in range(attempts):
try:
return fn()
except retryable:
if attempt == attempts - 1:
raise
delay = min(cap, base * 2 ** attempt)
sleep(delay / 2 + random.uniform(0, delay / 2))
def select_hits(ids, docs, distances, max_distance=0.6):
"""Keep only hits closer than the threshold. Cosine distance = 1 - cosine similarity."""
return [
{"id": i, "text": d, "distance": round(dist, 3)}
for i, d, dist in zip(ids, docs, distances)
if dist <= max_distance
]
def build_prompt(question, hits):
if not hits:
return None
sources = "\n".join(f"[{h['id'][:8]}] {h['text']}" for h in hits)
return (
"Answer using ONLY the sources below. Cite source ids in brackets. "
"If the sources do not contain the answer, say you don't know.\n\n"
f"Sources:\n{sources}\n\nQuestion: {question}"
)
if __name__ == "__main__":
text = ("The API rate limit is 600 requests per minute per key. " * 3
+ "Keys are rotated every 90 days by the platform team.")
parts = chunk("dev-docs/limits.md", text, max_words=20, overlap=5)
print(len(parts), "chunks; first id", parts[0]["id"][:8])
print([len(b) for b in batched(list(range(250)), 100)])
calls = {"n": 0}
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise TimeoutError("simulated timeout")
return "ok"
print(with_retries(flaky, (TimeoutError,), sleep=lambda s: None), "after", calls["n"], "calls")
hits = select_hits(["a" * 32, "b" * 32], ["Rate limit is 600 rpm.", "Office hours."], [0.21, 0.74])
print(build_prompt("What is the rate limit?", hits))Running it prints:
4 chunks; first id 40006139
[100, 100, 50]
ok after 3 calls
Answer using ONLY the sources below. Cite source ids in brackets. If the sources do not contain the answer, say you don't know.
Sources:
[aaaaaaaa] Rate limit is 600 rpm.
Question: What is the rate limit?Four decisions in that file are worth defending:
- Chunk size 200 words, overlap 40. The toy used 20 words so you could see the mechanics. Real chunks need enough surrounding context to answer from; 150 to 300 words with 15–20% overlap is a sound starting point to tune against your own evaluation.
- Ids are a content hash of the embedding model, document and text. Re-running indexing on unchanged documents finds every id already stored and embeds nothing. Changing the embedding model changes every id, so old and new vectors are never mixed.
- Retries are explicit and tested.
with_retriesuses exponential backoff with jitter, and only retries the exception types you pass. Retrying a malformed request just fails five times more slowly. - The threshold replaces
score > 0. With dense embeddings every chunk has some similarity, so the toy's zero check would never trigger abstention.select_hitsdrops anything with cosine distance above 0.6. Treat 0.6 as a placeholder: set it from your golden set by finding the distance above which retrieved chunks are almost never relevant.
The network layer
This file needs both API keys and makes real, billed calls. It is syntactically complete and was import-checked against the pinned versions, but you need your own keys to run it.
"""Production port of the toy pipeline. Needs OPENAI_API_KEY and ANTHROPIC_API_KEY set."""
import os
import pathlib
import sys
import anthropic
import chromadb
import openai
from rag_core import batched, build_prompt, chunk, select_hits, with_retries
EMBED_MODEL = os.environ.get("RAG_EMBED_MODEL", "text-embedding-3-small")
LLM_MODEL = os.environ.get("RAG_LLM_MODEL", "claude-opus-5-5")
INDEX_DIR = os.environ.get("RAG_INDEX_DIR", ".rag_index")
EMBED_BATCH = 128
# max_retries=0: retries happen in with_retries, once, where we can see and test them.
oai = openai.OpenAI(timeout=30.0, max_retries=0)
llm = anthropic.Anthropic(timeout=60.0, max_retries=0)
store = chromadb.PersistentClient(path=INDEX_DIR)
collection = store.get_or_create_collection(
name=f"docs-{EMBED_MODEL}", metadata={"hnsw:space": "cosine"}
)
OPENAI_RETRYABLE = (openai.RateLimitError, openai.APITimeoutError,
openai.APIConnectionError, openai.InternalServerError)
ANTHROPIC_RETRYABLE = (anthropic.RateLimitError, anthropic.APITimeoutError,
anthropic.APIConnectionError, anthropic.InternalServerError)
def embed(texts):
vectors = []
for batch in batched(texts, EMBED_BATCH):
resp = with_retries(
lambda: oai.embeddings.create(model=EMBED_MODEL, input=batch),
OPENAI_RETRYABLE,
)
vectors.extend(item.embedding for item in sorted(resp.data, key=lambda d: d.index))
return vectors
def index_folder(folder, embed_fn=embed):
pieces = []
for path in sorted(pathlib.Path(folder).glob("**/*.md")):
pieces.extend(chunk(str(path), path.read_text(encoding="utf-8")))
if not pieces:
return 0
known = set(collection.get(ids=[p["id"] for p in pieces])["ids"])
new = [p for p in pieces if p["id"] not in known]
for batch in batched(new, EMBED_BATCH):
collection.upsert(
ids=[p["id"] for p in batch],
embeddings=embed_fn([p["text"] for p in batch]),
documents=[p["text"] for p in batch],
metadatas=[{"doc_id": p["doc_id"], "n": p["n"]} for p in batch],
)
return len(new)
def retrieve(question, k=5, embed_fn=embed):
res = collection.query(query_embeddings=embed_fn([question]), n_results=k)
return select_hits(res["ids"][0], res["documents"][0], res["distances"][0])
def answer(question):
prompt = build_prompt(question, retrieve(question))
if prompt is None:
return "I don't know: nothing in the index is close enough to answer that."
msg = with_retries(
lambda: llm.messages.create(
model=LLM_MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
),
ANTHROPIC_RETRYABLE,
)
return "".join(b.text for b in msg.content if b.type == "text")
if __name__ == "__main__":
if sys.argv[1:2] == ["index"]:
print("embedded", index_folder(sys.argv[2]), "new chunks")
else:
print(answer(" ".join(sys.argv[1:])))# Needs both API keys. Index a folder of Markdown files, then ask.
python rag_prod.py index ./dev-docs
python rag_prod.py "What is the API rate limit per key?"Walk through what it adds over the toy.
Timeouts on every client. Without one, a hung connection can hold a worker indefinitely. 30 seconds for embeddings and 60 for generation are generous; tighten them once you have latency data, and remember that with retries the worst case is roughly timeout × attempts.
One retry layer, not two. Both SDKs retry by default. Setting max_retries=0 and wrapping calls in with_retries means there is exactly one retry policy, in code you have tested. Stacking the SDK's retries on top of your own multiplies attempts: 3 × 5 = 15 calls against a provider that is already struggling, which is how a rate limit turns into an outage.
Batched embeddings. One request per chunk is slow and burns request-rate quota. embed() sends 128 texts per call and re-sorts results by index, so vectors can never be attached to the wrong chunk. The embeddings endpoint accepts far larger batches; 128 keeps each request small enough that one retry is cheap.
A persistent, idempotent index. PersistentClient writes to .rag_index/, so a restart does not re-embed anything. index_folder asks the store which ids it already has and embeds only the new ones. Run it twice and the second run reports zero new chunks; that is idempotency, and it is what makes a crashed indexing job safe to simply run again.
The collection name includes the model. Vectors from different embedding models live in different spaces and cannot be compared. Naming the collection docs-text-embedding-3-small makes it impossible to query new vectors against an old index by accident.
The cost and size, worked
Assume 10,000 documents of 2,000 tokens each, so 20 million tokens, and an embedding list price of $0.02 per million tokens (illustrative, Oct 2026; check current pricing). Embedding the raw text once would cost about $0.40. With 200-word chunks and 40 words of overlap, each chunk advances about 160 words (≈ 215 tokens), so roughly 93,000 chunks, and the overlap adds about a quarter to the embedded tokens: call it $0.50. Each vector is 1,536 float32 numbers, 6 KB, so the vectors take about 570 MB before index overhead. Generation dominates the running cost: each answer sends five chunks plus the question, around 1,500 input tokens, to the LLM.
Tests that run without a network
These test the decisions above, not the providers. They need only pytest and finish in well under a second.
import pytest
from rag_core import batched, build_prompt, chunk, chunk_id, select_hits, with_retries
DOC = "Keys rotate every 90 days. " * 30
def test_chunks_overlap_and_ids_are_stable():
a = chunk("wiki/keys.md", DOC, max_words=40, overlap=10)
b = chunk("wiki/keys.md", DOC, max_words=40, overlap=10)
assert len(a) > 1
assert [c["id"] for c in a] == [c["id"] for c in b]
assert a[0]["text"].split()[-10:] == a[1]["text"].split()[:10]
def test_id_changes_with_embedding_model():
assert chunk_id("d", "t", model="m1") != chunk_id("d", "t", model="m2")
def test_batched_never_exceeds_size():
assert [len(b) for b in batched(list(range(257)), 128)] == [128, 128, 1]
def test_retry_recovers_from_transient_errors():
calls = []
def flaky():
calls.append(1)
if len(calls) < 3:
raise TimeoutError
return "ok"
assert with_retries(flaky, (TimeoutError,), sleep=lambda s: None) == "ok"
assert len(calls) == 3
def test_retry_does_not_swallow_permanent_errors():
def bad_request():
raise ValueError("400: bad input")
with pytest.raises(ValueError):
with_retries(bad_request, (TimeoutError,), sleep=lambda s: None)
def test_retry_gives_up_after_attempts():
def always_down():
raise TimeoutError
with pytest.raises(TimeoutError):
with_retries(always_down, (TimeoutError,), attempts=3, sleep=lambda s: None)
def test_far_hits_are_dropped_and_empty_means_abstain():
hits = select_hits(["x" * 32], ["unrelated"], [0.9], max_distance=0.6)
assert hits == []
assert build_prompt("q", hits) is Nonepython -m pytest -q test_rag_core.py
# 7 passedFor the network layer, add one smoke test that is skipped unless both keys are set, and run it before each dependency upgrade. index_folder and retrieve already accept an embed_fn, so you can also test indexing and querying against a real local Chroma with a fake embedder.
What this still does not do
- Deletions. A removed or edited document leaves its old chunks behind. Track ids per document and delete stale ones on re-index; the general pattern is a tombstone plus a periodic reconciliation pass.
- Permissions. Every user sees every chunk. Store an access list in metadata and filter on it in the query, never after it.
- Exact-term search. Dense retrieval is weak on codes like
ERR-4012. Add BM25 and merge with RRF when your error analysis shows those misses. - Measurement. Run the golden-set script from the evaluation post and track recall@5 before and after this port. If the number did not go up, the port did not help yet.
Key takeaways
- Keep a stdlib core (chunking, ids, batching, retries, threshold, prompt) that is testable offline, and a thin network layer behind seams.
- Pin SDK versions, read keys from the environment, set timeouts on every client, and keep exactly one retry layer that retries only transient errors.
- Content-hash ids that include the embedding model make indexing idempotent and prevent mixing vector spaces.
- Dense similarity is never zero, so abstention needs a distance threshold set from your own golden set.
- The port is done when recall@k on your evaluation set has moved, not when the code runs.
Sources
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — Lewis et al., 2020paper
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs — Malkov & Yashunin, 2016paper
- Vector embeddings guide — OpenAI, 2024docs
- Chroma documentation — Chroma, 2025docs