Lakshya

LLM Systems Engineering · Chapter 12 of 20

A RAG system, start to finish

The two pipelines, a working system in forty lines, the tools you would actually choose between, and the two upgrades that move the needle.

14 min read2 diagramsAll 20 chapters

One chapter covers what retrieval fixes and what it does not; another covers index internals. This one builds one, then covers the tool choices and the two upgrades that separate a demo from a system. The next chapter is the enterprise half — permissions, real data sources, and buy-versus-build.

TWO PIPELINES, NOT ONE — AND CONFLATING THEM IS THE FIRST DESIGN MISTAKE INGESTION — runs on a schedule. Slow, batched, expensive, and nobody is waiting. LOADPDFs, wiki, ticketsSPLITinto chunksEMBEDchunk → numbersSTORE+ metadata + ACLsINDEXso search is fast Get this wrong and no amount of clever prompting at query time will recover it. Most bad RAG is bad ingestion. QUERY — runs per question. Fast, and somebody IS waiting. Budget: a few hundred milliseconds. EMBEDthe questionSEARCH+ filter by userRERANKtop 50 → top 5ASSEMBLEthe promptGENERATEwith citations The only step here that touches a language model is the last one. RAG is a search problem wearing an AI hat. SO WHEN THE ANSWERS ARE BAD, CHECK IN THIS ORDER: was the right chunk retrieved at all? If no, it is a search problem and the model cannot fix it. If yes and the answer is still wrong, only then is it a prompt or modelproblem.
Fix this split in your head first. Nearly every confusing RAG conversation is two people talking about different pipelines.

The smallest thing that works

Before any framework, here is a complete working system. It is worth typing out, because it makes clear how little RAG actually is.

pythona complete RAG system — Postgres, pgvector, a local model
# One-time: Postgres with the pgvector extension
#   CREATE EXTENSION vector;
#   CREATE TABLE chunks (
#     id bigserial PRIMARY KEY,
#     doc_id text, source text, chunk text,
#     embedding vector(1024)
#   );

import psycopg, ollama
db = psycopg.connect("postgresql://localhost/kb")

def embed(text):
    return ollama.embed(model="bge-m3", input=text)["embeddings"][0]

# ---- INGEST (runs on a schedule) --------------------------------
for doc_id, source, chunk in load_and_split_your_documents():
    db.execute("INSERT INTO chunks (doc_id, source, chunk, embedding)"
               " VALUES (%s, %s, %s, %s)",
               (doc_id, source, chunk, embed(chunk)))
db.commit()

# ---- QUERY (runs per question) ----------------------------------
def answer(question):
    rows = db.execute(
        "SELECT chunk, source FROM chunks"
        " ORDER BY embedding <=> %s::vector"      # <=> is cosine distance
        " LIMIT 5", (embed(question),)).fetchall()

    context = "\n\n".join(f"[{i+1}] (source: {s})\n{c}"
                           for i, (c, s) in enumerate(rows))

    return ollama.chat(model="qwen3:8b", messages=[
        {"role": "system", "content":
         "Answer using ONLY the numbered sources below. Cite them as [1], [2]. "
         "If the sources do not contain the answer, say so and stop. Do not guess."},
        {"role": "user", "content": f"{context}\n\nQuestion: {question}"},
    ])["message"]["content"]

Three things in that system-message are doing real work

“ONLY the numbered sources” — without it the model will happily blend retrieved text with what it recalls from training, and you cannot tell which you got.

“If the sources do not contain the answer, say so and stop” — giving it an approved way to fail is the difference between a system that says ‘not in the documents’ and one that invents a plausible policy.

Numbered sources with a visible label — citations you can check. If the model cannot cite it, you cannot verify it.

None of this makes grounding guaranteed. It makes it likely, and it makes failures visible, which is the most you get.

Chunking — the decision made in five minutes and regretted for months

