Retrieval · evaluation

RAG & RAGAS

Retrieval-augmented generation answers from your documents: search first, then write. RAGAS scores the two halves separately, so a wrong answer tells you whether search missed or the model drifted.

IntermediateRAGAS 0.4

Search, then write

AnalogyRAG is two jobs wearing one coat. Search finds pages. Writing uses those pages. A wrong sentence does not tell you which job failed.
Data flow
Question→Retriever→Chunks→Generator→Answer

A demo that uploads one PDF and asks one question does not say which chunk was used, or what happens on the next fifty questions. An evaluation set does. Metadata filters (tenant, brand, “published”) belong before similarity, or you will retrieve another customer’s document.

Starter

question = "What is the return window?"
chunks = ["Returns: 30 days, unused, with receipt."]
prompt = "Use only the context.\n" + "\n".join(chunks) + "\nQ: " + question
print(prompt)

Example

“How does self-attention work?” over one paper should cite the section. “What is your return window?” over a catalog should cite the policy row.

A RAG app does two jobs. A retriever finds chunks that match the question. A generator (the LLM) writes an answer from those chunks. When the answer is wrong, one of those jobs failed. Looking at the paragraph cannot tell you which.

question
  → embed
  → nearest chunks in a vector store   # retrieval
  → prompt = instructions + chunks + question
  → LLM answer                           # generation

Uploading a paper and asking one question is a demo. It does not say which chunk was used, or what happens on the next fifty questions. An evaluation set does.

Example: an electronics help bot

AnalogyA catalog bot is a librarian with a short shelf. If the shelf does not contain gift cards, the librarian says so. They do not invent a policy.
Data flow
Catalog rows→Embed or keyword score→Top k→Prompt→Answer or refusal

Replace the keyword overlap with a real embedding model when you leave the tutorial. Keep k small. Add a reranker if the right row is in the top 20 but not the top 3. Tell the model to abstain when the context is silent.

Starter

catalog = ["Returns: 30 days, unused.", "Shipping: free over $50."]

def retrieve(query: str):
    return [row for row in catalog if any(w in row.lower() for w in query.lower().split())]

print(retrieve("return window"))

Example

TechNest-style electronics help, an employee handbook, and an on-call runbook are the same pipeline with different rows.

A shop, call it TechNest, has a short catalog and a few policies. The bot should answer only from those rows.

CATALOG = [
    {"id": "p1", "text": "NovaBuds: wireless earbuds, 24h battery, 1-year warranty."},
    {"id": "p2", "text": "Returns: 30 days, unused, with receipt. Opened earbuds are final sale."},
    {"id": "p3", "text": "Shipping: free over $50, otherwise $6. Delivered in 3 to 5 business days."},
]

def retrieve(query: str, k: int = 3) -> list[str]:
    # Replace this keyword overlap with real embeddings in production.
    scored = []
    words = set(query.lower().split())
    for row in CATALOG:
        overlap = len(words & set(row["text"].lower().split()))
        scored.append((overlap, row["text"]))
    scored.sort(reverse=True)
    return [text for score, text in scored[:k] if score > 0]

def answer(query: str) -> dict:
    chunks = retrieve(query)
    # Real app: send chunks to the model and require it to use only them.
    return {"question": query, "contexts": chunks}

Use cases that are the same pipeline

  • Employee handbook bot: “how many parental-leave weeks?” must cite the policy chunk.
  • On-call runbook: “how do we fail over payments?” retrieves the runbook section, not a guess from training data.
  • Research assistant over one PDF, such as Attention Is All You Need, where you care which section supported the sentence.

Production retrieval uses an embedding model (Gemini, OpenAI, or a local MiniLM) and a vector store. Add metadata filters (product line, tenant id, “published”) before similarity, or you will retrieve another customer’s document.

Chunking, top-k, and rerankers

AnalogyChunking is cutting a quilt. Too small and you lose the pattern. Too big and you hand the model the whole quilt when they needed one square.
Data flow
Document→Chunk + overlap→Embed→Top k→Optional rerank→Prompt

Overlap keeps a rule that crosses a boundary. Prefer paragraph or heading boundaries over a hard character cut when the source is markdown. Do not send all 10 hits. Drop weak scores and leave room for the system prompt and the answer.

Starter

text = "Returns: 30 days if unused. Opened earbuds are final sale."
size = 28
chunks = [text[i:i+size] for i in range(0, len(text), size)]
print(chunks)  # the exception may fall into the next chunk; overlap fixes that

Example

A chunk that says “30 days” without “opened earbuds are final sale” will produce a confident, wrong refund.

  • Chunks too big: the right sentence is buried, the prompt is expensive, the model gets distracted.
  • Chunks too small: “30 days” is retrieved without “opened earbuds are final sale”.
  • No overlap: a rule split across a heading boundary is invisible.
  • Top-k too high: you pay to show the model junk. Rerank the top 20 down to the best 4.
Tell the model to abstain. “If the context does not contain the answer, say you do not know.” A bot that invents a 90-day return window is worse than a bot that refuses.

RAGAS: a score for each half

AnalogyRAGAS is a referee who scores the pass and the shot separately. Retrieval is the pass. Generation is the shot. A missed goal does not tell you which one failed until you look at both.
Data flow
Question + chunks + answer + expected→Judge model→Five scores→Fix the low half

