<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[NextCTL]]></title><description><![CDATA[NextCTL]]></description><link>https://nextctl.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 09:55:49 GMT</lastBuildDate><atom:link href="https://nextctl.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Evaluating RAG in Python: Retrieval Metrics, Faithfulness and Regression Tests]]></title><description><![CDATA[Build a RAG evaluation harness in Python: a golden set, recall@k, MRR and nDCG, NLI-based faithfulness checks, abstention tests and a CI regression gate…]]></description><link>https://nextctl.hashnode.dev/evaluating-rag-python</link><guid isPermaLink="true">https://nextctl.hashnode.dev/evaluating-rag-python</guid><category><![CDATA[RAG ]]></category><category><![CDATA[Testing]]></category><category><![CDATA[llm]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[NextCTL]]></dc:creator><pubDate>Mon, 19 Oct 2026 11:26:09 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://nextctl.com/blog/evaluating-rag-python">nextctl.com</a>.</em></p>
<p>To evaluate a RAG system in Python, measure two layers separately and gate your CI on both. <strong>Retrieval:</strong> did the right documents reach the top k? Use recall@k, MRR and nDCG against a labelled golden set. <strong>Generation:</strong> is every claim in the answer supported by the retrieved context (faithfulness), and does the system abstain when the documents don't contain the answer? Save a baseline report. Then add a pytest suite that fails the build when any metric drops below a floor, falls beyond a tolerance from the baseline, or when a single query that used to pass now fails.</p>
<p>This post builds that harness end to end. It runs on CPU, needs no API key, and every number shown comes from actually running it. Chunking and hybrid retrieval aren't covered here; this post is about measuring whatever pipeline you have.</p>
<p>Setup (tested locally with Python 3.13, <code>sentence-transformers</code> 6.1 and CPU-only PyTorch):</p>
<pre><code class="language-bash">pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install sentence-transformers pytest
</code></pre>
<h2>What should a RAG evaluation measure?</h2>
<p>Bad answers come from separate failures, and one blended "quality score" hides which one happened:</p>
<table>
<thead>
<tr>
<th>Question</th>
<th>Metric</th>
<th>Failure it catches</th>
</tr>
</thead>
<tbody><tr>
<td>Did we find the evidence?</td>
<td>recall@k, hit@k</td>
<td>Retriever misses, bad chunking, stale index</td>
</tr>
<tr>
<td>Did we rank it first?</td>
<td>MRR, nDCG@k</td>
<td>Evidence buried under near-misses</td>
</tr>
<tr>
<td>Did we stay grounded?</td>
<td>Faithfulness</td>
<td>The generator invents facts (<a href="https://genai.owasp.org/llmrisk/llm092025-misinformation/">OWASP LLM09: Misinformation</a>)</td>
</tr>
<tr>
<td>Did we know when to stop?</td>
<td>Abstention accuracy</td>
<td>Confident answers to questions the docs can't answer</td>
</tr>
<tr>
<td>Did the key fact survive?</td>
<td><code>must_include</code> check</td>
<td>Correct-sounding answers that leave out the number that matters</td>
</tr>
</tbody></table>
<p>If faithfulness drops while recall holds, look at the prompt or the model. If recall drops, look at the index. You'll see that split pay off in the CI section.</p>
<h2>How do you build a golden set?</h2>
<p>A golden set is a versioned file of real questions. Each question is labelled with the documents that answer it and the facts the answer must contain. Write questions the way users ask them, not the way your docs are worded. Pull them from search logs and support tickets where you can. Each line carries:</p>
<ul>
<li><code>relevant</code>: document IDs with <strong>graded</strong> relevance (2 = answers it, 1 = partially relevant). Grades let nDCG reward the best document coming first.</li>
<li><code>must_include</code>: short strings the final answer has to contain.</li>
<li><code>answerable: false</code> for questions the corpus can't answer. <strong>Include these.</strong> A system that never says "I don't know" will look perfect until a user asks one.</li>
<li><code>tags</code> so you can report metrics per slice (billing, security, multi-doc).</li>
</ul>
<pre><code class="language-json">{"id": "q1", "question": "Can I get a refund on my annual plan?", "relevant": {"refund-annual": 2}, "must_include": ["30 days"], "answerable": true, "tags": ["billing"]}
{"id": "q2", "question": "What happens if I cancel a monthly subscription?", "relevant": {"refund-monthly": 2}, "must_include": ["end of the billing period"], "answerable": true, "tags": ["billing"]}
{"id": "q3", "question": "Do you support Okta login?", "relevant": {"sso": 2}, "must_include": ["Enterprise"], "answerable": true, "tags": ["security"]}
{"id": "q4", "question": "How many users are included in the Team plan?", "relevant": {"seats": 2}, "must_include": ["10"], "answerable": true, "tags": ["billing"]}
{"id": "q5", "question": "How do I download all my workspace data?", "relevant": {"export": 2}, "must_include": ["JSON"], "answerable": true, "tags": ["data"]}
{"id": "q6", "question": "What is the API rate limit?", "relevant": {"api-limits": 2}, "must_include": ["100 requests"], "answerable": true, "tags": ["api"]}
{"id": "q7", "question": "How long until a deleted project is gone for good?", "relevant": {"retention": 2}, "must_include": ["14 days"], "answerable": true, "tags": ["data"]}
{"id": "q8", "question": "Do you offer an on-premises version?", "relevant": {}, "must_include": [], "answerable": false, "tags": ["unanswerable"]}
{"id": "q9", "question": "Which plans can be refunded, monthly or annual?", "relevant": {"refund-annual": 2, "refund-monthly": 1}, "must_include": ["refund"], "answerable": true, "tags": ["billing", "multi-doc"]}
</code></pre>
<p>The corpus is a small set of help-centre passages:</p>
<pre><code class="language-json">{"id": "refund-annual", "text": "Annual plans can be refunded within 30 days of purchase. After 30 days, annual plans are not refundable."}
{"id": "refund-monthly", "text": "Monthly plans are not refundable, but you can cancel at any time and keep access until the end of the billing period."}
{"id": "sso", "text": "Single sign-on with SAML is available on the Enterprise plan. Okta and Microsoft Entra ID are supported identity providers."}
{"id": "seats", "text": "The Team plan includes 10 seats. Additional seats can be added from the billing page."}
{"id": "export", "text": "Workspace owners can export all data as JSON from Settings, then Data export. Exports are emailed within 24 hours."}
{"id": "api-limits", "text": "The public API allows 100 requests per minute per token. Requests above the limit receive HTTP 429."}
{"id": "retention", "text": "Deleted projects stay in the trash for 14 days before they are permanently removed."}
</code></pre>
<p>Nine questions is enough to demonstrate the method, but far too few to trust. Grow the set from production traffic, and add every bug report as a new case.</p>
<h2>How do you compute recall@k, MRR and nDCG?</h2>
<pre><code class="language-python">"""metrics.py - retrieval metrics over ranked doc IDs. relevant = {doc_id: grade}, grade &gt;= 1."""
import math

