Continuous LLM Evaluation in CI/CD: Automated Regression with DeepEval & Ragas


Introduction
The rapid proliferation of Large Language Models (LLMs) has revolutionized how we build applications, from intelligent chatbots to advanced content generation systems. However, the very nature of LLMs – their non-deterministic outputs, sensitivity to prompt changes, and potential for "hallucinations" – introduces a significant challenge: how do we ensure the quality, reliability, and consistency of LLM-powered features over time?
Traditional software testing methodologies, while foundational, often fall short when dealing with the nuanced and often subjective outputs of generative AI. This is where Continuous LLM Evaluation in CI/CD becomes not just a best practice, but a critical necessity. Integrating automated evaluation into your Continuous Integration/Continuous Deployment (CI/CD) pipelines allows you to catch regressions, detect model drift, and maintain high standards of performance and safety with every code change.
This comprehensive guide will walk you through the "how" and "why" of establishing a robust continuous evaluation pipeline for your LLM applications. We'll focus on two powerful open-source libraries: DeepEval for unit-testing individual LLM responses and integrating into existing test frameworks, and Ragas for deeply assessing Retrieval-Augmented Generation (RAG) systems. Together, they form a synergistic approach to automated regression testing, empowering you to build and deploy LLM features with confidence.
The Challenge of LLM Quality Assurance
Unlike traditional software where an output is either correct or incorrect, LLM outputs exist on a spectrum of quality. Evaluating them involves assessing multiple dimensions:
- Factual Accuracy: Is the information provided correct?
- Relevance: Is the output pertinent to the user's query?
- Coherence/Fluency: Is the language natural and easy to understand?
- Completeness: Does it address all aspects of the query?
- Conciseness: Is it free from unnecessary verbosity?
- Harmfulness/Bias: Does it contain offensive, biased, or unsafe content?
- Hallucination: Does it generate information that is plausible but factually incorrect?
- Context Adherence: For RAG systems, does it correctly use the provided context?
Manual evaluation is slow, expensive, and non-scalable, especially with frequent updates. Moreover, small changes to a prompt, a different version of an embedding model, or an update to your RAG knowledge base can subtly (or drastically) alter LLM behavior, leading to regressions that are hard to spot without automated checks.
Why Continuous LLM Evaluation is Crucial
Integrating LLM evaluation into your CI/CD pipeline offers several compelling benefits:
- Early Regression Detection: Catch performance degradations, factual errors, or shifts in tone immediately after a code change, preventing them from reaching production.
- Accelerated Iteration: Developers can experiment with prompts, models, and RAG configurations more confidently, knowing that automated guards are in place.
- Consistent Quality: Maintain a baseline quality standard for your LLM applications, ensuring a reliable user experience.
- Cost Efficiency: Automated evaluation is significantly cheaper than manual human review at scale.
- Responsible AI: Proactively identify and mitigate issues like bias, toxicity, or privacy breaches before they become critical.
- Data-Driven Development: Gain objective metrics to inform model selection, prompt optimization, and RAG improvements.
Prerequisites
Before diving into the code, ensure you have the following:
- Python 3.8+: Essential for both DeepEval and Ragas.
- pip: Python package installer.
- OpenAI API Key: Or access to another LLM provider (e.g., Hugging Face, Cohere) for evaluation metrics and the LLM under test. Store it securely as an environment variable.
- Basic understanding of LLMs and RAG: Familiarity with how these systems work.
- A CI/CD environment: Such as GitHub Actions, GitLab CI, Jenkins, etc.
Introducing DeepEval and Ragas: A Synergistic Approach
To tackle the multifaceted challenge of LLM evaluation, we'll leverage two leading open-source frameworks:
DeepEval: Unit Testing for LLMs
DeepEval is designed for unit-testing LLM outputs. It allows you to write specific test cases for your LLM calls, define expected behaviors, and use a suite of built-in metrics (or create custom ones) to assess performance. Its integration with standard Python testing frameworks (like pytest) makes it easy to incorporate into existing development workflows. DeepEval is excellent for verifying individual prompt responses, ensuring safety, and checking for specific output formats.
Ragas: Robust Evaluation for RAG Systems
Ragas (Retrieval Augmented Generation Assessment) specializes in evaluating RAG pipelines. RAG systems combine an LLM with an external knowledge base to generate more informed and grounded responses. Ragas provides a set of specific metrics (e.g., Faithfulness, Context Relevancy, Answer Relevancy, Context Recall) that assess how well a RAG system retrieves relevant information and uses it to formulate accurate answers, minimizing hallucinations. Ragas focuses on the end-to-end quality of the RAG flow.
How they complement each other: DeepEval can test individual components or general LLM interactions. Ragas provides a deeper, more specialized lens for RAG systems. By using both, you get comprehensive coverage: DeepEval for direct LLM quality and Ragas for the integrity of your RAG architecture.
Setting Up Your LLM Evaluation Environment
First, let's install the necessary libraries:
pip install deepeval ragas openai "deepeval[llama_index]" # deepeval[llama_index] for RAG specific testsNext, set up your OpenAI API key as an environment variable. Both DeepEval and Ragas often use LLMs themselves to evaluate outputs, making an API key essential.
export OPENAI_API_KEY="your_openai_api_key_here"For this guide, we'll assume a basic project structure:
my_llm_app/
├── app.py # Your LLM application logic
├── tests/
│ ├── test_llm_units.py # DeepEval unit tests
│ └── test_rag_system.py # Ragas RAG evaluation
└── data/
└── rag_eval_dataset.jsonl # Ragas evaluation dataset
DeepEval for Unit Testing LLM Outputs
DeepEval allows you to write tests that assert properties of LLM responses. Let's create a simple LLM function and then test it.
Suppose app.py has a function:
# app.py
from openai import OpenAI
client = OpenAI()
def generate_simple_answer(query: str) -> str:
try:
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": query}
],
temperature=0.7
)
return response.choices[0].message.content
except Exception as e:
return f"Error: {e}"Now, let's write a DeepEval test in tests/test_llm_units.py:
# tests/test_llm_units.py
import pytest
from deepeval import assert_test
from deepeval.metrics import HallucinationMetric, AnswerRelevancyMetric, ToxicityMetric
from deepeval.test_case import LLMTestCase
# Assuming generate_simple_answer is imported from your app.py
# For simplicity, we'll define a mock or assume it's directly accessible
# from app import generate_simple_answer
# Mocking the LLM interaction for demonstration if app.py isn't fully set up
class MockLLMResponse:
def __init__(self, content):
self.content = content
class MockLLM:
def __init__(self, mock_responses):
self.mock_responses = mock_responses
self.call_count = 0
def chat_completions_create(self, **kwargs):
# In a real scenario, this would call your actual LLM function
# For testing purposes, we'll return predefined responses.
response_content = self.mock_responses[self.call_count]
self.call_count += 1
class Choice:
def __init__(self, message_content):
self.message = MockLLMResponse(message_content)
class Choices:
def __init__(self, choice_list):
self.choices = choice_list
return Choices([Choice(response_content)])
# Replace the actual OpenAI client with our mock for testing purposes
# In a real setup, you'd mock the client or pass a testable function
# For this example, let's just use a simple function that returns fixed responses
def generate_simple_answer_mock(query: str) -> str:
if "capital of France" in query:
return "Paris is the capital of France."
if "flying cars" in query:
return "While flying cars are a futuristic concept, current technology focuses on electric vertical take-off and landing (eVTOL) aircraft, which are more like air taxis."
return "I don't know the answer to that."
@pytest.mark.deepeval_test
def test_simple_answer_correctness():
query = "What is the capital of France?"
expected_output = "Paris is the capital of France."
actual_output = generate_simple_answer_mock(query)
# DeepEval test case with metrics
test_case = LLMTestCase(
input=query,
actual_output=actual_output,
expected_output=expected_output,
# You can add context if it were a RAG system, but not needed here
)
# Define metrics to evaluate
# HallucinationMetric checks if the LLM output deviates from factual truth
# AnswerRelevancyMetric checks if the answer is relevant to the input
# ToxicityMetric checks for harmful content
metrics = [
HallucinationMetric(threshold=0.5, model="gpt-4"), # Use a stronger model for evaluation
AnswerRelevancyMetric(threshold=0.7, model="gpt-4"),
ToxicityMetric(threshold=0.5, model="gpt-4")
]
# Assert the test case, running all defined metrics
assert_test(test_case, metrics)
@pytest.mark.deepeval_test
def test_flying_cars_relevancy_and_hallucination():
query = "Tell me about flying cars."
actual_output = generate_simple_answer_mock(query) # Using the mock here
test_case = LLMTestCase(
input=query,
actual_output=actual_output,
# For open-ended questions, you might not have a single expected_output
# but rely on metrics like AnswerRelevancy, Hallucination, etc.
)
metrics = [
HallucinationMetric(threshold=0.5, model="gpt-4"),
AnswerRelevancyMetric(threshold=0.7, model="gpt-4")
]
assert_test(test_case, metrics)To run these tests, you'd simply execute pytest in your terminal:
pytest tests/test_llm_units.pyDeepEval will execute each test, run the specified metrics, and report the scores. If any metric falls below its threshold, the test will fail.
Ragas for Robust RAG System Evaluation
Ragas is purpose-built for RAG systems. Let's imagine you have a RAG pipeline that retrieves documents and then generates an answer. A simplified app.py RAG function might look like this:
# app.py (simplified RAG example)
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.schema import Document
from openai import OpenAI
client = OpenAI()
# This would typically be loaded from a persistent store
# For demonstration, let's create a small in-memory index
def create_rag_pipeline(documents_text: list[str]):
docs = [Document(text=t) for t in documents_text]
index = VectorStoreIndex.from_documents(docs)
return index.as_query_engine()
# Example documents for our RAG system
sample_documents = [
"The capital of France is Paris. Paris is known for its Eiffel Tower.",
"The Louvre Museum is located in Paris, France, and is home to the Mona Lisa.",
"Germany's capital is Berlin. The Brandenburg Gate is a famous landmark in Berlin."
]
query_engine = create_rag_pipeline(sample_documents)
def ask_rag_system(query: str) -> str:
try:
response = query_engine.query(query)
return str(response)
except Exception as e:
return f"Error: {e}"To evaluate this RAG system with Ragas, we need a dataset of questions, ground truths, and contexts. Ragas can generate contexts by running the retrieval part of your RAG pipeline. Let's create a dataset and then evaluate.
# tests/test_rag_system.py
import os
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall, context_relevancy
from openai import OpenAI
# Assuming ask_rag_system and query_engine are available from app.py
# For testing, we'll mock or simplify the RAG interaction
# A simple mock RAG system for demonstration
class MockRagSystem:
def __init__(self, documents):
self.documents = documents
def retrieve(self, query: str) -> list[str]:
# Simulate retrieval based on keywords
retrieved_docs = []
query_lower = query.lower()
for doc in self.documents:
if any(keyword in doc.lower() for keyword in query_lower.split()):
retrieved_docs.append(doc)
return retrieved_docs if retrieved_docs else ["No relevant context found."]
def generate_answer(self, query: str, context: list[str]) -> str:
# Simulate LLM generation
if "capital of France" in query.lower() and "paris" in " ".join(context).lower():
return "The capital of France is Paris."
elif "louvre" in query.lower() and "mona lisa" in " ".join(context).lower():
return "The Louvre Museum in Paris houses the Mona Lisa."
elif "capital of germany" in query.lower() and "berlin" in " ".join(context).lower():
return "The capital of Germany is Berlin."
else:
return f"Based on the context '{context}', I cannot provide a definitive answer to '{query}'."
# Initialize our mock RAG system
mock_rag_docs = [
"The capital of France is Paris. Paris is known for its Eiffel Tower.",
"The Louvre Museum is located in Paris, France, and is home to the Mona Lisa.",
"Germany's capital is Berlin. The Brandenburg Gate is a famous landmark in Berlin."
]
mock_rag = MockRagSystem(mock_rag_docs)
# Create an evaluation dataset
# In a real scenario, you'd have a larger, curated dataset.
# The 'contexts' and 'answer' fields will be populated by Ragas itself if not provided.
data_samples = {
"question": [
"What is the capital of France?",
"Where is the Mona Lisa located?",
"What is the capital of Germany?",
"Tell me about the climate of Mars."
],
"ground_truths": [
["Paris"],
["Louvre Museum in Paris", "Paris"],
["Berlin"],
["Mars has a cold, dusty desert-like climate."] # Even if RAG can't answer, provide a ground truth
]
}
# Convert to Hugging Face Dataset format
dataset = Dataset.from_dict(data_samples)
# Now, populate the dataset with 'answer' and 'contexts' by running the RAG system
def generate_answers_and_contexts(samples):
answers = []
contexts = []
for q in samples["question"]:
retrieved_context = mock_rag.retrieve(q)
answers.append(mock_rag.generate_answer(q, retrieved_context))
contexts.append(retrieved_context)
samples["answer"] = answers
samples["contexts"] = contexts
return samples
eval_dataset = dataset.map(generate_answers_and_contexts, batched=True)
# Print a sample to verify
print("\n--- Ragas Evaluation Dataset Sample ---")
print(eval_dataset[0])
print("---------------------------------------\n")
def test_rag_system_quality():
# Define the metrics to evaluate
metrics = [
faithfulness,
answer_relevancy,
context_recall,
context_relevancy,
]
# Evaluate the dataset with Ragas
result = evaluate(
eval_dataset,
metrics=metrics,
# Provide the OpenAI client directly or ensure API key is set
llm=OpenAI(),
embeddings=OpenAI(), # Ragas uses embeddings for some metrics
is_async=False # Set to True for larger datasets for speed
)
# Convert results to a pandas DataFrame for easy viewing
df = result.to_pandas()
print("\n--- Ragas Evaluation Results (DataFrame) ---")
print(df)
print("-------------------------------------------\n")
# Assert minimum acceptable scores for each metric
# These thresholds are crucial for CI/CD failure conditions
assert df["faithfulness"].mean() > 0.7, "Faithfulness score too low!"
assert df["answer_relevancy"].mean() > 0.7, "Answer relevancy score too low!"
assert df["context_recall"].mean() > 0.6, "Context recall score too low!"
assert df["context_relevancy"].mean() > 0.7, "Context relevancy score too low!"
# To run this test:
# pytest tests/test_rag_system.pyRunning pytest tests/test_rag_system.py will execute the Ragas evaluation, print the results, and fail the test if any of the assert statements (representing your quality thresholds) are not met.
Integrating Evaluation into CI/CD Pipelines
The real power of DeepEval and Ragas comes when integrated into your CI/CD pipeline. The goal is to automatically run these evaluation tests on every code push or pull request and block deployments if quality regressions are detected.
Here's a conceptual flow for GitHub Actions:
- Developer pushes code to a branch.
- GitHub Actions workflow triggers on
pushorpull_request. - Environment Setup: Install Python dependencies, set API keys.
- Run DeepEval Tests: Execute
pytest tests/test_llm_units.py. - Run Ragas Tests: Execute
pytest tests/test_rag_system.py. - Report Results: If any
pytestcommand fails (due to DeepEval/Ragas metric thresholds), the CI/CD pipeline fails.
Example GitHub Actions Workflow (.github/workflows/llm-eval.yml)
name: LLM Continuous Evaluation
on: [push, pull_request]
jobs:
evaluate-llm:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install deepeval ragas openai "deepeval[llama_index]" datasets pandas pytest
- name: Run DeepEval LLM Unit Tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
# The --fail-on-error flag is critical for CI/CD
deepeval test tests/test_llm_units.py --fail_on_error
- name: Run Ragas RAG System Evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
# Pytest will fail if any assert in test_rag_system.py fails
pytest tests/test_rag_system.py
# Optional: Generate and upload evaluation reports
# DeepEval can generate HTML reports, Ragas can output to CSV/JSON
- name: Generate DeepEval Report
if: always()
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
deepeval test tests/test_llm_units.py --fail_on_error false --generate_report
- name: Upload DeepEval Report
if: always()
uses: actions/upload-artifact@v4
with:
name: deepeval-report
path: deepeval_report.htmlKey considerations for CI/CD:
- Secrets Management: Never hardcode API keys. Use your CI/CD provider's secret management (e.g.,
secrets.OPENAI_API_KEYin GitHub Actions). --fail_on_error: For DeepEval, this flag ensures the command exits with a non-zero code if a test fails, causing the CI/CD job to fail.- Pytest assertions: For Ragas, explicit
assertstatements on metric thresholds within yourpytestfunction will cause the test to fail, thus failing the CI/CD job. - Reporting: Consider generating and archiving evaluation reports (e.g., DeepEval's HTML report) as build artifacts for later inspection.
Automated Regression Testing with Evaluation Thresholds
The core of automated regression testing with LLMs lies in defining clear, objective thresholds for your evaluation metrics. These thresholds act as your quality gates. If the performance of your LLM or RAG system falls below these predefined levels, the CI/CD pipeline should fail, preventing the degraded version from being deployed.
Setting DeepEval Thresholds
As seen in the DeepEval example, each metric can have a threshold parameter:
metrics = [
HallucinationMetric(threshold=0.5, model="gpt-4"), # Max 50% hallucination score
AnswerRelevancyMetric(threshold=0.7, model="gpt-4"), # Min 70% relevancy score
ToxicityMetric(threshold=0.5, model="gpt-4") # Max 50% toxicity score
]When assert_test is called, DeepEval will compare the calculated score for each metric against its threshold. If score < threshold (for metrics where higher is better, like relevancy) or score > threshold (for metrics where lower is better, like hallucination/toxicity), the test fails.
Setting Ragas Thresholds
For Ragas, you typically evaluate a dataset and get average scores. You then use standard Python assert statements in your pytest function:
df = result.to_pandas()
assert df["faithfulness"].mean() > 0.7, "Faithfulness score too low!"
assert df["answer_relevancy"].mean() > 0.7, "Answer relevancy score too low!"
# ... and so on for other metricsHow to determine thresholds?
- Baseline: Start by running evaluations on your current production or highest-quality version of your LLM application to establish a baseline. Then set thresholds slightly below this baseline to allow for minor fluctuations but catch significant drops.
- Business Requirements: What level of accuracy, relevancy, or safety is acceptable for your specific application and users?
- Iteration: Thresholds are not static. As your application matures and models improve, you might incrementally raise them.
Advanced Use Cases and Custom Metrics
Custom Metrics with DeepEval
DeepEval allows you to define your own metrics when built-in ones aren't sufficient. This is powerful for domain-specific evaluations.
from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase
from deepeval.scorer import Scorer
class CustomToneMetric(BaseMetric):
def __init__(self, name: str = "Custom Tone Metric", threshold: float = 0.5, model: str = "gpt-4"): # Lower score is better for "negative" tone
super().__init__(name, threshold, model)
def measure(self, test_case: LLMTestCase):
# Use an LLM to evaluate the tone
scorer = Scorer()
# Example prompt for tone evaluation
prompt = f"Rate the tone of the following text on a scale of 0 to 1, where 0 is very positive/neutral and 1 is very negative/toxic. Just output the score.\nText: {test_case.actual_output}"
# In a real scenario, you'd call an LLM here and parse its response
# For simplicity, let's mock a score based on keywords
if "error" in test_case.actual_output.lower() or "apologies" in test_case.actual_output.lower():
score = 0.8 # Negative tone
else:
score = 0.2 # Neutral/Positive tone
self.score = score
return self.score
def is_successful(self) -> bool:
return self.score <= self.threshold # Lower score is better
# Example usage in a test
# @pytest.mark.deepeval_test
# def test_llm_tone():
# query = "Tell me a joke."
# actual_output = "Why don't scientists trust atoms? Because they make up everything!"
# test_case = LLMTestCase(input=query, actual_output=actual_output)
# metric = CustomToneMetric(threshold=0.3)
# assert_test(test_case, [metric])Adversarial Testing and Red Teaming
Beyond standard evaluation, consider incorporating adversarial test cases designed to probe for weaknesses:
- Prompt Injections: Can the LLM be tricked into ignoring system instructions?
- Jailbreaks: Can users elicit harmful or inappropriate responses?
- Edge Cases: What happens with extremely long, short, ambiguous, or out-of-domain queries?
These tests often require a mix of automated metrics and human review for nuanced understanding.
Human-in-the-Loop Evaluation
Automated metrics are powerful but not infallible. For critical applications, integrate human review into your workflow:
- Flagging Low-Scoring Outputs: Automatically send outputs that fall below certain metric thresholds for human review.
- Periodic Audits: Regularly sample production outputs for manual quality checks.
- User Feedback: Incorporate direct user feedback into your evaluation datasets.
Dataset Versioning
Your evaluation datasets are as crucial as your code. Version control them (e.g., with DVC or by committing to Git) to ensure reproducibility and track changes over time. A regression might be due to a change in the model or a change in the evaluation data.
Best Practices for Continuous LLM Evaluation
- Start Small, Iterate Often: Don't try to evaluate everything at once. Begin with a few critical metrics and expand as you gain confidence.
- Version Control Everything: Your application code, prompts, evaluation tests, and evaluation datasets should all be under version control.
- Define Clear Success Metrics and Thresholds: These should be aligned with business goals and user expectations.
- Monitor Trends, Not Just Point Scores: Track metric scores over time to identify gradual degradation (drift) rather than just sudden drops.
- Use a Dedicated Evaluation Environment: Isolate your evaluation runs to prevent interference and ensure consistent results.
- Balance Cost and Coverage: LLM-based evaluations can be expensive. Prioritize critical tests for automated CI/CD and use sampling or cheaper models for broader, less critical checks.
- Educate Your Team: Ensure developers understand the evaluation metrics, how to interpret results, and how to debug failures.
- Automate Reporting: Generate concise, actionable reports that are easily accessible to the team.
Common Pitfalls and How to Avoid Them
- Over-reliance on Automated Metrics: While powerful, LLM-based metrics are not perfect. They can sometimes disagree with human judgment. Use them as strong indicators, but don't blindly trust them without periodic human validation, especially for subjective criteria.
- Stale Evaluation Datasets: Models evolve, and so should your evaluation data. Regularly update your test cases and Ragas datasets to reflect current use cases and production data distributions. A stale dataset will give you a false sense of security.
- Ignoring Edge Cases: Focusing only on common scenarios can leave your system vulnerable to unexpected inputs. Actively seek out and test edge cases, ambiguous queries, and potential adversarial inputs.
- Complex, Unmaintainable Test Suites: Keep your evaluation tests modular, readable, and focused. Overly complex tests become a burden to maintain, slowing down development.
- Lack of Clear Ownership: Who is responsible for defining metrics, maintaining evaluation datasets, and triaging evaluation failures? Clearly define roles to ensure the system is effectively managed.
- High Cost of Evaluation: Running expensive LLM calls for every test in CI/CD can quickly rack up costs. Mitigate this by:
- Using cheaper LLM models (e.g.,
gpt-3.5-turbo) for evaluation wheregpt-4isn't strictly necessary. - Sampling a subset of your evaluation dataset for CI/CD runs, and running full evaluations less frequently.
- Caching LLM responses for evaluation metrics where possible (DeepEval has some caching mechanisms).
- Using cheaper LLM models (e.g.,
Conclusion
The era of LLMs demands a new approach to quality assurance. By integrating continuous evaluation into your CI/CD pipelines using tools like DeepEval and Ragas, you can transform the challenge of non-deterministic outputs into a robust, automated safety net. DeepEval provides the granularity for unit-level LLM testing, while Ragas offers deep insights into the performance of your RAG systems.
Embracing this methodology allows your team to iterate faster, deploy with greater confidence, and consistently deliver high-quality, reliable, and responsible LLM-powered applications. Start building your continuous evaluation pipeline today, and unlock the full potential of generative AI in your products.

Written by
CodewithYohaFull-Stack Software Engineer with 7+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