Documents are split because you cannot paste a 200-page manual into every request, and because retrieval works better on focused passages. How you split is the highest-leverage decision in the whole pipeline and it is usually made by accepting a default.

  • Fixed size with overlap — e.g. 800 characters with 100 of overlap. Crude, and a reasonable place to start. The overlap exists so a sentence spanning a boundary survives in at least one chunk.
  • Structural — split on headings, sections, or clause numbers. Almost always better when the documents have structure, because a section is a unit of meaning and 800 characters is not.
  • Per-record — one chunk per ticket, per FAQ entry, per product. When your data is already records, do not chop them up. This is the commonest missed opportunity.
  • Small chunks, wider context at retrieval — index small precise passages, then expand to the surrounding section before passing to the model. Best of both, and worth the extra code.

The check that matters: take twenty real questions and ask whether the answer to each sits inside a single chunk. If an answer routinely spans two, your chunks are too small or split in the wrong place, and no amount of retrieval tuning will fix it.

pythoncapture metadata at ingest — retrofitting it means reindexing everything
# Structural chunking, with the metadata that makes filtering possible later
import re

def split_by_heading(text, doc_id, source, max_chars=1500):
    sections = re.split(r"\n(?=#{1,3}\s)", text)     # split before markdown headings
    for sec in sections:
        heading = sec.split("\n", 1)[0].lstrip("# ").strip()
        # a long section still needs splitting -- but now every piece knows
        # which section it came from, which is what makes citations useful
        for i in range(0, len(sec), max_chars):
            yield {
                "doc_id":  doc_id,
                "source":  source,
                "heading": heading,
                "chunk":   sec[i:i + max_chars],
                # capture these AT INGEST. Adding them later means a full re-index.
                "updated_at":  file_mtime(source),
                "acl_groups":  groups_that_can_read(source),
            }

Capture metadata at ingestion time even if you have no use for it yet.

Source path, section heading, last-modified date, document owner, and who is allowed to read it.

Adding a field later means re-embedding and re-indexing the entire corpus — hours or days of compute, and a migration. Adding an empty column now costs nothing.

The access-control field in particular: the next chapter is largely about why retrofitting that one is the difference between a two-week project and a six-month one.

For a worked example against a real source — a chatbot over Confluence, using the REST API (the ordinary web interface Confluence exposes) to index, a webhook to keep it fresh, and an MCP (Model Context Protocol) tool for live lookups — see APIs, webhooks and MCP.

The tools, by layer

“Which RAG tool should we use?” is four questions. They are independent, and treating them as one is why tool comparisons are so unhelpful.

LayerOptionsHow to choose
Embedding modelbge-m3, E5, Nomic (open, self-hosted) · OpenAI, Cohere, Voyage (hosted)Start with a good open one. Changing it later means re-embedding everything, so treat it as a schema decision. Check it handles your languages — many are English-first.
Vector storepgvector · Qdrant, Weaviate, Milvus · Pinecone · Elasticsearch / OpenSearchIf you already run Postgres, use pgvector. See below.
Rerankerbge-reranker (open) · Cohere Rerank, Voyage (hosted)Add one. It is usually the cheapest large accuracy gain available.
FrameworkLangChain, LlamaIndex, Haystack · or noneHonestly assessed below.

On the vector store, plainly

pgvector puts vectors in the Postgres you already run. Your documents, metadata, permissions and embeddings sit in one database, so you can filter and join with ordinary SQL and get transactional consistency for free. For most systems under a few million chunks this is simply the right answer, and the strongest argument is the one nobody puts in a benchmark: it is one fewer system to operate, back up, secure and explain to an auditor.

A dedicated store — Qdrant, Weaviate, Milvus — earns its keep at larger scale, or when you need filtering behaviour and latency that Postgres will not give you. Adopt one when you have measured a problem, not in anticipation of one.

Pinecone and similar managed services trade control for having no infrastructure at all. Reasonable when nobody on the team wants to run a database.

Elasticsearch or OpenSearch deserve a mention people forget: if you already run one for logs or site search, it does vector and keyword search in one place, which makes the hybrid section below almost free.

