Back to blog

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.

November 27, 2025Updated September 08, 2026

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:

  1. Event-driven mutations: Source systems emit discrete upsert and delete events.
  2. Buffer isolation: The queue buffers traffic surges during bulk uploads, isolating vector index nodes from write thrashing.
  3. Idempotent workers: Upsert operations use deterministic hash keys so reprocessing messages produces zero duplicate records.
typescript
// 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-kk 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:

ParameterFunctionProduction trade-off
ShardingHorizontal distribution across nodesIncreases write capacity and index memory ceiling
Replication factorHigh availability read replicasIncreases concurrent QPS; requires more RAM
HNSW M & ef_constructionGraph connectivity during buildHigher values improve recall at the cost of index build latency and memory
HNSW ef_searchSearch beam widthHigher 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
typescript
// 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 layerKey compositionRecommended TTLInvalidation rationale
Query embeddingSHA-256 of normalized query text24 hoursDeterministic vector representation
Candidate documentsQuery hash + metadata filter hash + top-kk300 secondsAligns with ingestion pipeline update cadence
Generated LLM answerQuery hash + sorted retrieved chunk IDs + filter hash120 secondsShort window to catch concurrent duplicate queries
typescript
// 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:

SubsystemTelemetry signalsAnomaly indicator
Ingestion QueueConsumer lag, ingestion worker latency, DLQ countsQueue backlog growing steadily
Vector Indexp99 query latency, cosine score distributionsScore distribution shifting downward over time
Cache TierHit rates per tier (Embedding, Retrieval, Generation)Unexplained drop in retrieval cache hit rate
LLM GatewayGeneration latency, input/output token countsToken usage spike or unexpected refusal increase
typescript
// 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-kk 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.