Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
Filter by Categories
About Article
AI Tools
Analyze Data
Archive
Best Practices
Better Outputs
Blog
Code Optimization
Code Quality
Command Line
Course
Daily tips
Dashboard
Data Analysis & Manipulation
Data Engineer
Data Visualization
DataFrame
Delta Lake
DevOps
DuckDB
Environment Management
Feature Engineer
Git
Jupyter Notebook
LLM
LLM Tools
Machine Learning
Machine Learning & AI
Machine Learning Tools
Manage Data
MLOps
Natural Language Processing
Newsletter Archive
NumPy
Pandas
Polars
PySpark
Python Helpers
Python Tips
Python Utilities
Scrape Data
SQL
Testing
Time Series
Tools
Visualization
Visualization & Reporting
Workflow & Automation
Workflow Automation

Stop Treating Your RAG Grounding Score as a Safety Net

Stop Treating Your RAG Grounding Score as a Safety Net

Table of Contents

Introduction

A grounding checker can look reliable on a benchmark because many benchmark errors are easy to see: long unsupported passages, extra details, or invented explanations.

But doing well on those cases does not mean the checker will catch every unsupported claim.

Your application may need it to catch smaller errors: a policy answer flipped by one “not,” or a pricing, healthcare, or logistics answer changed by one digit.

For example:

  policy source     You can export data after approval.
  wrong answer      You cannot export data after approval.
                              ^^^

  pricing source    The monthly plan costs $15.
  wrong answer      The monthly plan costs $25.
                                      ^

I wanted to test whether benchmark performance transfers to those errors, or whether the checker is mostly good at catching larger unsupported passages.

Why this matters to you right now

If you are using a grounding checker in any of these settings, the benchmark score may not tell you enough:

  • A policy assistant that must catch words like “not,” “never,” or “must.”
  • A finance, healthcare, pricing, or logistics assistant that must catch wrong values.
  • A production RAG app where unsupported answers are blocked only when the checker flags them.

This article shows how the checker behaves on three error types: benchmark hallucinations, one flipped negation, and one changed number.

The hypothesis

The hypothesis is simple: if a checker really understands grounding, it should flag unsupported claims no matter how small they are.

expected behavior

large unsupported passage    flagged
changed number               flagged
flipped negation             flagged

That is the behavior you would want in production. A wrong number or one flipped “not” can matter just as much as several unsupported sentences.

The experiment below tests whether the checker behaves that way.

Stay Current with CodeCut

Easy-to-digest articles on Python, AI, and open-source tools. Delivered twice a week.

The setup

What a grounding checker checks

A grounding checker tests whether the retrieved document supports the LLM-generated answer.

For example, if the source says the refund window is 30 days but the answer says 60 days, the checker should flag that claim as unsupported.

  THE DOCUMENT                  THE ANSWER                 THE CHECKER SHOULD SAY

  "Returns are accepted    +    "You can return       ->   unsupported
   within 30 days of            items within               "60 days" appears
   purchase."                   60 days."                  nowhere in the document

  "Returns are accepted    +    "You can return       ->   supported
   within 30 days of            items within               every claim traces
   purchase."                   30 days."                  back to the source

This experiment uses LettuceDetect, an open-source checker for unsupported spans in RAG answers. I chose it because it can run locally and deterministically, which makes the results easier to reproduce.

Install the packages

Install the packages:

uv venv --python 3.11 .venv
uv pip install --python .venv/bin/python datasets scikit-learn numpy lettucedetect

This article uses lettucedetect v0.2.3, datasets v5.0.1, and scikit-learn v1.9.0.

Initialize the detector

First, initialize LettuceDetect with the ModernBERT checkpoint used in this experiment:

from lettucedetect.models.inference import HallucinationDetector

detector = HallucinationDetector(
    method="transformer",
    model_path="KRLabsOrg/lettucedect-base-modernbert-en-v1",
)

Build the RAGTruth sample

The experiment uses 200 RAGTruth QA answers: 100 with hallucinated text and 100 clean. This checks whether the model can tell unsupported answers apart from supported ones.

import json
import random

from datasets import load_dataset

rows = load_dataset("wandb/RAGTruth-processed", split="test")
qa_rows = [row for row in rows if row["task_type"] == "QA"]


def is_hallucinated(row):
    return len(json.loads(row["hallucination_labels"])) > 0


hallucinated = [row for row in qa_rows if is_hallucinated(row)]
clean = [row for row in qa_rows if not is_hallucinated(row)]

rng = random.Random(0)
rng.shuffle(hallucinated)
rng.shuffle(clean)
sample = hallucinated[:100] + clean[:100]
rng.shuffle(sample)

cases = [
    {
        "id": str(row["id"]),
        "query": row["query"],
        "answer": row["output"],
        "context": row["context"],
        "hallucinated": is_hallucinated(row),
    }
    for row in sample
]

