Production-Grade RAG: A Blueprint for Scalable, Real-Time Architecture
A RAG system fails quietly when it answers from documents that were modified or deleted upstream. Real-time accuracy requires event-driven change data capture, explicit vector index configuration, tiered semantic caching, and telemetry that monitors silent retrieval drift.
The failure mode that degrades production RAG systems is rarely a hard process crash. It is a confident answer synthesized from a document that was updated or deleted upstream, with no HTTP 500 in the trace.
Prototypes built with simple document loaders succeed on static corpora. Handling high concurrency, low latency, and real-time freshness in production requires decoupling ingestion, storage, and retrieval services.
The prototype problem is retrieval quality. The production problem is data freshness, and freshness is governed by the ingestion pipeline rather than the generator.
Decoupling the pipeline lets each service scale, fail, and recover on its own.
Chapter 0: Core service boundaries
A production RAG deployment is four services: ingestion, the vector database, the retrieval gateway, and the telemetry plane.
Keeping them separate stops an ingestion spike from starving user-facing queries, and it means a latency regression has one owner instead of four suspects.
Step 1: Push updates via event-driven Change Data Capture
Batch re-indexing fails as corpora grow. Periodic re-indexing introduces long staleness windows and consumes excessive GPU embedding hours.
Change Data Capture (CDC) via Kafka or AWS SQS streams document mutations into workers in real time:
Key ingestion requirements:
- Event-driven mutations: Source systems emit discrete
upsertanddeleteevents. - Buffer isolation: The queue buffers traffic surges during bulk uploads, isolating vector index nodes from write thrashing.
- Idempotent workers: Upsert operations use deterministic hash keys so reprocessing messages produces zero duplicate records.
// src/ingestion-worker.ts
import { Kafka } from 'kafkajs';
import { EmbeddingService } from './embeddingService';
import { VectorDbClient } from './vectorDbClient';
interface DocumentChangeEvent {
id: string;
content: string;
metadata: Record<string, any>;
type: 'upsert' | 'delete';
timestamp: string;
}
const kafka = new Kafka({ brokers: [process.env.KAFKA_BROKERS || 'localhost:9092'] });
const consumer = kafka.consumer({ groupId: 'rag-ingestion-group' });
const embeddingService = new EmbeddingService();
const vectorDbClient = new VectorDbClient();
async function startIngestionWorker() {
await consumer.connect();
await consumer.subscribe({ topic: 'document-changes', fromBeginning: false });
await consumer.run({
eachMessage: async ({ topic, partition, message, heartbeat }) => {
if (!message.value) return;
try {
const event: DocumentChangeEvent = JSON.parse(message.value.toString());
if (event.type === 'upsert') {
const embedding = await embeddingService.getEmbedding(event.content);
await vectorDbClient.upsert({
id: event.id,
vector: embedding,
metadata: { ...event.metadata, ingestion_timestamp: event.timestamp },
content: event.content
});
} else if (event.type === 'delete') {
await vectorDbClient.delete(event.id);
}
await heartbeat();
} catch (error) {
console.error(`Ingestion error at offset ${message.offset}:`, error);
}
},
});
}
startIngestionWorker().catch(err => {
console.error("Ingestion worker startup failed:", err);
process.exit(1);
});Handle the delete events explicitly. Without them, a document removed upstream keeps surfacing in top- searches and keeps getting cited.
The tell: delete a document at the source, then query for something only that document says. If the answer still comes back confident, your ingestion path has no delete.
Step 2: Configure the vector database for partitioned metadata filtering
Qdrant, OpenSearch, Milvus, and pgvector all ship defaults that are wrong at scale. Tune the index explicitly.
Primary scaling parameters:
| Parameter | Function | Production trade-off |
|---|---|---|
| Sharding | Horizontal distribution across nodes | Increases write capacity and index memory ceiling |
| Replication factor | High availability read replicas | Increases concurrent QPS; requires more RAM |
HNSW M & ef_construction | Graph connectivity during build | Higher values improve recall at the cost of index build latency and memory |
HNSW ef_search | Search beam width | Higher values increase Hit@k at the cost of query latency |
| Vector quantization (Scalar / Product) | Compression (FP32 to INT8/INT4) | Reduces memory footprint by up to 4x with minimal recall loss |
// src/vectorDbClient.ts
import { QdrantClient } from '@qdrant/qdrant-sdk';
import { PointStruct, Filter } from '@qdrant/qdrant-sdk/dist/qdrant_client';
export class VectorDbClient {
private client: QdrantClient;
private collectionName: string;
constructor(
host: string = process.env.QDRANT_HOST || 'localhost',
port: number = parseInt(process.env.QDRANT_PORT || '6333', 10),
collectionName: string = process.env.QDRANT_COLLECTION || 'rag_documents'
) {
this.client = new QdrantClient({ host, port });
this.collectionName = collectionName;
}
async upsert(document: { id: string; vector: number[]; metadata: Record<string, any>; content?: string }) {
const point: PointStruct = {
id: document.id,
vector: document.vector,
payload: { ...document.metadata, content: document.content || null }
};
await this.client.upsert(this.collectionName, {
wait: true,
points: [point],
});
}
async query(
queryVector: number[],
limit: number = 5,
filters?: Filter
): Promise<Array<{ id: string | number; score: number; payload: Record<string, any> }>> {
const searchResult = await this.client.search(this.collectionName, {
vector: queryVector,
limit: limit,
filter: filters,
with_payload: true,
with_vectors: false,
});
return searchResult.map(hit => ({
id: hit.id,
score: hit.score,
payload: hit.payload || {},
}));
}
async delete(id: string) {
await this.client.delete(this.collectionName, { pointsSelector: { points: [id] } });
}
}Setting with_vectors: false during retrieval minimizes network serialization overhead by returning only text payloads and scores.
Step 3: Implement tiered caching with granular TTLs
Caching cuts LLM cost and p50/p95 latency. The catch is that the three artifacts in the retrieval flow go stale at different rates, so they cannot share a TTL:
| Cache layer | Key composition | Recommended TTL | Invalidation rationale |
|---|---|---|---|
| Query embedding | SHA-256 of normalized query text | 24 hours | Deterministic vector representation |
| Candidate documents | Query hash + metadata filter hash + top- | 300 seconds | Aligns with ingestion pipeline update cadence |
| Generated LLM answer | Query hash + sorted retrieved chunk IDs + filter hash | 120 seconds | Short window to catch concurrent duplicate queries |
// src/retrievalService.ts
import { EmbeddingService } from './embeddingService';
import { VectorDbClient } from './vectorDbClient';
import { LLMClient } from './llmClient';
import { RedisClient } from './redisClient';
import crypto from 'crypto';
export class RetrievalService {
private embeddingService: EmbeddingService;
private vectorDbClient: VectorDbClient;
private llmClient: LLMClient;
private redis: RedisClient;
constructor() {
this.embeddingService = new EmbeddingService();
this.vectorDbClient = new VectorDbClient();
this.llmClient = new LLMClient();
this.redis = new RedisClient();
}
private generateCacheKey(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
private async getCached<T>(key: string, fetchFn: () => Promise<T>, ttlSeconds: number): Promise<T> {
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
const result = await fetchFn();
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
return result;
}
async retrieveAndGenerate(
userQuery: string,
filters?: Record<string, any>,
numDocuments: number = 5
): Promise<string> {
// 1. Cached Query Embedding
const embeddingCacheKey = this.generateCacheKey(`embedding:${userQuery}`);
const queryEmbedding = await this.getCached(
embeddingCacheKey,
() => this.embeddingService.getEmbedding(userQuery),
86400 // 24 hours
);
// 2. Cached Vector Retrieval
const filterString = filters ? JSON.stringify(filters) : '';
const retrievalCacheKey = this.generateCacheKey(`retrieval:${userQuery}:${filterString}:${numDocuments}`);
const retrievedDocs = await this.getCached(
retrievalCacheKey,
async () => {
const hits = await this.vectorDbClient.query(queryEmbedding, numDocuments, filters);
return hits.map(hit => ({
content: hit.payload.content as string,
metadata: hit.payload as Record<string, any>
}));
},
300 // 5 minutes
);
if (retrievedDocs.length === 0) {
return await this.llmClient.generateResponse([{ role: 'user', content: userQuery }]);
}
// 3. Prompt Assembly & Cached Generation
const context = retrievedDocs.map(doc => doc.content).join('\n\n---\n\n');
const promptMessages = [
{ role: 'system' as const, content: 'You are an accurate technical assistant. Use only the provided context.' },
{ role: 'user' as const, content: `Context:\n${context}\n\nQuestion: ${userQuery}` }
];
const sortedDocIds = retrievedDocs.map(d => d.metadata.id || '').sort().join(',');
const responseCacheKey = this.generateCacheKey(`response:${userQuery}:${sortedDocIds}:${filterString}`);
return await this.getCached(
responseCacheKey,
() => this.llmClient.generateResponse(promptMessages),
120 // 2 minutes
);
}
}Step 4: Monitor telemetry for silent retrieval degradation
CPU load and HTTP 200 counts stay green through every failure this post describes. Instrument the pipeline stages instead:
| Subsystem | Telemetry signals | Anomaly indicator |
|---|---|---|
| Ingestion Queue | Consumer lag, ingestion worker latency, DLQ counts | Queue backlog growing steadily |
| Vector Index | p99 query latency, cosine score distributions | Score distribution shifting downward over time |
| Cache Tier | Hit rates per tier (Embedding, Retrieval, Generation) | Unexplained drop in retrieval cache hit rate |
| LLM Gateway | Generation latency, input/output token counts | Token usage spike or unexpected refusal increase |
// src/telemetry.ts
type MetricType = 'counter' | 'gauge' | 'histogram';
export function logMetric(name: string, value: number, type: MetricType, labels?: Record<string, string>) {
const labelStr = labels ? Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',') : '';
console.log(`METRIC: ${name}{${labelStr}} ${value} (type: ${type}, timestamp: ${Date.now()})`);
}When this is the wrong choice
- The corpus does not change. Everything above is built around freshness. Against a fixed set of documents, a batch job and one index give you identical answers without Kafka, a metadata store, or consumer lag to watch.
- One service, low QPS. Sharding, replication, and three cache tiers exist to keep ingestion from contending with queries. Below the point where they contend, you have replaced one moving part with six, and each one can be the thing that is down.
- Answers must reflect the last edit immediately. The candidate-document cache holds for 300 seconds and the answer cache for 120. In a workflow where a correction has to be visible in the next answer, the cache tier is the thing producing the silent staleness this post opens with, and you should turn it off rather than tune it.
Key drift indicators
- Similarity score percentiles: Track the p50 and p95 cosine similarity scores of top- retrieved chunks. A persistent decline indicates semantic drift or missing domain documentation.
- Null retrieval frequency: Track the proportion of queries that return zero passages above your minimum relevance threshold.
- Source collection skew: Monitor the categorical distribution of retrieved documents to detect when search results over-index on stale collections.