Faithfulness: do the claims follow from the chunks? Answer relevancy: does it address the question? Context precision: are useful chunks ranked above noise? Context recall: did search find what the reference answer needed? Answer correctness: does it match the expected answer? A judged score wobbles. A jump from 0.55 to 0.82 matters. A jump from 0.81 to 0.83 might be noise.

Starter

row = {
    "faithfulness": 0.4,   # answer invented facts
    "context_recall": 0.9, # the right chunk was there
}
print("fix the writer" if row["context_recall"] > row["faithfulness"] else "fix search")

Example

If recall is high and faithfulness is low, the prompt is letting the model improvise. If recall is low, stop tuning the writer. The fact never arrived.

RAGAS (Retrieval Augmented Generation Assessment) uses an LLM as a judge. A low number points at the part to fix. Five metrics cover the usual lab:

MetricQuestion it answersLow score means
FaithfulnessDo the claims in the answer follow from the retrieved chunks?The model added facts. Fix the prompt or the generator.
Answer relevancyDoes the answer address the question?The model wandered. Fix instructions.
Context precisionAre the useful chunks ranked above the noise?Retrieval ranking is messy. Rerank or retune k.
Context recallDid retrieval find the chunks needed for the reference answer?Search missed. Fix chunking or embeddings.
Answer correctnessDoes the answer match the expected answer?End-to-end miss. Read the other four to see why.

You can also score without a model: exact match, “does this string appear?”, overlap of chunk ids. Use those for cheap checks. Use the judge when the wording can vary and still be right.

One sample, a judge, an experiment

AnalogyA sample is one exam question with the pages the student was given, the answer they wrote, and the answer key.
Data flow
user_input→retrieved_contexts→response→reference→metric

Cheap checks need no judge: exact match, “does this string appear?”, overlap of chunk ids. Use a judge when the wording can vary. Pin the library version. Import paths move. The judge is another model call, so it costs money and it is not perfectly stable.

Starter

sample = {
    "user_input": "What is the return window for earbuds?",
    "retrieved_contexts": ["Returns: 30 days, unused, with receipt."],
    "response": "Unused earbuds can be returned within 30 days with a receipt.",
    "reference": "30 days, unused, with receipt.",
}
print(sample["reference"][:2], "matches the spirit of the response")

Example

Five real customer questions, each with the answer support would accept, beat a hundred toy questions. Include one whose correct answer is “I don’t know.”

A SingleTurnSample is one question, the chunks that came back, the answer, and the answer you expected.

from ragas import SingleTurnSample
from ragas.metrics import Faithfulness
from ragas.llms import llm_factory

sample = SingleTurnSample(
    user_input="What is the return window for NovaBuds?",
    retrieved_contexts=[
        "Returns: 30 days, unused, with receipt. Opened earbuds are final sale."
    ],
    response="You can return unused NovaBuds within 30 days with a receipt.",
    reference="Unused NovaBuds can be returned within 30 days with a receipt.",
)

# llm_factory wraps a hosted model so it can judge.
# judge = llm_factory("groq/openai/gpt-oss-120b")
# score = Faithfulness(llm=judge).score(sample)

The names in RAGAS 0.4: llm_factory for the judge, Dataset plus @experiment for a saved run over a file of goldens, and the older evaluate() one-call API you will see in most existing blog posts. Pin the version. The import paths have moved.

A five-question golden file

Write questions a real customer asks, each with the answer support would accept. “Is shipping free on a $40 order?” Expected: no, it is $6 under $50. If context recall is low, the shipping chunk was not retrieved. If faithfulness is low, the chunk was there and the model still said shipping was free.

Labs often use one key for the answering model (Groq) and another for embeddings (Gemini or a local model). The judge is a second model call. It is not free, and it is not perfectly stable. Read scores as a direction. A move from 0.55 to 0.82 matters. A move from 0.81 to 0.83 might be noise.

How to read a bad run

AnalogyRead a bad run from the bottom of the pipe upward. If the page never arrived, do not blame the writer.
Data flow
Low correctness→Check recall→Then faithfulness→Then precision→Change one thing

Sort questions by correctness. Low context recall means the generator never saw the fact. High recall and low faithfulness means the instruction is loose. Low precision with high recall means you retrieved the answer plus junk: lower k or rerank. Do not tune on the only test set you have.

Starter

recall, faith = 0.55, 0.90
if recall < 0.7:
    print("fix chunking, embeddings, or filters")
elif faith < 0.7:
    print("tighten the prompt and add a refusal example")

Example

A shipping question that fails recall is a missing chunk. The same question that fails faithfulness, with the chunk present, is a model that said shipping was free anyway.

  1. Sort questions by answer correctness.
  2. If context recall is low, stop. The generator never saw the fact. Inspect the chunk and the embedding of that question.
  3. If recall is high and faithfulness is low, the prompt is letting the model improvise. Tighten “use only the context” and show a refusal example.
  4. If precision is low but recall is high, you retrieved the answer plus a lot of junk. Lower k or rerank.
Do not tune on the test set only. Keep a handful of questions you never edit the prompt against. Otherwise the bot memorizes your quiz.

Practice

  • Add a golden whose answer is “I don’t know” because the catalog never mentions gift cards.
  • Break retrieval on purpose (k = 1 and a vague chunk) and predict which metric falls.
  • Write one sentence each for faithfulness vs answer relevancy, using the earbuds policy.
  • Name one metadata filter you would add if the catalog had two brands.