def recall_at_k(ranked: list[str], relevant: dict[str, int], k: int) -&gt; float:
    """Fraction of all relevant docs that appear in the top k."""
    if not relevant:
        return 1.0
    return len(set(ranked[:k]) &amp; set(relevant)) / len(relevant)

def hit_at_k(ranked: list[str], relevant: dict[str, int], k: int) -&gt; float:
    """1.0 if at least one relevant doc is in the top k (often mislabelled 'recall')."""
    return float(bool(set(ranked[:k]) &amp; set(relevant))) if relevant else 1.0

def reciprocal_rank(ranked: list[str], relevant: dict[str, int]) -&gt; float:
    for rank, doc_id in enumerate(ranked, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

def ndcg_at_k(ranked: list[str], relevant: dict[str, int], k: int) -&gt; float:
    """Graded relevance: rewards putting the most relevant docs first."""
    dcg = sum((2 ** relevant.get(d, 0) - 1) / math.log2(i + 2) for i, d in enumerate(ranked[:k]))
    ideal = sorted(relevant.values(), reverse=True)[:k]
    idcg = sum((2 ** g - 1) / math.log2(i + 2) for i, g in enumerate(ideal))
    return dcg / idcg if idcg else 1.0
</code></pre>
<p>Two details catch people out:</p>
<ul>
<li><strong>Recall@k is not hit@k.</strong> Recall@k is the share of <em>all</em> relevant documents found in the top k. Hit@k only asks whether <em>any</em> were found. Many tutorials compute hit@k and call it recall. On single-document questions the two are identical, so the mix-up goes unnoticed until a question needs two sources. For <code>q9</code>, with the annual-refund document ranked first and the monthly one second, recall@1 is <strong>0.5</strong> but hit@1 is <strong>1.0</strong>. Only recall shows you're missing half the evidence.</li>
<li><strong>MRR only looks at the first relevant hit.</strong> It's the right metric when one good passage is enough. When the order of several graded documents matters, use nDCG. Swap the two refund documents in <code>q9</code> and nDCG@3 falls from 1.0 to about 0.80, while recall@3 doesn't move. The <a href="https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-ranked-retrieval-results-1.html">Stanford IR book's chapter on ranked evaluation</a> covers the theory.</li>
</ul>
<p>Score empty <code>relevant</code> sets (unanswerable questions) as 1.0 for retrieval and judge them on abstention instead. Otherwise they drag every average down for no reason.</p>
<h2>What does the pipeline under test look like?</h2>
<p>To keep the harness deterministic, the system under test is a small BM25 retriever. Its "generator" returns the best passage verbatim, and it abstains when the top score falls below <code>min_score</code>. Replace <code>retrieve()</code> and <code>answer()</code> with your real stack. The harness only needs ranked IDs, the answer text and the contexts used.</p>
<pre><code class="language-python">"""rag.py - a tiny, deterministic RAG pipeline to evaluate. Swap in your real retriever/LLM."""
import json, math, re
from collections import Counter

STOP = set("a an and are at be can do does for from how i in is it my of on or the to what when "
           "with you your".split())

def tokenize(text: str) -&gt; list[str]:
    return [t for t in re.findall(r"[a-z0-9]+", text.lower()) if t not in STOP]

class BM25:
    def __init__(self, docs: dict[str, str], k1: float = 1.5, b: float = 0.75):
        self.ids, self.k1, self.b = list(docs), k1, b
        self.tf = [Counter(tokenize(docs[i])) for i in self.ids]
        self.len = [sum(c.values()) for c in self.tf]
        self.avg = sum(self.len) / len(self.len)
        df = Counter(t for c in self.tf for t in c)
        n = len(self.ids)
        self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}

    def search(self, query: str, k: int = 5) -&gt; list[tuple[str, float]]:
        q = tokenize(query)
        scores = []
        for i, tf in enumerate(self.tf):
            s = sum(self.idf.get(t, 0) * tf[t] * (self.k1 + 1) /
                    (tf[t] + self.k1 * (1 - self.b + self.b * self.len[i] / self.avg)) for t in q)
            scores.append((self.ids[i], s))
        return sorted([x for x in scores if x[1] &gt; 0], key=lambda x: -x[1])[:k]

