Implementation Guides
Building a RAG System from Scratch
A complete step-by-step guide to building a production-ready Retrieval-Augmented Generation system. Covers document processing, chunking, embeddings, vector stores, hybrid retrieval with Cohere Rerank 4, cited generation, and evaluation.

Gabriele Masetti ·
Architecture
A production RAG system has five moving parts: an ingestion pipeline that turns raw documents into searchable chunks, an embedding model that maps text to vectors, a vector store that indexes those vectors for approximate nearest-neighbor (ANN) search, a retrieval-plus-rerank layer that narrows candidates down to the few passages worth showing the model, and a generation step that assembles a prompt and produces a cited answer. Evaluation sits alongside all of it, not after it — you need a way to measure retrieval quality independently from generation quality, because a system can fail at either stage for unrelated reasons.
The data flow is:
documents -> loaders -> chunker -> embedder -> vector store (index)
|
query -> embedder -> ANN search (top-k) -> reranker -> top-n -> prompt -> LLM -> answer + citations
Treat ingestion and query-time retrieval as separate services from day one. Ingestion is a batch/streaming job you run on a schedule or on webhook triggers when source documents change; retrieval is a low-latency read path. Conflating them is the single biggest reason RAG prototypes don't survive contact with production traffic.
Ingestion and Chunking
Start with document loading. For PDFs, pypdf or the unstructured library (which also handles DOCX, HTML, PPTX, and scanned images with OCR fallback) give you clean text plus per-page/per-element metadata. Preserve source metadata (document ID, page number, section heading, URL, last-modified date) at load time — you cannot cheaply recover it later, and you need it for citations and for filtered retrieval (e.g., "only search docs updated after X").
Chunking is the highest-leverage decision in the whole pipeline. Two failure modes dominate: chunks too large dilute the embedding (the vector represents an average of several ideas, so semantic search gets fuzzy), and chunks too small lose the surrounding context the LLM needs to answer correctly. RecursiveCharacterTextSplitter from langchain_text_splitters is a reasonable default — it splits on paragraph breaks first, then sentences, then words, only falling back to hard character cuts when a paragraph itself exceeds chunk_size:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # characters, not tokens
chunk_overlap=120, # preserves context across chunk boundaries
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(document_text)
For technical or structured content (API docs, legal contracts, code), prefer structure-aware splitting: split on Markdown headers or HTML tags first, then recursively split any section that's still too large. That keeps a chunk's retrieved context aligned with a coherent unit (one function, one clause, one FAQ entry) rather than an arbitrary character window. A chunk size of 500–1,000 characters (roughly 150–300 tokens) with 10–20% overlap is a solid starting point for prose; tune it against your own eval set rather than trusting a blog-post default.
Attach stable, deterministic chunk IDs (hash of source ID + offset) so re-ingesting an unchanged document doesn't create duplicate vectors, and so you can delete/replace exactly the chunks belonging to an updated source document.
Embeddings
OpenAI's text-embedding-3-small (1,536 dimensions, $0.02 per 1M input tokens) and text-embedding-3-large (3,072 dimensions, $0.13 per 1M input tokens) are the standard hosted option; both support the Matryoshka representation trick — pass a dimensions parameter to get a shorter, still-useful vector (e.g., 256 or 512) and cut storage/search cost with a small recall hit.
Those are list prices for synchronous calls; routing ingestion through the Batch API halves them, which is worth doing for the first full index of a corpus — the largest single embedding bill you will pay.
If you need to keep embeddings on your own infrastructure or avoid per-call cost, open models from the sentence-transformers ecosystem — BAAI/bge-small-en-v1.5 or sentence-transformers/all-MiniLM-L6-v2 — run locally on CPU or a small GPU and are competitive on English retrieval benchmarks.
| Component | Spec |
|---|---|
| text-embedding-3-small | 1,536 dims, $0.02 per 1M input tokens |
| text-embedding-3-large | 3,072 dims, $0.13 per 1M input tokens |
| Cohere rerank-v4.0-pro / -fast | 32K-token context, multilingual |
from openai import OpenAI
client = OpenAI()
def embed(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
resp = client.embeddings.create(model=model, input=texts)
return [d.embedding for d in resp.data]
Batch embedding calls — send 100–500 chunks per request rather than one at a time, both models accept batched input lists — and cache the results keyed by chunk ID so re-runs of the ingestion job skip unchanged chunks. Whatever model you pick for ingestion, you must use the exact same model (and dimensions setting) for queries at retrieval time; embeddings from different models are not comparable.
Vector Store
For most teams already running Postgres, pgvector is the pragmatic choice — one fewer system to operate, transactional consistency with your existing metadata tables, and it scales to tens of millions of vectors with an HNSW index:
CREATE TABLE chunks (
id UUID PRIMARY KEY,
document_id UUID NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
embedding VECTOR(1536)
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
HNSW builds incrementally (no separate training pass, unlike IVFFlat) and gives the best recall/latency tradeoff for read-heavy workloads, at the cost of higher memory and slower inserts — fine for most RAG corpora, which are read-dominated. If you outgrow a single Postgres instance or need a fully managed, horizontally-scaled ANN service, purpose-built stores (Chroma for local/embedded use, or a hosted vector database) are worth the migration.
Chroma is the fastest path to a working local prototype:
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs")
collection.add(ids=chunk_ids, embeddings=chunk_vectors,
documents=chunk_texts, metadatas=chunk_metadata)
results = collection.query(query_embeddings=[query_vector], n_results=20)
FAISS (faiss-cpu/faiss-gpu) is the library to reach for when you want raw ANN performance and are willing to manage persistence and metadata storage yourself — it's a search index, not a database:
import faiss
index = faiss.IndexHNSWFlat(1536, 32) # d=1536, M=32
index.hnsw.efConstruction = 64
index.hnsw.efSearch = 64
index.add(vectors) # normalize vectors first for cosine similarity
distances, ids = index.search(query_vector.reshape(1, -1), k=20)
Whichever store you pick, always retrieve metadata filters alongside the vector search (document type, date range, access control tags) — pure semantic search with no filtering is how RAG systems leak content across tenants or surface stale documents ranked above current ones.
Retrieval and Reranking
Retrieve more than you plan to use. Pull the top 15–30 candidates by vector similarity (or hybrid: vector + BM25/keyword search, especially important for exact terms like product SKUs, error codes, or names that embeddings smear together), then rerank down to the 3–8 chunks that actually go in the prompt. A rerank step consistently outperforms retrieving directly at high precision, because a cross-encoder scores the query and passage jointly (full attention across both), whereas the bi-encoder embeddings used for the initial ANN search compress each text independently and lose fine-grained interaction signal.
Two practical reranking options. The first is Cohere's hosted Rerank 4, announced on 11 December 2025 and split into two models you choose between: rerank-v4.0-pro when you want the accuracy and rerank-v4.0-fast when you want the latency. Both are multilingual and take a 32K-token context, eight times the 4,096 tokens of the rerank-v3.5 generation they replace, which means a long chunk no longer has to be truncated before the cross-encoder sees it. The second is a local cross-encoder from sentence-transformers such as cross-encoder/ms-marco-MiniLM-L6-v2, if you need to keep reranking in-house:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
pairs = [(query, c["content"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_chunks = [c for c, _ in ranked[:5]]
Hybrid search plus rerank is the combination that matters most in practice: vector search alone typically misses exact-match queries, and rerank alone is too slow to run over an entire corpus, so use ANN (or ANN + keyword, fused with reciprocal rank fusion) to get a fast, high-recall candidate set, then rerank for precision.
Generation with Citations
Assemble the prompt so the model can only answer from what you gave it, and so every claim can be traced back to a source chunk. Number the retrieved chunks and instruct the model to cite by number; store the chunk-ID-to-source mapping alongside the generation call so you can turn [2] into a real document link in the UI.
def build_prompt(query: str, chunks: list[dict]) -> str:
context = "\n\n".join(
f"[{i+1}] (source: {c['document_id']}, p.{c['page']})\n{c['content']}"
for i, c in enumerate(chunks)
)
return f"""Answer the question using ONLY the numbered sources below.
Cite sources inline as [1], [2], etc. If the sources don't contain
the answer, say so explicitly instead of guessing.
Sources:
{context}
Question: {query}
Answer:"""
Send this to a current frontier chat model (or a comparable instruction-tuned open-weight model) with a low temperature (0–0.3) for factual QA — you want consistency, not creativity, here. Post-process the response to validate that every [n] citation actually exists in the numbered source list, and reject or regenerate answers that cite nothing when the question clearly required grounding. Keep the system prompt strict about refusing to answer outside the provided context — this single instruction is what prevents a RAG system from quietly reverting to the model's parametric (and unverifiable) knowledge when retrieval comes back thin.
Evaluation
Evaluate retrieval and generation separately — a low-quality answer with perfect retrieval is a prompting/generation bug, and a good answer built on the wrong sources is a retrieval bug, and conflating the two by only eyeballing final answers makes both nearly impossible to fix.
Build a labeled eval set of 50–200 realistic queries with the correct source chunk(s) identified by hand. Track standard IR metrics on the retrieval step: recall@k (did the right chunk make it into the top k?) and MRR (how high did it rank?). For end-to-end quality, RAGAS is the most widely used open-source framework and implements the metrics that matter most with an LLM-as-judge:
- Faithfulness — does the generated answer only contain claims supported by the retrieved context, or did the model add unsupported content?
- Answer relevancy — does the answer actually address the question asked?
- Context precision — of the chunks retrieved, how many were actually relevant?
- Context recall — of the chunks that were needed, how many did retrieval surface?
Run this eval suite on every change to chunking strategy, embedding model, or prompt template — these four numbers catch regressions that manual spot-checking reliably misses, especially context precision drops from over-broad top-k retrieval.
Common Pitfalls
- Chunk size chosen once and never revisited. The right size depends on your document structure and query style; treat it as a hyperparameter and tune it against your eval set, not a default copied from a tutorial.
- Embedding model mismatch between ingestion and query time. Vectors from different models (or different
dimensionssettings) are not comparable — a silent model upgrade on one side of the pipeline degrades recall without throwing an error. - No hybrid search. Pure dense retrieval underperforms on exact-match queries (IDs, codes, proper nouns) that keyword search handles trivially; skipping BM25/keyword fusion costs you an easy precision win.
- Skipping reranking to save latency. A rerank pass over 20–30 candidates typically adds well under a second and measurably improves the precision of what reaches the prompt — cutting it to save that time usually costs more in wrong answers.
- No re-ingestion strategy for updated documents. Without stable chunk IDs and a delete-then-reinsert (or upsert) path keyed on source document version, stale and current chunks both stay in the index and compete for retrieval slots.
- Citations that aren't verified. If the model can label a claim
[2]without your code checking that source 2 actually supports it, citations become decoration rather than a trust mechanism — validate the mapping post-generation. - Evaluating only the final answer. Without separate retrieval metrics (recall@k, context precision/recall), a generation-quality dip and a retrieval-quality dip look identical from the outside and get debugged in the wrong layer.
- No metadata filtering at retrieval time. Semantic similarity alone doesn't respect document permissions, tenancy, or recency — bake filters into the vector query, not into post-hoc result filtering after the top-k is already fixed.