On frameworks, an honest assessment. LangChain, LlamaIndex and Haystack give you loaders for dozens of file types and connectors for dozens of systems, which is genuine time saved. They also add an abstraction layer over something that — as the forty lines above show — is not very complicated, and debugging a retrieval problem through three layers of framework is materially harder than debugging a SQL query.

A defensible position: use their document loaders, which are the genuinely tedious part, and write the retrieval and prompting yourself. If you cannot see the exact text being sent to the model without a debugger, you will not be able to fix it when it is wrong.

The two upgrades that actually move the needle

THE SINGLE BIGGEST UPGRADE TO A BASIC RAG SYSTEM KEYWORD SEARCH (BM25) Matches the actual words. Decades old, unglamorous. ✓ Finds “error E-04”, “INV-2291”, “section 7.3(b)” ✓ Finds a part number it has never seen before ✗ Misses “card machine broken” → “terminal fault” VECTOR SEARCH Matches meaning. The part everyone builds first. ✓ Finds “terminal fault” from “card machine broken” ✓ Works across paraphrase and synonym ✗ Fails on exact identifiers — codes look alike to it Their failures are OPPOSITE, which is exactly why combining them works so well. Run both, then merge with Reciprocal Rank Fusion: score each result as 1/(60 + its rank) in each list, and add the scores. No calibration, no tuning, no need for the two scoring scales to be comparable — it only uses position. That is why it is the default. THEN RERANK — the second-biggest upgrade, and it works on a different principle. Search compares the question and each document separately, which is what makes it fast enough to scan millions. A reranker reads the question and one document together — far more accurate, far too slow for the whole corpus. So: search wide and cheap for 50 candidates, then rerank precisely to the 5 you actually paste in. Retrieve 50, keep 5.
If you do only two things beyond the basic system, do these. They are worth more than any amount of prompt tuning.
sqlhybrid retrieval with permission filtering, in plain Postgres
-- Hybrid search in one SQL statement: keyword and vector, fused by rank.
WITH semantic AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> %(qvec)s::vector) AS rank
  FROM chunks WHERE acl_groups && %(user_groups)s     -- permission filter, applied FIRST
  ORDER BY embedding <=> %(qvec)s::vector LIMIT 50
),
keyword AS (
  SELECT id, ROW_NUMBER() OVER (
           ORDER BY ts_rank_cd(tsv, websearch_to_tsquery(%(q)s)) DESC) AS rank
  FROM chunks
  WHERE tsv @@ websearch_to_tsquery(%(q)s) AND acl_groups && %(user_groups)s
  LIMIT 50
)
SELECT c.id, c.chunk, c.source,
       COALESCE(1.0 / (60 + s.rank), 0) +
       COALESCE(1.0 / (60 + k.rank), 0) AS score      -- Reciprocal Rank Fusion
FROM chunks c
LEFT JOIN semantic s ON s.id = c.id
LEFT JOIN keyword  k ON k.id = c.id
WHERE s.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY score DESC
LIMIT 50;      -- 50 candidates go to the reranker, which returns the 5 you use

The 60 in that formula is a smoothing constant, and it is conventional rather than tuned — it stops the top one or two results dominating. The reason reciprocal rank fusion is the default is that it needs no calibration: it uses only position, so the two search methods' incompatible score scales never have to be reconciled.

pythona cross-encoder reranker — search wide, then read carefully
# Reranking: read the question and each candidate TOGETHER, then keep the best few
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

pairs  = [[question, c["chunk"]] for c in candidates]        # 50 candidates
scores = reranker.compute_score(pairs)

top = [c for _, c in sorted(zip(scores, candidates),
                            key=lambda x: -x[0])][:5]        # keep 5

# Worth measuring rather than assuming: on most corpora this is the single
# largest accuracy gain per unit of effort in the whole pipeline. It costs
# roughly 50-200ms, which is why it runs on 50 candidates and not 50,000.

Evaluating it — and the split that makes debugging possible