def load_corpus(path: str = "evals/corpus.jsonl") -&gt; dict[str, str]:
    return {d["id"]: d["text"] for d in map(json.loads, open(path))}

class Pipeline:
    def __init__(self, corpus: dict[str, str], min_score: float = 1.5):
        self.corpus, self.index, self.min_score = corpus, BM25(corpus), min_score

    def retrieve(self, question: str, k: int = 5) -&gt; list[str]:
        return [doc_id for doc_id, _ in self.index.search(question, k)]

    def answer(self, question: str, k: int = 3) -&gt; dict:
        hits = self.index.search(question, k)
        if not hits or hits[0][1] &lt; self.min_score:
            return {"answer": "I don't know based on the available documents.", "contexts": []}
        contexts = [self.corpus[d] for d, _ in hits]
        # Stand-in for an LLM call: answer with the best passage, verbatim.
        return {"answer": contexts[0], "contexts": contexts}
</code></pre>
<h2>How do you check faithfulness without an API key?</h2>
<p><a href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/faithfulness/">Ragas defines faithfulness</a> as the share of claims in the answer that the retrieved context supports. You can compute it with an LLM judge, or locally with a natural-language-inference (NLI) cross-encoder. NLI models classify a (premise, hypothesis) pair as entailment, contradiction or neutral. We use <a href="https://huggingface.co/cross-encoder/nli-deberta-v3-small"><code>cross-encoder/nli-deberta-v3-small</code></a> through <a href="https://sbert.net/docs/cross_encoder/usage/usage.html">Sentence Transformers' <code>CrossEncoder</code></a>:</p>
<pre><code class="language-python">"""faithfulness.py - is every claim in the answer supported by the retrieved context?"""
import re
from typing import Protocol

def split_claims(answer: str) -&gt; list[str]:
    """Sentence-level claims. Good enough to start; an LLM can decompose compound sentences."""
    return [s.strip() for s in re.split(r"(?&lt;=[.!?])\s+", answer) if len(s.strip()) &gt; 3]

class Judge(Protocol):
    def supported(self, claim: str, contexts: list[str]) -&gt; bool: ...

def windows(contexts: list[str], size: int = 2) -&gt; list[str]:
    """NLI models are trained on short premises, so score claims against 1-2 sentence windows."""
    out = []
    for ctx in contexts:
        sents = split_claims(ctx)
        for n in range(1, size + 1):
            out += [" ".join(sents[i:i + n]) for i in range(len(sents) - n + 1)]
    return out

