Module 8: RAG Evaluation + The Capstone Project
Evaluation CI/CD with GitHub Actions
Capsule description
You have an evaluation pipeline, a golden dataset and calibrated quality gates. But there's a law of human nature in software: if the evaluation doesn't run automatically, eventually it stops being run. Three weeks after implementing it, someone has an urgent delivery, skips the evaluation "just this once", nothing bad appears to happen, and it becomes a habit. Three months later nobody runs the evaluation and you're back to the initial state with no safety net.
The solution is to eliminate the option of skipping the evaluation. The professional way is to integrate it into CI/CD: every pull request automatically triggers the evaluation, the results get commented on the PR, and degradations block the merge. It doesn't depend on anyone remembering — it's part of the infrastructure.
In this capsule you're going to configure GitHub Actions so your RAG system has the same level of operational rigor as a traditional software system: smoke tests on every PR, a full nightly evaluation, reports published as browsable artifacts, automatic comments with the metric changes vs main.
By the end you'll have a repository with RAG quality evaluation running automatically, with no way to skip it. It's the operational close that separates "I have evaluation" from "my team operates with evaluation discipline".
The philosophy: CI layers by speed and coverage
Not every check can run on every PR. Cost, time and rate limits matter. Structure it in layers:
| Layer | Trigger | Duration | Coverage | Effect |
|---|---|---|---|---|
| Smoke | Every PR | <2 min | 10 balanced queries | Blocks the PR if it fails |
| Full | A PR to main, nightly | 10-15 min | 100+ queries | Blocks the merge to main |
| Exhaustive | A pre-release tag | 30-60 min | 500+ queries with a gpt-4o judge | Blocks the release |
| Drift watch | Weekly | 30 min | Production samples | Alerts, doesn't block |
This structure means:
- The developer gets feedback in 2 min for normal PRs
- Risky changes (to main) get a deeper evaluation
- Releases go through an evaluation with a premium judge
- Drift in production gets caught weekly
The base workflow: smoke on every PR
# .github/workflows/rag-eval-smoke.yml
name: RAG Eval - Smoke
on:
pull_request:
paths:
- 'app/**'
- 'eval/**'
- 'golden_dataset/**'
- 'pyproject.toml'
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install -e .[eval]
- name: Run smoke evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
PINECONE_INDEX: ${{ vars.PINECONE_INDEX_TEST }}
OPENAI_SEED: '42'
run: python scripts/evaluate.py --mode smoke --enforce-thresholds
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-report-${{ github.event.pull_request.number }}
path: eval_reports/
retention-days: 30
The key decisions:
- The
pathsfilter: it only fires if the relevant directories change. Changes to the docs don't need to run the eval (it saves OpenAI costs). timeout-minutes: 5: it blocks infinite workflows from a bug in the code.if: always()on the upload: it uploads the report even if the run failed, so you can debug.OPENAI_SEED: '42': reproducibility between runs.- A separate index for testing:
PINECONE_INDEX_TESTkeeps you from touching the production index.
The full workflow: a full nightly evaluation
# .github/workflows/rag-eval-nightly.yml
name: RAG Eval - Nightly Full
on:
schedule:
- cron: '0 3 * * *' # 3am UTC daily
workflow_dispatch: # manual trigger
jobs:
full:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -e .[eval]
- name: Run full evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
PINECONE_INDEX: ${{ vars.PINECONE_INDEX_PROD }}
OPENAI_SEED: '42'
run: python scripts/evaluate.py --mode full --enforce-thresholds
- name: Compare against baseline
run: python scripts/compare_baseline.py --report eval_reports/latest.json
- name: Upload to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 cp eval_reports/ s3://rag-eval-reports/$(date +%Y-%m-%d)/ --recursive
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "🚨 RAG nightly evaluation failed",
"channel": "${{ secrets.SLACK_CHANNEL }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
The differences from smoke:
- A cron schedule instead of a PR trigger
- The production index (
PINECONE_INDEX_PROD) so you evaluate against the real state - An upload to S3 for long retention (GitHub artifacts get deleted after 90 days)
- A Slack notification on failure — because it ran overnight and nobody is going to see the red in GitHub
The comparison workflow against main
To show the developer how the quality changed vs main, not just whether it passes the thresholds:
# .github/workflows/rag-eval-compare.yml
name: RAG Eval - Compare with main
on:
pull_request:
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -e .[eval]
- name: Run smoke on PR branch
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
run: |
python scripts/evaluate.py --mode smoke
mv eval_reports/latest.json eval_reports/pr.json
- name: Run smoke on main
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
run: |
git checkout main
python scripts/evaluate.py --mode smoke
mv eval_reports/latest.json eval_reports/main.json
- name: Generate comparison
run: python scripts/diff_reports.py --pr eval_reports/pr.json --main eval_reports/main.json --output diff.md
- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
path: diff.md
header: rag-eval-diff
The PR comment looks like this:
## 📊 RAG Quality Diff
| Metric | main | this PR | Δ |
|--------|------|---------|---|
| Faithfulness | 0.91 | 0.93 | ↑ +2.2% |
| Answer Relevancy | 0.87 | 0.86 | ↓ -1.1% |
| Context Precision | 0.82 | 0.85 | ↑ +3.7% |
| Context Recall | 0.85 | 0.84 | ↓ -1.2% |
**Verdict**: ✅ Net improvement (no regressions beyond tolerance)
This is what makes evaluation visceral for the developer — it isn't an abstract test, it's direct feedback on their PR.
Handling secrets and variables
GitHub distinguishes between secrets (encrypted, the value isn't visible) and variables (visible in the logs). Use them appropriately:
| Type | Example | Use |
|---|---|---|
| Secret | OPENAI_API_KEY | Credentials that grant access to paid resources |
| Secret | PINECONE_API_KEY | Database access |
| Secret | SLACK_WEBHOOK_URL | A URL with an embedded token |
| Variable | PINECONE_INDEX_TEST | The index's name (not sensitive) |
| Variable | PINECONE_REGION | Public configuration |
Configuration:
# Repo settings → Secrets and variables → Actions
# The "Secrets" tab:
gh secret set OPENAI_API_KEY
gh secret set PINECONE_API_KEY
# The "Variables" tab:
gh variable set PINECONE_INDEX_TEST --body "rag-eval-test"
gh variable set PINECONE_INDEX_PROD --body "rag-prod-v1"
The critical anti-patterns:
- Hardcoding keys in the YAML → automatic model expulsion and a key rotation
- Logging secrets with
echo $OPENAI_API_KEY→ they end up in the workflow's public logs - Using production's OPENAI_API_KEY for CI → CI can exhaust the production quota. Use a separate key with its own quota.
Reducing CI costs
Evaluation with an LLM-as-judge costs money per run. For an active repo with 30 PRs/month:
| Strategy | Approx. cost/month |
|---|---|
| No filtering: a full eval on every push | $300+ |
| Smoke on the PR (10 queries) + full nightly | $25 |
| A path filter (only relevant changes) | $15 |
| + a gpt-4o-mini judge | $8 |
Specific optimizations:
# Cancel old runs when a new push lands on the same PR
concurrency:
group: rag-eval-${{ github.ref }}
cancel-in-progress: true
# Pip cache to speed up the setup
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
# Embedding cache (if you reuse the golden set's queries)
- uses: actions/cache@v4
with:
path: .cache/embeddings/
key: embeddings-${{ hashFiles('golden_dataset/v1.0.0.json') }}
Connection with the final project
Your Advanced RAG System must deliver:
.github/workflows/
├── rag-eval-smoke.yml # On every PR
├── rag-eval-nightly.yml # A daily cron
└── rag-eval-compare.yml # A comment with the diff vs main
scripts/
├── evaluate.py # The evaluation CLI
├── compare_baseline.py # The regression check
└── diff_reports.py # A comparison between runs
And in the README:
## Quality Gates
Every PR runs RAG smoke evaluation automatically. See latest results:
[](...)
Nightly full evaluation: [latest report on S3](s3://rag-eval-reports/latest.html)
The badge in the README is the visible signal that the repo operates with evaluation discipline.
Comparison: CI without evaluation vs CI with RAG evaluation
| Criterion | Traditional CI, no eval | CI with RAG eval |
|---|---|---|
| Catching quality degradation | Nonexistent | Immediate, in the PR |
| Confidence to refactor the pipeline | Low: fear of breaking things | High: there's a safety net |
| Onboarding new devs | Risky | Safe |
| Conversations about quality | Subjective | Quantitative |
| Visibility into historical quality | Nonexistent | Versioned reports |
| Operational maturity | Standard | RAG-specific operational excellence |
Troubleshooting
Problem 1: "The workflow fails on a timeout"
The cause: a large dataset in the PR, or low concurrency on the runner.
The fix: smoke mode on the PR (not full). Raise timeout-minutes only if the full eval really does take more than 30 min — and if so, optimize the concurrency in execute_batch.
Problem 2: "The secret isn't found in CI"
The cause: the secret isn't configured, or the name is wrong.
The fix: check with gh secret list and compare the exact names. Remember that organization secrets aren't inherited automatically; they have to be granted to the repo.
Problem 3: "Unstable metrics: it passes locally, fails in CI"
The cause: different dependencies or a different seed.
The fix: pin exactly in pyproject.toml (ragas==0.1.x, not ragas>=0.1). Set OPENAI_SEED=42 and temperature=0 in the eval code. Verify the dataset_version is the same.
Problem 4: "The OpenAI costs exploded"
The cause: the workflow runs on every push (not just PRs), or it runs the full eval instead of smoke.
The fix: path filters, smoke on the PR, full only nightly. Use a separate key with a quota cap for CI. Consider gpt-4o-mini as the default judge.
Problem 5: "The diff comments flood the PR"
The cause: multiple runs comment separately.
The fix: use marocchino/sticky-pull-request-comment@v2 with a unique header — it updates the existing comment instead of creating new ones.
Problem 6: "The workflow runs on external forks with no secrets"
The cause: PRs from forks don't get access to the secrets, for security reasons.
The fix: the workflow detects this and skips the eval with a clear message. For PRs from external contributors, the eval runs when a maintainer types /eval or when it's merged into a feature branch.
Exercises
Exercise 1: A basic workflow for a PR
Create a minimal workflow that runs the smoke eval on every PR.
See the solution
# .github/workflows/rag-eval.yml
name: RAG Eval
on:
pull_request:
paths:
- 'app/**'
- 'eval/**'
- 'golden_dataset/**'
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -e .[eval]
- run: python scripts/evaluate.py --mode smoke --enforce-thresholds
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_SEED: '42'
The explanation: the path filter keeps it from running on irrelevant changes; the timeout prevents hangs; the cache speeds up the setup.
Exercise 2: Uploading the report as an artifact
Extend the workflow to upload the JSON report with 30-day retention.
See the solution
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-report-pr-${{ github.event.pull_request.number }}
path: |
eval_reports/*.json
eval_reports/*.md
retention-days: 30
The explanation: if: always() uploads the report even if the previous step failed (crucial for debugging). Naming it with the PR number makes it easy to find the specific report.
Exercise 3: An automatic PR comment with the diff
Implement a job that posts the diff vs main on every PR.
See the solution
- name: Post diff comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: rag-eval
message: |
## 📊 RAG Eval Results
| Metric | This PR | Threshold | Status |
|--------|---------|-----------|--------|
| Faithfulness | ${{ steps.eval.outputs.faithfulness }} | 0.85 | ${{ steps.eval.outputs.faithfulness_status }} |
| Relevancy | ${{ steps.eval.outputs.relevancy }} | 0.80 | ${{ steps.eval.outputs.relevancy_status }} |
To populate the outputs:
- id: eval
run: |
python scripts/evaluate.py --mode smoke --output-format github
echo "faithfulness=$(jq .ragas.faithfulness eval_reports/latest.json)" >> $GITHUB_OUTPUT
The explanation: the sticky comment updates on every push instead of creating noise. The outputs let you parameterize the comment with real values.
Exercise 4: A schedule for the nightly full eval
Create a workflow that runs the full eval every night and notifies Slack if it fails.
See the solution
name: RAG Eval Nightly
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
full:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -e .[eval]
- run: python scripts/evaluate.py --mode full --enforce-thresholds
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
- if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "🚨 RAG nightly eval failed: ${{ github.workflow_url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
The explanation: workflow_dispatch allows a manual trigger when you need to re-run without waiting for the cron. The Slack notification makes sure nightly regressions don't wait until Monday.
Summary
- CI/CD turns evaluation into permanent infrastructure, not a voluntary practice
- Layers by speed/coverage: smoke (PR), full (nightly), exhaustive (release), drift (weekly)
- Path filters, concurrency cancellation and caching cut the CI cost 10×
- Diff comments on the PR make evaluation visceral for the developer
- Secrets vs variables: secrets for credentials, variables for names
- A Slack notification on nightly failures: what you don't get told about doesn't get fixed
- The README badge is a public signal of operational maturity
Additional resources
- GitHub Actions Documentation - The complete official reference.
- Workflow Syntax - The YAML syntax in detail.
- Encrypted Secrets - Handling credentials safely.
- Sticky PR Comment Action - Comments that update in place.
- Slack GitHub Action - Notifications to Slack.
- Reusable Workflows - DRY across multiple repos.
Created: March 13, 2026
Version: 2.0