print(f"{len(cases)} cases, {sum(case['hallucinated'] for case in cases)} hallucinated")
Output
200 cases, 100 hallucinated

The scoring mechanism

Before scoring the full sample, let’s look at one RAGTruth example to understand how the checker works.

case = next(case for case in cases if case["id"] == "12045")

source_document = case["context"]
question = case["query"]
answer = case["answer"].replace("\n", " ")
source_start = source_document.find("If using a gas grill")

print("question:", question)
print()
print(
    "source:",
    source_document[source_start : source_start + 220].replace("\n", " "),
    "...",
)
print()
print("answer:", answer[answer.find("7. Cook") : answer.find("8. Remove")].strip())
print("answer:", answer[answer.find("8. Remove") :].strip())
Output
question: how to grill a porterhouse

source: If using a gas grill, set it up for three-zone grilling.  1 Leave the section closest to you coal-free. 2  Replace the grill grate. 3  If using a gas grill, set it up for three-zone grilling. 4  Keep it hot, keep it clea ...

answer: 7. Cook for 4-5 minutes per side, or until the internal temperature reaches your desired doneness (unspecified in passages)
answer: 8. Remove from the grill and let rest for 5-10 minutes before slicing and serving (unspecified in passages)

The source explains how to set up the grill, but it does not say how long to cook the steak or how long to let it rest. Those timing instructions are added by the answer. Let’s see if LettuceDetect can catch those unsupported claims.

Turn checker output into a score

First, ask LettuceDetect for spans. A span is a phrase in the answer that the checker thinks is unsupported by the document. If the checker finds no unsupported phrase, it returns an empty list.

spans = detector.predict(
    context=[source_document],
    question=question,
    answer=answer,
    output_format="spans",
)

for span in sorted(spans, key=lambda span: span["confidence"], reverse=True):
    print(f"{span['confidence']:.3f}: {span['text']}")
Output
0.988: 8. Remove from the grill and let rest for 5-10 minutes before slicing and serving (unspecified in passages)
0.983: 7. Cook for 4-5 minutes per side, or until the internal temperature reaches your desired doneness (unspecified in passages)
0.898: . Place the Porterhouse steak on the grill, bone side down (Passage 3)

LettuceDetect gives the strongest span a confidence of 0.988, meaning it is highly confident the span is unsupported. This flagging makes sense: the source mentions grill setup, but it does not give the steak cooking time or resting time.

To turn spans into one score per answer, we could count how many spans were flagged:

print(f"span count: {len(spans)}")
Output
span count: 3

The problem is that longer answers can naturally produce more spans. To avoid making answer length part of the score too early, switch to token output instead.

Token output gives each small piece of the answer a suspicion score between 0 and 1, which is easier to compare across answers of different lengths.

tokens = detector.predict(
    context=[source_document],
    question=question,
    answer=answer,
    output_format="tokens",
)

for token in sorted(tokens, key=lambda token: token["prob"], reverse=True)[:8]:
    print(f"{token['prob']:.3f}: {token['token']!r}")
Output
0.988: '-'
0.987: '10'
0.983: ' 4'
0.979: ' 5'
0.976: ' minutes'
0.973: ' minutes'
0.973: '-'
0.969: '5'

The highest-scoring tokens come from the same unsupported timing details that appeared in the span output. Token scores stay on a 0 to 1 scale. The closer a score is to 1, the more confident the checker is that the token is unsupported.

One answer now has many token scores, not one score. To reduce them to a single answer-level score, take the maximum token probability.

def score_by_token(tokens):
    return max(token["prob"] for token in tokens)

To flag unsupported answers, define two helpers: one returns the answer score, and the other checks whether that score is high enough to count as a flag.

def score_answer(case):
    tokens = detector.predict(
        context=[case["context"]],
        question=case["query"],
        answer=case["answer"],
        output_format="tokens",
    )
    return score_by_token(tokens)


def is_flagged(case, threshold=0.5):
    return score_answer(case) > threshold

Run both helpers on the Porterhouse example:

print(f"token score: {score_answer(case):.3f}")
print(f"flagged: {is_flagged(case)}")
Output
token score: 0.988
flagged: True

A score of 0.988 means the checker is highly confident that some part of the answer is unsupported. Since it is above 0.5, the answer is flagged.

The benchmark score

Now score all 200 answers with their own documents:

flagged = {True: 0, False: 0}
for case in cases:
    if is_flagged(case):
        flagged[case["hallucinated"]] += 1

print(f"hallucinated answers flagged: {flagged[True]} of 100")
print(f"clean answers flagged: {flagged[False]} of 100")
Output
hallucinated answers flagged: 75 of 100
clean answers flagged: 13 of 100

This is a strong baseline: the checker catches most hallucinated answers while only flagging a small number of clean ones.