class NLIJudge:
    """Local NLI cross-encoder: a claim is supported if some context window entails it."""
    LABELS = ["contradiction", "entailment", "neutral"]   # label order from the model card

    def __init__(self, model: str = "cross-encoder/nli-deberta-v3-small", threshold: float = 0.5):
        from sentence_transformers import CrossEncoder
        self.model, self.threshold = CrossEncoder(model), threshold

    def supported(self, claim: str, contexts: list[str]) -&gt; bool:
        premises = windows(contexts)
        if not premises:
            return False
        probs = self.model.predict([(p, claim) for p in premises], apply_softmax=True)
        return float(probs[:, self.LABELS.index("entailment")].max()) &gt;= self.threshold

ABSTAIN = re.compile(r"\b(i don't know|not (?:in|covered by) the (?:available )?documents)\b", re.I)

def faithfulness(answer: str, contexts: list[str], judge: Judge) -&gt; dict:
    if ABSTAIN.search(answer):
        return {"score": 1.0, "abstained": True, "unsupported": []}
    claims = split_claims(answer)
    unsupported = [c for c in claims if not judge.supported(c, contexts)]
    score = 1 - len(unsupported) / len(claims) if claims else 0.0
    return {"score": score, "abstained": False, "unsupported": unsupported}
</code></pre>
<p>Building this taught us one thing worth passing on. NLI models are trained on short premises. When we scored a claim against a whole passage, the model sometimes returned "neutral" even for a claim copied word for word from it. Scoring against one- and two-sentence windows and taking the best entailment fixed that. Always include a known hallucination in your tests. Ours changes "30 days" to "60 days", and the judge has to score it 0.</p>
<p>The trade-off is real. Sentence splitting misses compound claims, and NLI struggles with numbers and multi-hop reasoning. An LLM judge handles those better, but costs money, varies between runs and needs calibrating against human labels. A sensible setup is the cheap local judge on every pull request and an LLM judge in a nightly job.</p>
<h2>How do you turn it into a report?</h2>
<pre><code class="language-python">"""evaluate.py - run the golden set, print and save a report.  python evaluate.py"""
import json, statistics, sys
from collections import defaultdict
from faithfulness import NLIJudge, faithfulness
from metrics import hit_at_k, ndcg_at_k, recall_at_k, reciprocal_rank
from rag import Pipeline, load_corpus

K = 3

def evaluate(pipeline, golden: list[dict], judge) -&gt; dict:
    rows = []
    for item in golden:
        ranked = pipeline.retrieve(item["question"], k=10)
        out = pipeline.answer(item["question"])
        f = faithfulness(out["answer"], out["contexts"], judge)
        rows.append({
            "id": item["id"], "tags": item["tags"],
            f"recall@{K}": recall_at_k(ranked, item["relevant"], K),
            f"hit@{K}": hit_at_k(ranked, item["relevant"], K),
            "mrr": reciprocal_rank(ranked, item["relevant"]) if item["relevant"] else 1.0,
            f"ndcg@{K}": ndcg_at_k(ranked, item["relevant"], K),
            "faithfulness": f["score"], "unsupported": f["unsupported"],
            # answerable questions must not abstain; unanswerable ones must
            "abstention_ok": f["abstained"] != item["answerable"],
            "has_key_facts": all(s.lower() in out["answer"].lower() for s in item["must_include"]),
        })
    metric_names = [f"recall@{K}", f"hit@{K}", "mrr", f"ndcg@{K}", "faithfulness",
                    "abstention_ok", "has_key_facts"]
    agg = {m: round(statistics.mean(float(r[m]) for r in rows), 4) for m in metric_names}
    by_tag = defaultdict(list)
    for r in rows:
        for t in r["tags"]:
            by_tag[t].append(r)
    slices = {t: {m: round(statistics.mean(float(r[m]) for r in rs), 4) for m in metric_names}
              for t, rs in by_tag.items()}
    return {"aggregate": agg, "slices": slices, "per_query": rows}

if __name__ == "__main__":
    golden = [json.loads(line) for line in open("evals/golden.jsonl")]
    report = evaluate(Pipeline(load_corpus()), golden, NLIJudge())
    json.dump(report, open(sys.argv[1] if len(sys.argv) &gt; 1 else "report.json", "w"), indent=2)
    print(json.dumps(report["aggregate"], indent=2))
</code></pre>
<p><code>python evaluate.py baseline.json</code> writes the aggregate figures, per-tag slices and per-query rows. On this corpus every aggregate metric is 1.0, which is what a baseline for a toy set should look like. Slices matter on real data. A 0.95 average can hide a 0.6 on the <code>security</code> tag.</p>
<h2>How do you gate CI on regressions?</h2>
<p>Three checks, each catching a different kind of failure:</p>
<ol>
<li><strong>Absolute floors.</strong> Quality must never go below these levels, whatever the baseline says.</li>
<li><strong>No aggregate regression</strong> beyond a small tolerance compared with the committed baseline.</li>
<li><strong>No newly failing queries.</strong> Averages can hold steady while individual questions break. This test names the exact queries.</li>
</ol>
<pre><code class="language-python"># test_rag_regression.py - the CI gate.  pytest -q   (update baseline: python evaluate.py baseline.json)
import json, os
import pytest
from evaluate import evaluate
from faithfulness import NLIJudge, faithfulness
from rag import Pipeline, load_corpus

