A retrieval change that regresses a RAG app

The generator still calls a real model. The eval dataset still asks the same questions. A normal-looking retrieval precision filter removes useful context for short informational queries, and the RAG app starts answering that it does not know.

rag-qa / llmci.yaml
View focused PR failed check

Retrieval diff

@@ retrieve(query)
score = sum(1 for kw in entry["keywords"] if kw in query_lower)
- if score > 0:
+ if score >= MIN_KEYWORD_MATCHES:
      scored.append((score, doc_id, entry["doc"]))

llmci result

retrieval_recall 0.636 < 0.90
retrieval_precision 0.636 ≥ 0.50
failed examples 4

The generator is not the only thing that can regress

The example lives in llmci-cli/llmci-testbed, a public repository with small LLM-powered services. This one is a RAG QA service: retrieve documentation passages, pass them to a LiteLLM-backed generator, and answer user questions.

The focused testbed PR does not change the eval data or the user questions. It changes the retriever. The generator can still call openai/gpt-4o-mini, but if retrieval silently drops useful context, the model has nothing to work with.

Relevant testbed files
services/rag-qa/pipeline/retrieve.py
services/rag-qa/pipeline/generate.py
services/rag-qa/pipeline/run.py
services/rag-qa/evals/qa.jsonl
services/rag-qa/llmci.yaml
.github/workflows/llmci.yml

The regression looks like precision tuning

The original retriever ranks documents by keyword overlap and returns any document with at least one matching keyword. The pull request adds a minimum-match filter. In review, that can look reasonable: fewer weak matches, less noisy context, cleaner answers.

services/rag-qa/pipeline/retrieve.py
@@ retrieval scoring
+MIN_KEYWORD_MATCHES = 2

score = sum(1 for kw in entry["keywords"] if kw in query_lower)
-if score > 0:
+if score >= MIN_KEYWORD_MATCHES:
    scored.append((score, doc_id, entry["doc"]))

The bug is subtle because the new rule does improve the shape of some retrieval results: weak single-keyword matches disappear. But short, valid questions often contain only one domain term. What is Python? has the important word python. What is Kubernetes? has kubernetes. The new filter drops both before the generator ever runs.

The app still calls a real model

This is not a fake model-path example. In the focused GitHub Actions job, MOCK_LLM is off and the generator calls LiteLLM. The deterministic mock path still exists for cheap local smoke runs, but the PR comment comes from the real-model CI path.

services/rag-qa/pipeline/generate.py
def generate(query: str, context: list[str]) -> str:
    if not context:
        return "I don't have enough information to answer that question."

    combined_context = " ".join(context)
    if not is_mock():
        model = os.environ.get("RAG_MODEL", "openai/gpt-4o-mini")
        prompt = (
            "Answer the user's question using only the provided documentation.\n"
            "If the documentation does not contain the answer, say you do not have enough information.\n\n"
            f"Question: {query}\n\nDocumentation:\n{combined_context}\n\nAnswer:"
        )
        return complete(prompt, model=model)

The pipeline wrapper preserves the structured RAG output llmci needs: the generated answer, retrieved contexts, and retrieved document IDs.

services/rag-qa/pipeline/run.py
def run_pipeline(input_data: dict) -> dict:
    query = input_data["input"]
    doc_ids, docs = retrieve(query)
    answer = generate(query, docs)
    return {
        "output": answer,
        "contexts": docs,
        "retrieved_ids": doc_ids,
    }

The eval targets retrieval quality

The eval dataset is small and plain. Each row names the user question, the answer text that should be supported, and the document ID that should be retrieved.

services/rag-qa/evals/qa.jsonl
{"input": "What is Python?",
 "expected": "high-level programming language",
 "relevant_ids": ["python"]}
{"input": "Explain Docker containers",
 "expected": "containerization",
 "relevant_ids": ["docker"]}
{"input": "What is Kubernetes?",
 "expected": "orchestrates containerized",
 "relevant_ids": ["kubernetes"]}

This is the right level for a RAG gate. You do not need the model to hallucinate before you notice the regression. If the correct document never appears in retrieved_ids, the product is already off the rails.

The config turns retrieval into a CI gate

The RAG judge scores retrieval recall and precision at k=2. Recall has the tighter gate because missing the right document is the failure mode this service cannot tolerate.

services/rag-qa/llmci.yaml
target:
  command: "python3 pipeline/run.py --input {input_file} --output {output_file}"

evals:
  - name: rag-qa
    level: pipeline
    dataset: ./evals/qa.jsonl
    judge:
      type: rag
      criteria:
        - {name: retrieval_recall, type: retrieval_recall, k: 2}
        - {name: retrieval_precision, type: retrieval_precision, k: 2}
    metrics:
      - {name: retrieval_recall, threshold: 0.90, mode: absolute}
      - {name: retrieval_precision, threshold: 0.50, mode: absolute}

The focused workflow job runs only this RAG config on the demo branch, using the real LiteLLM-backed generator path.

GitHub Actions: focused RAG eval
- name: Run focused RAG eval
  working-directory: services/rag-qa
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
    RAG_MODEL: openai/gpt-4o-mini
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    LLMCI_REPORT_SLICE: rag-qa/llmci.yaml
  run: llmci run --config llmci.yaml --compare-to=origin/main

The report catches the real failure

The public focused RAG PR fails in exactly the way a reviewer should care about. Precision still passes, so the retriever is not completely broken. Recall fails because useful short questions no longer retrieve their relevant documents.

Retrieval Recall
0.636

Below the configured 0.90 recall floor.

Retrieval Precision
0.636

Still above the configured 0.50 precision floor.

Failures
4

Questions now retrieve no documents and get no useful answer.

GitHub Actions llmci report comment showing RAG retrieval recall failing while retrieval precision passes, with failed RAG examples
The PR comment makes the shape of the regression visible: recall fails, precision still passes, and the failed examples show the model answering without retrieved context.

That mix matters. A coarse smoke test might only check whether the app returns text. A prompt-only eval might focus on answer wording after context is supplied. This gate catches the upstream retrieval failure before it becomes an answer-quality mystery.

What review alone can miss

The diff looks like a common retrieval hardening change. Teams often add score thresholds, minimum match counts, metadata filters, recency windows, or reranker cutoffs to reduce noisy context. Each of those changes can improve one slice of quality while cutting away another.

RAG quality is a pipeline property. If retrieval drops the right document, the model can be perfectly reasonable and still produce a bad product outcome.

Try it yourself

Open the testbed repository, inspect services/rag-qa, and apply the retrieval diff above on a branch. The smallest useful version of this pattern is: