Eval pipelines, drift, freshness, latency, multi-tenancy, citation, cost, and the failure modes you will definitely encounter.
A RAG prototype can be built in an afternoon. A production RAG system is a different class of problem: it must be correct, fast, secure, cost-predictable, and observable — at scale, continuously, over a corpus that drifts.
Level 0: Works in a notebook. Level 1: Deployed with a golden test set showing >80% answer faithfulness. Level 2: Continuous eval, alerting on metric drops. Level 3: Embedding drift monitoring, A/B testing infra, multi-tenant isolation. Level 4: Full cost attribution per tenant, SLA-backed latency, abstention policies.
The single highest-leverage investment in a production RAG system is a labelled evaluation set of 200–500 question–answer pairs drawn from real user queries. Without it, you cannot measure whether a change improved or regressed the system. With it, every infrastructure decision (new embedding model, chunk size change, reranker swap) becomes an empirical measurement rather than a guess.
RAG evaluation has two separate concerns: retrieval quality (did we find the right documents?) and generation quality (given the documents, did the LLM produce a correct, faithful answer?).
| Framework | Retrieval metrics | Generation metrics | LLM-as-judge? | Notes |
|---|---|---|---|---|
| RAGAS v0.2 | Context Recall, Context Precision | Faithfulness, Answer Relevancy, Answer Correctness | Yes (configurable LLM) | Most widely adopted; pip install ragas; Works with LangChain + LlamaIndex |
| ARES | Context Relevance | Answer Faithfulness, Answer Relevance | Yes (fine-tuned DeBERTa judges) | Trains small judges on your domain; less LLM API cost at scale |
| Trulens Eval | Context Relevance | Groundedness, Answer Relevance | Yes | Real-time eval via instrumentation; Streamlit dashboard; works with any framework |
| Golden set | Recall@k, MRR@k, NDCG@k | Exact match, F1, human rating | Optional (LLM scoring) | Ground-truth labels; most reliable; requires upfront annotation effort |
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall
from datasets import Dataset
eval_data = {
"question": ["What is HNSW?", ...],
"contexts": [["HNSW is a graph-based ANN..."], ...],
"answer": ["HNSW stands for Hierarchical...", ...],
"ground_truth": ["Hierarchical Navigable Small World...", ...],
}
result = evaluate(
dataset=Dataset.from_dict(eval_data),
metrics=[faithfulness, answer_relevancy, context_recall],
)
# result.to_pandas() for detailed per-question breakdown
RAG systems degrade over time from two distinct sources: corpus drift (the world changes; indexed documents become stale) and model drift (a better embedding model is available but the index still uses the old one).
Monitor doc_modified_at vs ingested_at lag. Alert when >5% of the corpus is stale by more than N days. Use change-data-capture (CDC) from the source system (Postgres WAL, S3 event notifications, Kafka) to trigger incremental re-ingestion. Freshness is not free: every re-ingested document costs one embedding call.
Never mix embeddings from two different models in the same index. Old and new model vectors live in different metric spaces; cosine similarity across them is meaningless. Migration plan: build a shadow index with the new model; run A/B traffic; cut over atomically via collection alias swap. Shadow index build time at 100 k docs / 768 dims / BGE-base: ~20 min on A10G.
A/B test any significant change (new embedding model, new chunk size, new reranker) before full rollout. Serve variant A to 90% of traffic, variant B to 10%. Measure: RAGAS Faithfulness, Answer Relevancy, Recall@10, user thumbs up/down rate, query latency P50/P99. Run for 72 hours minimum to account for daily query distribution variation. Use a hash of the user ID or session ID for stable assignment — never random per request or you'll show the same user both variants for the same query.
RAG latency is dominated by two serial operations that cannot easily be parallelised: embedding the query and LLM generation. Everything else can be optimised to negligible cost.
| Optimisation | Latency saving | Implementation | Caveat |
|---|---|---|---|
| Semantic query cache | 90–100% on cache hit (skip retrieval + LLM) | Redis + vector similarity cache: store (query_embedding, response). Cache hit if cosine > 0.97. GPTCache library. | Stale responses; invalidation when corpus changes |
| Embedding cache | 5–10 ms per query | Cache query embeddings keyed by normalised query string (lowercase, strip punctuation). Redis STRING, TTL 1 h. | Near-duplicate queries may bypass cache |
| Reranker candidate reduction | 40–60% of reranker cost | Reduce from k=100 to k=50 first-stage candidates. Measure Recall@50 vs Recall@100 on your eval set; accept the recall trade-off. | Recall ceiling drops |
| Async retrieval prefetch | 50–100 ms | Prefetch retrievals for in-flight conversation turns. Predict likely next query from context; run retrieval before user submits. | Wasted compute on mispredictions |
| Query rewriting | Improves quality, not speed | Expand abbreviations, correct typos, add domain terminology before embedding. Cheaper than HyDE but less effective. | LLM call adds 200 ms if not cached |
Multi-tenant RAG is a security-critical design. A tenant isolation failure allows one customer to retrieve another customer's documents — a data breach.
One vector DB collection per tenant. Complete data isolation; no cross-contamination possible. Simplest to implement and audit. Cost: collection-level overhead multiplied by N tenants. Suitable for <1000 tenants. Pinecone namespaces, Qdrant collections, Weaviate classes, pgvector schemas all support this model.
All tenants in one collection. Every vector carries a tenant_id payload field. Every query includes a mandatory filter. More efficient but requires that the filter is always applied — one missing filter = cross-tenant leak. Enforce at the retrieval service layer, not the application layer. Qdrant's payload indexes make this efficient.
Each document has an ACL list of user/group IDs. At query time, filter to documents where the user's ID or group appears in the ACL. Enables row-level security across tenants and roles. Implementation: store ACL as a JSON array payload field; Qdrant supports array-contains filters. For >1 M documents per tenant with complex ACLs, consider a dedicated access control service.
Multi-tenant RAG must guard against prompt injection via retrieved documents: a malicious document in tenant A's corpus could contain instructions that leak tenant B's context if A and B share an LLM session. Mitigate with: (1) strict namespace isolation at the retrieval layer, (2) sanitise retrieved text before inserting into the LLM prompt, (3) instruct the model with a system prompt that forbids acting on instructions found in retrieved context. This is an active research area — no complete solution exists yet.
Enterprise RAG users require answers to be grounded in sources — both for trust and for auditability in regulated industries (legal, medical, financial). Citation is not a cosmetic feature; it is the mechanism by which users verify correctness.
# System prompt
system = """
You are a precise research assistant. Answer only from the provided context.
For each claim, include an inline citation [1], [2] referencing the source chunk.
If the context does not contain the answer, say "I don't have enough information."
Never invent sources or cite documents not in the context.
"""
# Context block (each retrieved chunk labelled)
context = "\n\n".join([
f"[{i+1}] (Source: {c['source']}, p.{c['page']})\n{c['content']}"
for i, c in enumerate(retrieved_chunks)
])
# User message includes context + question
user = f"Context:\n{context}\n\nQuestion: {query}"
RAGAS Faithfulness checks whether each sentence in the generated answer is entailed by at least one retrieved chunk. Score = (faithfully-grounded sentences) / (total sentences). A score below 0.85 indicates the model is extrapolating beyond its retrieved context. Use Trulens' real-time groundedness check as a guardrail: if faithfulness drops below threshold, append a disclaimer to the response or escalate to a human reviewer. Never surface answers with faithfulness < 0.7 without explicit user warning.
Cost modelling is essential before committing to infrastructure choices. The largest RAG cost drivers are often not the LLM generation but the embedding and vector DB layers.
| Cost component | Unit | Typical rate (2025) | Example: 1M docs, 10k q/day |
|---|---|---|---|
| Embedding (index-time) | per Mtok input | $0.02 (text-embedding-3-small) / $0.13 (large) | 1-time: ~$10–65 for 500-token avg chunks |
| Embedding (query-time) | per Mtok input | $0.02–0.13 | <$1/day (queries are short, ~30 tokens) |
| Cohere Rerank API | per 1k (query, chunk) pairs | $0.002 | $20/day at k=100, 10k queries |
| Qdrant Cloud | per GB RAM per hour | ~$0.03/GB/h (1 M × 1024d ≈ 4 GB) | ~$90/month |
| Pinecone Serverless | per read unit / write unit | $0.00000025/RU (query ≈ 10 RU) | ~$25/month query cost |
| LLM generation | per Mtok (input+output) | $0.15 (GPT-4o-mini) / $3 (Claude Sonnet) | $6–120/day (5 chunks × 500tok context) |
For a 10 k query/day system at 5 retrieved chunks × 500 tokens each, the input context alone is 25 M tokens/day. At GPT-4o-mini prices ($0.15/Mtok), that is $3.75/day. At Claude Sonnet ($3/Mtok): $75/day. The reranker and embedding costs are a rounding error by comparison. Reduce LLM cost first: use contextual compression to extract only the relevant sentences from each chunk before inserting into context (LangChain LLMChainExtractor). A 50% context reduction at the same answer quality halves your LLM bill.
A production RAG system must handle retrieval failures gracefully. Silently returning hallucinated answers is worse than explicit abstention.
| Failure mode | Symptom | Detection | Mitigation |
|---|---|---|---|
| Empty retrieval | Top-k returns 0 results or all below relevance threshold | Check result count + min score before calling LLM | Widen search (lower threshold, increase k); fall back to web search; explicit “I don't know” |
| Stale corpus answer | LLM answers confidently from outdated doc | Check doc_modified_at of retrieved chunks; alert if >N days old | CRAG web-search fallback; prepend staleness warning to context; filter by recency in retrieval |
| Hallucination | Model generates claims not in retrieved context | RAGAS Faithfulness < threshold; Trulens groundedness check | Stronger system prompt; lower temperature; abstention guardrail |
| Cross-tenant data leak | User sees another tenant's document content | Audit log of retrieved doc IDs vs requesting user's ACL | Mandatory payload filter; namespace isolation; post-retrieval ACL check |
| Index degradation (HNSW stale) | Recall drops 5–15% silently after many deletes | Recall@10 on fixed eval set run nightly | Trigger compaction when tombstone fraction > 20% |
| Embedding model mismatch | Cosine scores all < 0.2; retrieval nonsensical | Log score distributions; alert on P50 cosine drop | Validate model version matches index at startup; block mixed-model upserts |
Design explicit abstention into the system. If context relevance is below 0.5 (RAGAS), return: "I couldn't find reliable information in the knowledge base for this question. Please consult [source] or contact [team]." This is not a failure — it is correct behaviour that prevents low-quality hallucinated answers from eroding user trust. Track abstention rate as a metric; a sudden spike indicates corpus freshness or retrieval pipeline issues.
You've completed the RAG & Retrieval Systems series. The seven decks cover the full stack from embedding models and vector databases through hybrid search, chunking, agentic patterns, graph augmentation, and production operations. Return to the RAG & Retrieval Systems sub-hub for further reading and related resources.