FLOORS = {"recall@3": 0.90, "mrr": 0.80, "faithfulness": 0.95, "abstention_ok": 1.0}
TOLERANCE = 0.02          # allowed aggregate drop vs baseline before the build fails
PER_QUERY = ["recall@3", "faithfulness", "abstention_ok", "has_key_facts"]

@pytest.fixture(scope="session")
def judge():
    return NLIJudge()

@pytest.fixture(scope="session")
def report(judge):
    golden = [json.loads(line) for line in open("evals/golden.jsonl")]
    min_score = float(os.environ.get("RAG_MIN_SCORE", "1.5"))   # lets us simulate a bad change
    return evaluate(Pipeline(load_corpus(), min_score=min_score), golden, judge)

@pytest.fixture(scope="session")
def baseline():
    return json.load(open("baseline.json"))

def test_absolute_floors(report):
    agg = report["aggregate"]
    failing = {m: agg[m] for m, floor in FLOORS.items() if agg[m] &lt; floor}
    assert not failing, f"below floor: {failing}"

def test_no_aggregate_regression(report, baseline):
    drops = {m: (baseline["aggregate"][m], v) for m, v in report["aggregate"].items()
             if v &lt; baseline["aggregate"][m] - TOLERANCE}
    assert not drops, f"metrics dropped (baseline, now): {drops}"

def test_no_newly_failing_queries(report, baseline):
    before = {r["id"]: r for r in baseline["per_query"]}
    broken = [(r["id"], m) for r in report["per_query"] for m in PER_QUERY
              if r["id"] in before and float(r[m]) &lt; float(before[r["id"]][m])]
    assert not broken, f"queries that got worse: {broken}"

def test_judge_catches_a_hallucination(judge):
    ctx = ["Annual plans can be refunded within 30 days of purchase. "
           "After 30 days, annual plans are not refundable."]
    bad = faithfulness("Annual plans can be refunded within 60 days.", ctx, judge)
    good = faithfulness("Annual plans can be refunded within 30 days.", ctx, judge)
    assert bad["score"] == 0.0 and good["score"] == 1.0
</code></pre>
<p>Session-scoped <a href="https://docs.pytest.org/en/stable/how-to/fixtures.html">pytest fixtures</a> load the model once. Now simulate a plausible "tuning" change: raise the abstention threshold to cut hallucinations. <code>RAG_MIN_SCORE=2.5 pytest -q</code> gives:</p>
<pre><code class="language-text">E  AssertionError: below floor: {'abstention_ok': 0.6667}
E  AssertionError: metrics dropped (baseline, now): {'abstention_ok': (1.0, 0.6667), 'has_key_facts': (1.0, 0.6667)}
E  AssertionError: queries that got worse: [('q1', 'abstention_ok'), ('q1', 'has_key_facts'), ('q3', 'abstention_ok'), ('q3', 'has_key_facts'), ('q7', 'abstention_ok'), ('q7', 'has_key_facts')]
3 failed, 1 passed
</code></pre>
<p>Retrieval metrics didn't change, and faithfulness stayed at 1.0, because "I don't know" is always faithful. A dashboard tracking only recall and faithfulness would have shipped this. The abstention and key-fact checks caught three answerable questions the system now refuses, and named them.</p>
<p>Wire it into <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax">GitHub Actions</a> so it runs on any pull request that touches the pipeline or the eval data, and cache the model download:</p>
<pre><code class="language-yaml">name: rag-eval
on:
  pull_request:
    paths: ["rag.py", "faithfulness.py", "metrics.py", "evals/**", "baseline.json"]
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.12"
      - uses: actions/cache@v6
        with:
          path: ~/.cache/huggingface
          key: hf-nli-deberta-v3-small
      - run: pip install torch --index-url https://download.pytorch.org/whl/cpu
      - run: pip install sentence-transformers pytest
      - run: pytest -q test_rag_regression.py
      - if: always()
        run: python evaluate.py report.json
      - if: always()
        uses: actions/upload-artifact@v7
        with:
          name: rag-eval-report
          path: report.json