Measure retrieval and generation separately. Score only the final answer and you learn that it is wrong without learning why, and you will spend a week tuning prompts to fix a chunking problem.

QuestionMetricWhat a bad score means
Did the right chunk come back at all?Recall@k — is the correct passage in the top k?Fix ingestion. Chunking, the embedding model, or hybrid search. The model cannot rescue this.
Was it near the top?MRR — how far down the list it wasAdd a reranker. The information is there; the ordering is wrong.
Did the answer use it?Faithfulness — is every claim supported by a retrieved passage?Fix the prompt, or the model is too weak for the task.
Did it answer the question?RelevanceOften the question was ambiguous rather than the system broken. Read the failures.

Building the evaluation set is unglamorous and it is the work: collect fifty real questions and, for each, note which document actually answers it. That gives you recall and MRR immediately, computed with ordinary code and no framework. Fifty honest examples beat five hundred generated ones, because generated questions are answerable by construction and real ones are not.

pythonrun this before and after every change
# Retrieval evaluation. No framework needed -- this is the whole thing.
def evaluate(eval_set, retrieve, k=10):
    hits, rr = 0, 0.0
    for q, gold_doc_id in eval_set:
        ids = [c["doc_id"] for c in retrieve(q, limit=k)]
        if gold_doc_id in ids:
            hits += 1
            rr   += 1.0 / (ids.index(gold_doc_id) + 1)
    n = len(eval_set)
    return {"recall@k": hits / n, "mrr": rr / n}

print("vector only  ", evaluate(eval_set, retrieve_vector))
print("hybrid       ", evaluate(eval_set, retrieve_hybrid))
print("hybrid+rerank", evaluate(eval_set, retrieve_hybrid_reranked))
# vector only   {'recall@k': 0.72, 'mrr': 0.51}
# hybrid        {'recall@k': 0.88, 'mrr': 0.63}
# hybrid+rerank {'recall@k': 0.88, 'mrr': 0.81}

Read those numbers carefully, because the shape is typical and it is instructive. Hybrid search raised recall from 0.72 to 0.88 — it found documents that vector search alone was missing entirely, almost always ones containing exact codes or identifiers. Reranking left recall unchanged and moved MRR from 0.63 to 0.81 — it found nothing new, it put what was already there in the right order. Two different problems, two different fixes, and you can only see which you have if you measure both.

Check yourself

What are the two pipelines?Ingestion — load, split, embed, store, index — running on a schedule; and query — embed, search, rerank, assemble, generate — running per question. Only the last step touches a language model.
What order do you debug in?Ask first whether the right chunk was retrieved at all. If not it is a search problem and no prompting will fix it. Only if retrieval was correct is it a prompt or model problem.
Why give the model permission to say 'not in the documents'?An approved way to fail is the difference between a system that admits ignorance and one that invents a plausible policy.
What is the chunking check?Take twenty real questions and ask whether each answer sits inside a single chunk. If answers routinely span two, no retrieval tuning will fix it.
Why capture metadata at ingest?Adding a field later means re-embedding and re-indexing the whole corpus. Adding an empty column now costs nothing — especially the access-control field.
When is pgvector the right choice?If you already run Postgres and are under a few million chunks. Documents, metadata, permissions and vectors in one database, joined with SQL — and one fewer system to operate and explain to an auditor.
Why does hybrid search work so well?Keyword and vector search fail in opposite ways. Keyword finds exact codes and identifiers; vector finds paraphrase and synonym. Reciprocal rank fusion merges them using position only, so no calibration is needed.
What does a reranker do differently?Search compares question and document separately, which is what makes it fast. A reranker reads them together — much more accurate, far too slow for the whole corpus. So retrieve 50, rerank, keep 5.
Why measure retrieval and generation separately?Otherwise you learn the answer is wrong without learning why. Low recall means fix ingestion; low MRR means add a reranker; low faithfulness means fix the prompt.
← A fine-tune, start to finishRAG in the enterprise →