RAG Pipeline Testing: Chunk Quality, Retrieval Precision, and Hallucination Detection with RAGAS

RAG Pipeline Testing: Chunk Quality, Retrieval Precision, and Hallucination Detection with RAGAS

Retrieval-Augmented Generation (RAG) pipelines are complex systems with multiple failure points: chunking can destroy context, retrieval can miss relevant documents, and the LLM can hallucinate facts not present in the retrieved chunks. Testing a RAG system requires evaluating each stage and the full pipeline together.

This guide covers how to test RAG pipelines systematically, with a focus on RAGAS — the leading evaluation framework purpose-built for RAG assessment.

RAG Pipeline Failure Modes

Before testing, understand what can go wrong:

Chunking failures:

  • Chunks too small → context is cut mid-sentence, retrieval misses relevant passages
  • Chunks too large → retrieve irrelevant content along with relevant content
  • No overlap between chunks → ideas that span chunk boundaries get lost

Retrieval failures:

  • Low precision → retrieved chunks are not relevant to the query
  • Low recall → relevant chunks are not retrieved
  • Embedding model mismatch → query and document embeddings in different semantic spaces

Generation failures:

  • Hallucination → LLM generates facts not present in retrieved chunks
  • Context ignoring → LLM uses training knowledge instead of retrieved context
  • Answer irrelevance → response doesn't address the original question

Setting Up RAGAS

RAGAS (Retrieval Augmented Generation Assessment) is an open-source framework that provides standardized metrics for RAG evaluation.

pip install ragas langchain openai

RAGAS evaluates four core metrics:

  • Faithfulness — is the answer supported by the retrieved context?
  • Answer Relevancy — does the answer address the question?
  • Context Precision — are the retrieved chunks relevant to the question?
  • Context Recall — does the retrieved context cover the ground truth answer?

Basic RAGAS Evaluation

from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# Your RAG pipeline output
data = {
    "question": [
        "What is the refund policy?",
        "How do I cancel my subscription?",
        "What payment methods are accepted?"
    ],
    "answer": [
        "You can request a refund within 30 days of purchase.",
        "You can cancel anytime from your account settings.",
        "We accept Visa, Mastercard, and PayPal."
    ],
    "contexts": [
        [
            "All purchases come with a 30-day money-back guarantee. "
            "To request a refund, contact support@example.com."
        ],
        [
            "Subscriptions can be cancelled at any time. "
            "Log into your account, go to Settings > Subscription > Cancel."
        ],
        [
            "Accepted payment methods include Visa, Mastercard, American Express, "
            "PayPal, and Apple Pay."
        ]
    ],
    "ground_truth": [  # required for context_recall
        "Refund is available within 30 days of purchase.",
        "Cancel through account settings under Subscription.",
        "Visa, Mastercard, American Express, PayPal, and Apple Pay are accepted."
    ]
}

dataset = Dataset.from_dict(data)

results = evaluate(
    dataset,
    metrics=[
        faithfulness,
        answer_relevancy,
        context_precision,
        context_recall,
    ]
)

print(results)
# {'faithfulness': 0.97, 'answer_relevancy': 0.92, 
#  'context_precision': 0.89, 'context_recall': 0.78}

Running RAGAS in Tests

Wrap RAGAS evaluations in pytest assertions:

import pytest
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset
import your_rag_pipeline  # your actual RAG implementation

# Test questions with known good answers
TEST_CASES = [
    {
        "question": "What is the return policy?",
        "ground_truth": "Items can be returned within 30 days for a full refund."
    },
    {
        "question": "Is there a free trial?",
        "ground_truth": "Yes, a 14-day free trial is available with no credit card required."
    },
]

THRESHOLDS = {
    "faithfulness": 0.85,
    "answer_relevancy": 0.80,
    "context_precision": 0.75,
}

def run_rag(question: str) -> tuple[str, list[str]]:
    """Run your RAG pipeline, return (answer, retrieved_contexts)"""
    return your_rag_pipeline.query(question)