</code></pre>
<h2>How do you keep the golden set honest?</h2>
<ul>
<li><strong>Update the baseline deliberately.</strong> Re-run <code>python evaluate.py baseline.json</code> in the same pull request as an intended improvement, so reviewers see the diff in metrics.</li>
<li><strong>Use small tolerances on small sets.</strong> With nine questions, one query is 11% of the score. The per-query test is more informative than the averages until the set has hundreds of items.</li>
<li><strong>Version the corpus with the golden set.</strong> If documents change, labels can go stale. A "regression" may just mean the answer moved to a different document.</li>
<li><strong>Keep a hidden holdout.</strong> If people tune prompts against the same questions CI checks, the scores will creep up without real gains.</li>
<li><strong>Pin the judge.</strong> Upgrading the NLI or LLM judge model changes scores. Treat it like a dependency upgrade and re-baseline.</li>
</ul>
<blockquote>
<p><strong>Key takeaways</strong></p>
<ul>
<li>Evaluate retrieval (recall@k, MRR, nDCG) and generation (faithfulness, abstention, key facts) separately, so a drop tells you where to look.</li>
<li>Recall@k counts all relevant documents. Hit@k counts any. Know which one you report.</li>
<li>A local NLI cross-encoder scoring sentence windows is a free, deterministic faithfulness check for every pull request.</li>
<li>Add unanswerable questions. Without them, "never abstain" and "always abstain" both slip through.</li>
<li>Gate CI on floors, baseline drift and newly failing queries. The last one catches what averages hide.</li>
</ul>
</blockquote>
<h2>FAQ</h2>
<p><strong>How many golden questions do I need?</strong>
Start with whatever you can label well, even 30 to 50, covering your main intents and some unanswerable ones. Grow it from production logs. Small sets are useful for catching regressions, as long as you watch individual queries and not just averages.</p>
<p><strong>Should I use an LLM as the judge?</strong>
For nuanced claims, yes, but calibrate it against human labels and pin the model version. A local NLI judge is a good, cheap first gate on every pull request.</p>
<p><strong>What value of k should I evaluate?</strong>
Use the k your generator actually receives. If the prompt gets three chunks, recall@10 flatters you.</p>
<p><strong>Is faithfulness the same as correctness?</strong>
No. An answer can be faithful to a stale or wrong document. That's why <code>must_include</code> checks, and fresh documents, still matter.</p>
<hr />
<p><em>Written by the engineering team at NextCTL LLC, an AI-native software engineering and business automation company registered in Delaware and operating from Dhaka, serving the US, Canada, the UK and Europe. We build RAG systems with evaluation and monitoring built in from the start. See our <a href="https://nextctl.com/llm-rag">LLM and RAG development</a> service or visit <a href="https://nextctl.com">nextctl.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Production RAG: Chunking, Retrieval Evaluation, and Why Most RAG Demos Fail in Production]]></title><description><![CDATA[Retrieval-augmented generation looks easy in a demo. Load a few PDFs, split them into chunks, embed them, store the vectors, retrieve the top five, and ask…]]></description><link>https://nextctl.hashnode.dev/production-rag-chunking-retrieval-evaluation-and-why-most-rag-demos-fail-in-production</link><guid isPermaLink="true">https://nextctl.hashnode.dev/production-rag-chunking-retrieval-evaluation-and-why-most-rag-demos-fail-in-production</guid><category><![CDATA[llm]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[NextCTL]]></dc:creator><pubDate>Sun, 18 Oct 2026 02:43:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad431f48bdbbc8157d2f1a7/578a619e-13fe-435c-952a-7dd8a84296ba.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Retrieval-augmented generation looks easy in a demo. Load a few PDFs, split them into chunks, embed them, store the vectors, retrieve the top five, and ask a model to answer. On ten hand-picked questions it works beautifully.</p>
<p>Then it meets real users, a real document corpus and real expectations, and the answers start drifting: confidently wrong, outdated, missing obvious information, or quoting a document the user should never have seen.</p>
<p>The cause is almost never the language model. It is nearly always the retrieval pipeline and the absence of measurement. This article covers the parts that decide whether RAG works in production: how you chunk, how you retrieve, how you evaluate, and the operational details demos skip.</p>
<h2>Why Demos Succeed and Production Fails</h2>
<p>Demos hide five problems:</p>
<ol>
<li><strong>Curated questions.</strong> The builder writes questions they already know the corpus answers, phrased in the corpus's own vocabulary. Real users use different words, ask multi-part questions and ask things the corpus does not cover.</li>
<li><strong>Small, clean corpus.</strong> Fifty tidy documents behave differently from 50,000 documents with duplicates, old versions, scanned tables and boilerplate headers on every page.</li>
<li><strong>No evaluation.</strong> "It looked right" is the only test. Nobody knows the retrieval hit rate, so nobody notices when a change makes it worse.</li>
<li><strong>No access control.</strong> Everything is in one index and everyone can retrieve everything.</li>
<li><strong>Static data.</strong> The index was built once. In production, documents change daily, and stale chunks give stale answers.</li>
</ol>
<p>Fixing RAG in production means addressing each of these deliberately.</p>
<h2>Chunking: The Decision That Shapes Everything Downstream</h2>
<p>The retriever can only return what you chunked. Bad chunks mean the right information is either split across pieces that never get retrieved together, or buried in a chunk dominated by irrelevant text.</p>
<h3>Use the document's structure</h3>
<p>Fixed-size splitting (every 500 tokens) is a reasonable baseline and a poor final answer. Split on structure first: headings, sections, list items, table boundaries, and fall back to size limits only within a section. A chunk that corresponds to "Section 4.2: Cancellation policy" is far more retrievable than one that starts mid-sentence in 4.1 and ends in 4.3.</p>
<h3>Carry context into each chunk</h3>
<p>A chunk that says "This applies only to annual plans" is useless without knowing <em>what</em> applies. Prepend lightweight context to each chunk before embedding: document title, section path and, if useful, a one-sentence summary of the document. For example:</p>
<pre><code class="language-text">[Billing Policy &gt; Refunds &gt; Annual Plans]
Refunds for annual plans are prorated by unused full months...
</code></pre>
<p>This costs a few tokens per chunk and often produces a noticeable improvement in retrieval quality, because both the embedding and keyword matching now "see" the topic.</p>
<h3>Size for the question, not the model</h3>
<p>Bigger chunks give more context but dilute the embedding; smaller chunks are precise but lose surrounding meaning. Practical patterns:</p>
<ul>
<li><strong>Moderate chunks with modest overlap</strong> (for example a few hundred tokens with 10â€“15% overlap) as a starting point, then tune against your evaluation set.</li>
<li><strong>Parent-child retrieval:</strong> embed small chunks for precise matching, but pass the larger parent section to the model.</li>
<li><strong>Special handling for tables:</strong> keep a table whole when it fits, repeat header rows when you must split, or convert rows into self-describing text ("Plan: Pro, Monthly price: ..., Seats: ...").</li>
</ul>
<h3>Clean before you chunk</h3>
<p>Strip repeated headers, footers, navigation and legal boilerplate. Deduplicate near-identical documents. Keep only the current version of a policy, or tag versions explicitly. Garbage in the index competes with good content for the top-k slots.</p>
<h3>Store metadata</h3>
<p>Every chunk should carry source ID, URL or path, section, last-modified date, document type and access-control tags. You will need all of these for filtering, citations, freshness and permissions.</p>
<h2>Retrieval: Hybrid, Filtered and Reranked</h2>
<h3>Hybrid search beats either method alone</h3>
<p>Dense embeddings capture meaning ("how do I get my money back" matches "refund policy"). Keyword search (BM25) captures exact terms: product codes, error messages, names, acronyms, which embeddings often handle poorly. Run both and fuse the results, for example with reciprocal rank fusion:</p>
<pre><code class="language-python">def reciprocal_rank_fusion(result_lists, k=60):
    scores = {}
    for results in result_lists:
        for rank, doc_id in enumerate(results):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