This is the same kind of quality question I explored in the MLflow RAG evaluation guide, but here I focus on smaller controlled errors.

Next, calculate the typical amount of unsupported text in the hallucinated answers:

import statistics

unsupported_lengths = [
    sum(len(label["text"]) for label in json.loads(row["hallucination_labels"]))
    for row in qa_rows
    if is_hallucinated(row)
]

print(f"answers: {len(unsupported_lengths)}")
print(f"median unsupported text: {int(statistics.median(unsupported_lengths))} characters")
Output
answers: 160
median unsupported text: 134 characters

This gives useful context for the baseline result: many benchmark hallucinations are fairly large pieces of unsupported text, often equivalent to one or two sentences.

That raises a narrower question: what happens when the unsupported claim is tiny? The next two tests change only one negation or one number and measure whether the checker catches it.

Flip one negation

Negation is a small edit with a large effect. Adding “not” can turn a supported claim into its opposite.

Here is one clean answer before and after a negation flip:

  answer     To peel potatoes quickly, you can follow these steps
                                           ^^^
  flipped    To peel potatoes quickly, you cannot follow these steps
                                           ^^^^^^

To build many negated examples, I created flip_a_negation() in run_negation.py. On a clean answer about peeling potatoes, it changes “you can follow” to “you cannot follow”:

Output
original: To peel potatoes quickly, you can follow these steps:  1.
negated:  To peel potatoes quickly, you cannot follow these steps:  1.

original score: 0.000
negated score: 0.000

The negated answer should get a higher score because it now contradicts the source. Instead, it stays at 0.000, so the checker is treating an unsupported answer as if it were still supported.

To see whether this is a pattern, I ran run_negation.py across 100 clean answers and score each answer before and after the edit:

Answerflagged
unchanged13 of 100
one negation flipped17 of 100

Only 4 more answers are flagged after every answer gets a negation flip. That means the checker is not reliably detecting this kind of contradiction.

Change one number

I wanted to know whether this was specific to negation or a broader problem with small edits, so I repeated the test with one changed number.

This time, start with a number the source supports, then change one digit so the answer gives a value the source never mentions:

  answer     Boil the potatoes for about 15 minutes
                                         ^
  changed    Boil the potatoes for about 25 minutes
                                         ^

To create the number test set, I wrote run_numbers.py. For each clean answer, it changes one supported number to a value the source does not mention.

On the same potato answer, the number test changes “about 15 minutes” to “about 25 minutes”:

Output
original: Boil the potatoes for about 15 minutes.
changed:  Boil the potatoes for about 25 minutes.

original score: 0.000
changed score: 0.010

The changed answer should get a higher score because the source says 15 minutes, not 25. Instead, the score stays close to 0, so the checker is treating the wrong number as if it were still supported.

Then I ran run_numbers.py on 100 clean answers to see how often the checker flags the changed version:

Answerflagged
unchanged16 of 100
one number changed48 of 100

The number edit is caught more often than the negation flip. But even here, more than half of the changed answers are still not flagged.

What the results show

Here is the full comparison across the three error types:

Unsupported claimsizecaught
invented passage~134 chars75 of 100
changed number~2 chars48 of 100
flipped negation~4 chars17 of 100
nothing changed013 of 100

The pattern is clear:

  • Large invented passages are usually detected.
  • One-digit number changes are detected about half the time.
  • One-word negation flips are almost missed entirely.

This means the checker is more likely to catch large unsupported text than small edits, even when the small edits are important.

What to change in your system

From this experiment, I learned that a strong benchmark result does not guarantee good performance on every error. LettuceDetect caught many large unsupported passages, but often missed edits that changed only one word or one number.

Because of that, do not adopt a checker on benchmark performance alone. It should earn trust on the exact errors your application needs it to catch:

  • If your app answers policy questions, test negation flips.
  • If it answers finance, healthcare, pricing, or logistics questions, test numbers, dates, thresholds, and units.

You do not need a large benchmark for this. A small set of targeted examples can reveal the blind spots that matter.

Running the code yourself

The full scripts and recorded results are in the companion repository.

To reproduce the three measurements, run:

cd delete-the-evidence-rag-grounding-control

python run_sentences.py --stage report
python run_negation.py --stage report
python run_numbers.py --stage report

The scripts also save the generated examples and raw scores as JSON, so you can inspect the cases behind each summary table.

Key takeaways

  • A grounding checker can look strong on benchmark hallucinations and still miss smaller errors.
  • Error size matters: long unsupported passages are easier to catch than a changed word or digit.
  • Test the checker against the failure modes your application depends on, not only against a general benchmark score.

Stay Current with CodeCut

Easy-to-digest articles on Python, AI, and open-source tools. Delivered twice a week.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top

Work with Khuyen Tran

Work with Khuyen Tran