@pytest.mark.llm
def test_rag_quality_metrics():
    rows = {"question": [], "answer": [], "contexts": [], "ground_truth": []}

    for case in TEST_CASES:
        answer, contexts = run_rag(case["question"])
        rows["question"].append(case["question"])
        rows["answer"].append(answer)
        rows["contexts"].append(contexts)
        rows["ground_truth"].append(case["ground_truth"])

    dataset = Dataset.from_dict(rows)
    results = evaluate(dataset, metrics=[faithfulness, answer_relevancy, context_precision])

    failures = []
    for metric, threshold in THRESHOLDS.items():
        score = results[metric]
        if score < threshold:
            failures.append(f"{metric}: {score:.2f} < {threshold}")

    assert not failures, f"RAG quality below thresholds:\n" + "\n".join(failures)

Testing Faithfulness (Hallucination Detection)

Faithfulness is the most critical RAG metric. An unfaithful answer contains claims not supported by the retrieved context — in other words, hallucinations.

from ragas.metrics import faithfulness

@pytest.mark.llm
def test_no_hallucinations():
    """Answers must be grounded in retrieved context"""
    test_question = "What are the system requirements?"
    answer, contexts = run_rag(test_question)

    dataset = Dataset.from_dict({
        "question": [test_question],
        "answer": [answer],
        "contexts": [contexts],
        "ground_truth": [""]  # not needed for faithfulness
    })

    result = evaluate(dataset, metrics=[faithfulness])
    score = result["faithfulness"]

    assert score >= 0.90, (
        f"Hallucination detected! Faithfulness score: {score:.2f}\n"
        f"Answer: {answer}\n"
        f"Context: {contexts}"
    )

Manual Faithfulness Check

For debugging, you can build a simpler hallucination checker:

from openai import OpenAI

client = OpenAI()

def check_faithfulness(answer: str, context: str) -> dict:
    prompt = f"""Given this context and answer, identify any claims in the answer 
that are NOT supported by the context.

Context: {context}

Answer: {answer}

Return JSON: {{
    "is_faithful": true/false,
    "unsupported_claims": ["claim1", "claim2"],
    "explanation": "..."
}}"""

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"}
    )
    return json.loads(response.choices[0].message.content)

def test_specific_answer_faithfulness():
    answer, contexts = run_rag("What is the pricing?")
    result = check_faithfulness(answer, "\n".join(contexts))

    assert result["is_faithful"], (
        f"Unfaithful answer detected.\n"
        f"Unsupported claims: {result['unsupported_claims']}\n"
        f"Explanation: {result['explanation']}"
    )

Testing Retrieval Quality

Poor retrieval is often the root cause of poor RAG answers. Test retrieval separately:

def test_retrieval_precision():
    """Retrieved chunks should be relevant to the query"""
    query = "cancellation policy"
    retrieved_chunks = your_rag_pipeline.retrieve(query, k=5)

    # Each chunk should mention cancellation-related content
    relevant_keywords = ["cancel", "cancellation", "terminate", "end subscription"]
    relevant_count = sum(
        1 for chunk in retrieved_chunks
        if any(kw in chunk.lower() for kw in relevant_keywords)
    )

    precision = relevant_count / len(retrieved_chunks)
    assert precision >= 0.6, f"Retrieval precision too low: {precision:.2f}"

def test_retrieval_recall():
    """All relevant documents should be retrieved"""
    # These chunks are known to be relevant (from your document set)
    KNOWN_RELEVANT_DOCS = [
        "doc_cancellation_policy",
        "doc_subscription_terms",
    ]

    query = "how to cancel"
    retrieved_chunks = your_rag_pipeline.retrieve(query, k=10)
    retrieved_ids = {chunk.metadata["doc_id"] for chunk in retrieved_chunks}

    recalled = sum(1 for doc in KNOWN_RELEVANT_DOCS if doc in retrieved_ids)
    recall = recalled / len(KNOWN_RELEVANT_DOCS)

    assert recall >= 0.8, f"Retrieval recall too low: {recall:.2f}"

Testing Chunk Quality

The chunking strategy dramatically affects retrieval. Test it explicitly:

from langchain.text_splitter import RecursiveCharacterTextSplitter

