n4nAI

Building a CI/CD pipeline for a RAG application

A hands-on tutorial to build a CI/CD pipeline for RAG applications using GitHub Actions, covering indexing, retrieval tests, and automated LLM evals.

n4n Team3 min read675 words

Audio narration

Coming soon — every post will get a voice note here.

Most RAG systems rot silently: a chunking change breaks retrieval, an embedding model swap skews similarity, a prompt edit degrades answers. A solid CI/CD pipeline for RAG applications catches these regressions before they hit production by treating indexes, retrievers, and generation as testable artifacts. This tutorial builds one with GitHub Actions, Python, and a deterministic eval harness you can run locally and in CI.

Traditional CI assumes code is the only variable. In RAG, the data, the embedding model, and the generation model all shift underneath you. The pipeline we build separates those concerns into fast unit tests, slower retrieval integration tests, and a gated LLM eval.

Prerequisites

  • Python 3.11+ and a GitHub repository.
  • pip install chromadb sentence-transformers openai pytest.
  • An OpenAI-compatible API key (any provider or gateway).
  • Basic familiarity with pytest and GitHub Actions YAML.

We will not cover vector DB hosting or deployment to production—only the automated quality gate that should run on every pull request.

Project layout

rag-demo/
├── rag.py
├── tests/
│   ├── test_chunking.py
│   ├── test_retrieval.py
│   └── test_eval.py
├── eval.py
└── .github/workflows/ci.yml

Step 1: A minimal RAG module

rag.py implements chunking, embedding, and a query path. We use sentence-transformers for local embeddings and Chroma for the index.

import chromadb
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
client = chromadb.Client()
coll = client.create_collection("docs")

def chunk_text(text, size=200, overlap=20):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i+size]) for i in range(0, len(words), step)]

def build_index(docs):
    chunks = [c for d in docs for c in chunk_text(d)]
    emb = model.encode(chunks).tolist()
    coll.add(ids=[str(i) for i in range(len(chunks))],
             embeddings=emb, documents=chunks)
    return chunks

def retrieve(query, k=3):
    q_emb = model.encode([query]).tolist()
    return coll.query(query_embeddings=q_emb, n_results=k)

This is intentionally small. The point is to have pure functions and a clear boundary for tests. In a larger app, build_index would point at a persistent store and accept a configurable embedding model name.

Step 2: Test chunking and embedding

Chunking regressions are common. A simple unit test locks behavior so a later “optimization” can’t silently merge unrelated sections.

from rag import chunk_text

def test_chunk_count():
    text = "word " * 420
    chunks = chunk_text(text, size=200, overlap=20)
    assert len(chunks) == 3

def test_overlap_preserved():
    chunks = chunk_text("a b c d e f g h i j", size=4, overlap=2)
    assert chunks[0].split()[-2:] == chunks[1].split()[:2]

Run pytest tests/test_chunking.py -q. Expected output:

..                                       [100%]
2 passed in 0.31s

These tests run in milliseconds and need no network. They belong in the same job as your lint step.

Step 3: Retrieval integration test

Retrieval is where RAG lives or dies. Index a known document and assert the top hit contains the source phrase. This catches embedding dimension mismatches and Chroma schema drift.

from rag import build_index, retrieve

def test_retrieval_hits():
    build_index(["The capital of France is Paris. Snails are tasty."])
    res = retrieve("What is the capital of France?", k=1)
    docs = res["documents"][0]
    assert any("Paris" in d for d in docs)

Output:

.                                        [100%]
1 passed in 1.12s

If someone changes chunk_text to drop overlap, this test may still pass, but the next step catches semantic drift that pure lexical checks miss.

Step 4: Generation eval with an LLM judge

A retrieval test does not prove the answer is correct. We add eval.py that queries a model and uses a second model as a judge. Point the OpenAI client at n4n.ai’s OpenAI-compatible endpoint to run the judge against multiple models without code changes; it meters per-token usage and forwards cache hints so repeated eval prompts stay cheap.

import os, json
from openai import OpenAI
from rag import build_index, retrieve

client = OpenAI(base_url=os.environ["OPENAI_BASE_URL"],
                api_key=os.environ["OPENAI_API_KEY"])

def generate(question):
    build_index(["The capital of France is Paris. The Eiffel Tower is in Paris."])
    ctx = retrieve(question, k=2)["documents"][0]
    prompt = f"Context: {ctx}\nQuestion: {question}\nAnswer:"
    r = client.chat.completions.create(
        model=os.environ.get("MODEL", "gpt-4o-mini"),
        temperature=0,
        messages=[{"role": "user", "content": prompt}])
    return r.choices[0].message.content

def judge(question, answer):
    sys = "You are a strict grader. Reply JSON: {\"pass\": bool, \"reason\": str}"
    r = client.chat.completions.create(
        model=os.environ.get("JUDGE_MODEL", "gpt-4o-mini"),
        temperature=0,
        messages=[{"role": "system", "content": sys},
                  {"role": "user", "content": f"Q: {question} A: {answer}"}])
    return json.loads(r.choices[0].message.content)

if __name__ == "__main__":
    q = "What is the capital of France?"
    ans = generate(q)
    verdict = judge(q, ans)
    print(json.dumps({"answer": ans, "verdict": verdict}))

Why temperature zero

LLM judges are flaky if they sample. Setting temperature=0 makes the grader deterministic across CI runs. If your gateway supports cache-control, the judge prompt is identical per question, so it gets served from cache after the first call.

Run OPENAI_BASE_URL=https://api.n4n.ai/v1 OPENAI_API_KEY=sk-... python eval.py. Sample output:

{"answer": "The capital of France is Paris.", "verdict": {"pass": true, "reason": "Answer matches context"}}

If verdict.pass is false, the CI job should fail. Wrap the script so a non-pass exits 1.

Step 5: Assemble the CI/CD pipeline for RAG applications

The GitHub Actions workflow installs deps, runs unit/integration tests, then runs the eval gate. This CI/CD pipeline for RAG applications runs on every pull request and blocks merges on regression.

name: rag-ci
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install chromadb sentence-transformers openai pytest
      - run: pytest tests/ -q
      - name: eval gate
        env:
          OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          MODEL: gpt-4o-mini
          JUDGE_MODEL: gpt-4o-mini
        run: |
          python eval.py | tee eval.out
          grep -q '"pass": true' eval.out || exit 1

Caching in CI

Installing sentence-transformers downloads an 80MB model. Add actions/cache for pip and the HuggingFace cache to keep job time under a minute. The eval network call is the long pole; run it only after the fast tests pass.

Step 6: Verify the pipeline

Push a branch. GitHub Actions shows:

pytest tests/ -q
...
3 passed in 1.45s
python eval.py | tee eval.out
{"answer": "The capital of France is Paris.", "verdict": {"pass": true, "reason": "Answer matches context"}}

If you deliberately break chunk_text to return empty strings, the retrieval test fails and the job stops before the eval. If you swap the embedding model to a random projector, retrieval may still return something but the judge will likely flag the answer as unsupported.

Extending the gate

For real systems, replace the single-question eval with a bundled dataset of (question, expected_fact) pairs. Store embeddings in a persistent Chroma instance rather than an in-memory client. Cache the judge model responses with provider cache-control headers to cut cost. The core pattern stays: chunk/embed tests, retrieval assertions, and an LLM judge in the same CI/CD pipeline for RAG applications you already trust for regular software.

A good RAG pipeline is not a notebook; it is a tested component. Wire these checks into your repo today and your future prompt edits will thank you.

Tagsragci-cdgithub-actionsllm-apps

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ci/cd pipelines for llm apps posts →