fused = reciprocal_rank_fusion([bm25_ids, vector_ids])
</code></pre>
<h3>Rerank a wider candidate set</h3>
<p>Retrieve more candidates than you need (say 30â€“50), then use a cross-encoder or reranking model to reorder them and keep the top handful. Rerankers read the query and chunk together, so they judge relevance far better than vector similarity alone. This is often one of the highest-leverage additions to a weak pipeline.</p>
<h3>Filter with metadata before ranking</h3>
<p>If the user asks about "the 2026 employee handbook," filter by document type and year rather than hoping similarity sorts it out. Filters are also where permissions belong (more below).</p>
<h3>Handle the query, not just the corpus</h3>
<p>User questions are often vague or compound. Useful techniques include rewriting the query into a standalone question using conversation history, splitting multi-part questions into sub-queries, and generating a few alternative phrasings. Each adds latency and cost, so add them only when evaluation shows a gain.</p>
<h2>Evaluation: The Part Everyone Skips</h2>
<p>You cannot improve what you do not measure, and in RAG there are two things to measure separately: <strong>did we retrieve the right content</strong>, and <strong>did the model use it correctly</strong>.</p>
<h3>Build a golden set</h3>
<p>Collect 100â€“300 real or realistic questions. Sources: support tickets, search logs, sales calls, subject-matter experts. For each, record the expected answer and the IDs of the chunks or documents that contain it. Include questions the corpus cannot answer; the correct behavior there is "I don't know."</p>
<p>This is tedious work and it is the most valuable asset in the project. Version it alongside your code.</p>
<h3>Retrieval metrics</h3>
<ul>
<li><strong>Recall@k:</strong> of the questions, how often does at least one relevant chunk appear in the top k?</li>
<li><strong>MRR (mean reciprocal rank):</strong> how high does the first relevant chunk rank?</li>
<li><strong>nDCG:</strong> rewards putting the most relevant chunks first when there are several.</li>
</ul>
<pre><code class="language-python">def recall_at_k(golden, retrieve, k=5):
    hits = 0
    for item in golden:
        retrieved = retrieve(item["question"], k=k)
        if set(retrieved) &amp; set(item["relevant_ids"]):
            hits += 1
    return hits / len(golden)