def test_chunk_size_distribution():
    """Chunks should be within acceptable size bounds"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=512,
        chunk_overlap=50,
    )

    with open("docs/policy.txt") as f:
        text = f.read()

    chunks = splitter.split_text(text)
    chunk_lengths = [len(chunk.split()) for chunk in chunks]

    # No chunk should be under 50 words (too small = context lost)
    assert min(chunk_lengths) >= 50, f"Chunk too small: {min(chunk_lengths)} words"

    # No chunk should be over 600 words (too large = retrieval noise)
    assert max(chunk_lengths) <= 600, f"Chunk too large: {max(chunk_lengths)} words"

    # Most chunks should be near the target size
    avg_length = sum(chunk_lengths) / len(chunk_lengths)
    assert 100 <= avg_length <= 400, f"Average chunk size {avg_length} is outside acceptable range"

def test_no_mid_sentence_splits():
    """Chunks should not start or end mid-sentence"""
    splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)
    chunks = splitter.split_text(open("docs/policy.txt").read())

    for i, chunk in enumerate(chunks):
        # Chunk should end with sentence-ending punctuation (or be the last chunk)
        if i < len(chunks) - 1:
            assert chunk.rstrip()[-1] in ".!?", (
                f"Chunk {i} ends mid-sentence: ...{chunk[-50:]}"
            )

End-to-End RAG Test Suite

Combine all the above into a comprehensive test suite:

# tests/test_rag_pipeline.py
import pytest
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall

# Mark slow tests — skip in fast CI runs
pytestmark = pytest.mark.slow

class TestRAGPipeline:
    def setup_method(self):
        self.rag = RAGPipeline()

        # Representative test cases with ground truth
        self.test_cases = load_test_cases("tests/fixtures/rag_test_cases.json")

    def test_faithfulness_above_threshold(self):
        """No hallucinations — answers must be grounded in retrieved context"""
        results = self._evaluate_metrics([faithfulness])
        assert results["faithfulness"] >= 0.90

    def test_answer_relevancy_above_threshold(self):
        """Answers must address the question asked"""
        results = self._evaluate_metrics([answer_relevancy])
        assert results["answer_relevancy"] >= 0.85

    def test_context_precision_above_threshold(self):
        """Retrieved context must be relevant"""
        results = self._evaluate_metrics([context_precision])
        assert results["context_precision"] >= 0.75

    def test_context_recall_above_threshold(self):
        """Must retrieve enough relevant context"""
        results = self._evaluate_metrics([context_recall])
        assert results["context_recall"] >= 0.70

    def _evaluate_metrics(self, metrics):
        rows = {"question": [], "answer": [], "contexts": [], "ground_truth": []}
        for case in self.test_cases:
            answer, contexts = self.rag.query(case["question"])
            rows["question"].append(case["question"])
            rows["answer"].append(answer)
            rows["contexts"].append(contexts)
            rows["ground_truth"].append(case["ground_truth"])

        return evaluate(Dataset.from_dict(rows), metrics=metrics)

CI/CD Pipeline

name: RAG Tests
on:
  push:
    paths:
      - 'rag/**'
      - 'prompts/**'
      - 'docs/**'

jobs:
  rag-eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install
        run: pip install ragas langchain openai datasets pytest

      - name: Run RAG tests
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: pytest tests/test_rag_pipeline.py -v -m "not slow"

      - name: Full evaluation (on merge to main)
        if: github.ref == 'refs/heads/main'
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: pytest tests/test_rag_pipeline.py -v

Baseline and Regression Tracking

Track your RAGAS scores over time to catch gradual degradation:

import json
from pathlib import Path
from datetime import date

def save_metrics_history(results: dict):
    history_file = Path("tests/metrics_history.json")
    history = json.loads(history_file.read_text()) if history_file.exists() else []
    history.append({
        "date": str(date.today()),
        **{k: round(v, 3) for k, v in results.items()}
    })
    history_file.write_text(json.dumps(history, indent=2))

def test_no_metric_regression():
    """Metrics should not drop more than 5% from last run"""
    history_file = Path("tests/metrics_history.json")
    if not history_file.exists():
        pytest.skip("No history yet")

    history = json.loads(history_file.read_text())
    last_run = history[-1]

    current = evaluate(...)  # run evaluation

    for metric in ["faithfulness", "answer_relevancy", "context_precision"]:
        drop = last_run[metric] - current[metric]
        assert drop <= 0.05, (
            f"{metric} dropped {drop:.2f} from {last_run[metric]:.2f} "
            f"to {current[metric]:.2f}"
        )

RAG testing is not optional — it's how you catch the hallucinations, retrieval failures, and chunk quality issues that silently degrade user experience. Start with RAGAS on a small test set, gate your CI pipeline on faithfulness and answer relevancy, and you'll catch 80% of RAG failures before they reach production.

Read more

Start now free