def mrr(golden, retrieve, k=10):
    total = 0.0
    for item in golden:
        for rank, doc_id in enumerate(retrieve(item["question"], k=k), start=1):
            if doc_id in item["relevant_ids"]:
                total += 1 / rank
                break
    return total / len(golden)
</code></pre>
<p>If recall@k is low, no prompt engineering will save you. Fix chunking and retrieval first.</p>
<h3>Generation metrics</h3>
<ul>
<li><strong>Faithfulness / groundedness:</strong> is every claim in the answer supported by the retrieved context?</li>
<li><strong>Answer correctness:</strong> does it match the expected answer?</li>
<li><strong>Citation accuracy:</strong> do cited sources actually support the claims they are attached to?</li>
<li><strong>Abstention:</strong> does it decline when the answer is not in the corpus?</li>
</ul>
<p>LLM-as-judge scoring is practical at scale, but calibrate it: have people grade a sample, compare with the judge's scores, and adjust the judging prompt until they agree on most cases. An uncalibrated judge gives you numbers, not knowledge.</p>
<h3>Run it on every change</h3>
<p>Treat the evaluation set like a test suite. Re-run it when you change chunking, embedding models, retrieval parameters, prompts or the generation model. Track results over time. Regressions are common and invisible without this.</p>
<h2>Production Concerns Demos Ignore</h2>
<p><strong>Permissions.</strong> Enforce access control at retrieval time by filtering on ACL metadata that mirrors the source system. Never rely on the prompt ("don't reveal HR documents") to protect data. If a chunk is retrieved, assume the user can see it.</p>
<p><strong>Freshness.</strong> Build incremental ingestion: detect changed and deleted documents, re-chunk and re-embed only those, and remove stale chunks. Show "last updated" dates in citations so users can judge recency.</p>
<p><strong>Citations.</strong> Return source links with every answer. They build trust, let users verify, and give you an easy feedback signal.</p>
<p><strong>Prompt injection via documents.</strong> Retrieved text is untrusted input. A document can contain instructions aimed at the model. Keep retrieved content clearly delimited, and never give a RAG system tools that can act on such instructions without checks.</p>
<p><strong>Monitoring.</strong> Log queries, retrieved chunk IDs, scores, answers and user feedback. Review low-confidence and thumbs-down cases weekly, and add the good ones to your golden set.</p>
<p><strong>Cost and latency.</strong> Reranking, query rewriting and large contexts all add up. Measure per-query cost and p95 latency, and cache embeddings and frequent queries.</p>
<h2>Production RAG Checklist</h2>
<ul>
<li> Documents cleaned, deduplicated and versioned before chunking.</li>
<li> Structure-aware chunking with section context prepended.</li>
<li> Metadata on every chunk: source, section, date, type, ACL.</li>
<li> Hybrid retrieval (keyword + vector) with fusion.</li>
<li> Reranking over a wider candidate set.</li>
<li> Golden set of 100+ questions, including unanswerable ones.</li>
<li> Retrieval metrics (recall@k, MRR) and generation metrics (faithfulness, correctness) tracked per change.</li>
<li> LLM judge calibrated against human grades.</li>
<li> Permissions enforced at retrieval time.</li>
<li> Incremental ingestion with deletion handling.</li>
<li> Citations with dates on every answer.</li>
<li> Query, retrieval and feedback logging with a weekly review.</li>
</ul>
<h2>The Takeaway</h2>
<p>Most RAG failures are search failures wearing a chatbot costume. Treat RAG as an information retrieval system first: invest in clean data, thoughtful chunking, hybrid retrieval and an evaluation set you trust. The model is the last step in the pipeline, not the first thing to tune.</p>
<hr />
<p><em>Published by NextCTL LLC, an AI-native software engineering and business automation company that designs RAG applications, AI agents and custom software for organizations in the US, Canada, the UK and Europe. More at <a href="https://nextctl.com">nextctl.com</a>.</em></p>
]]></content:encoded></item></channel></rss>