Production RAG Architecture

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-09-30 against langchain-core 1.6.6 on Python 3.12, Spring AI 2.0.1 on Spring Boot 4.1.1 with Java 21, and LangChain4j 1.20.2 (see the version baseline). The Python orchestrator was run against in-memory retrievers; the Java classes were compiled against the released jars and were not run against a live model or store.

A prototype RAG application is one process: it loads documents, embeds them, answers a question and exits. A production service is two systems that share a set of stores. The ingestion plane turns changing sources into indexed, access-tagged chunks; the query plane turns an authenticated request into a grounded, cited, streamed answer inside a latency budget. Keeping the planes apart is the single most useful architectural decision: they scale on different signals (documents per hour versus requests per second), fail differently, and are deployed on different schedules. The foundations of each box — embeddings, ANN indexes, chunking and retrieval — are in Vector Databases & RAG; the scale path and capacity numbers for the vector store itself are in Production Considerations. This page is about the service around them.

The reference architecture

Reference architecture of a production RAG service. The ingestion plane reads sources, parses, redacts and tags, chunks, embeds and writes to the stores. The stores are PostgreSQL with pgvector, Neo4j, a conversation store, Redis caches and an object store for originals. The query plane has an API gateway with OAuth2 and rate limits, input guardrails with query condensation, a fan-out to vector, keyword and graph retrievers with the ACL filter, gather and rerank, a streaming generator with prompt caching, and output checks for citations and faithfulness, all driven by a stateless orchestrator. Cross-cutting: OpenTelemetry, online evaluation, audit log, secrets and clients.

The boxes map to pages of this sub-section:

Component Responsibility Page

Parse, redact and tag

Turns PDFs, HTML and scans into structured text with metadata, removes PII, attaches ACL groups and a content hash.

Document Parsing, Ingestion Pipelines & Freshness

Chunk, embed, index writer

Splits along structure, embeds in batches, upserts and deletes by stable IDs.

Document Ingestion & Chunking, Advanced Indexing Patterns

API gateway

Authenticates, rate-limits and versions the public contract.

Serving RAG as an API

Input guardrails and condensation

Screens the request and rewrites a follow-up into a standalone query.

Guardrails & Prompt Injection, Conversational RAG & Memory

Fan-out retrievers

Vector, keyword and graph or SQL retrieval in parallel, each with the caller’s ACL filter.

Query Understanding & Routing, Access Control & Privacy

Gather and rerank

Deduplicates, fuses (reciprocal rank fusion) and reranks with a cross-encoder.

Hybrid Search & Re-ranking

Generator and output checks

Streams the answer with citations, then verifies them and the faithfulness of the answer.

Citations & Grounding, Hallucination Detection & Correction

Caches and conversation store

Response, retrieval and semantic caches; checkpoints and long-term memory.

Caching, Latency & Cost

A stateless orchestrator with fan-out and gather

The orchestrator is the only component that knows the whole request. It must be stateless: the conversation, the cache and the index live in the stores, so any replica can serve any turn, a crashed pod loses nothing, and the horizontal pod autoscaler can add replicas without sticky sessions. Its main job is to run the independent retrievals concurrently and to enforce a deadline on each, so that one slow backend degrades the answer instead of stalling it.

With retrievers run sequentially the retrieval stage costs ; run concurrently it costs , bounded by the per-retriever timeout :

In Python the fan-out is asyncio.gather over the async ainvoke of each LangChain retriever, with asyncio.wait_for enforcing the deadline and any other retriever error logged and turned into an empty list, so that one exception does not fail the whole gather. The result is deduplicated by chunk ID and fused with reciprocal rank fusion before the reranker sees it:

docsassistant/orchestrator.py — parallel fan-out with deadlines and RRF (Python, langchain-core 1.6.6)
import asyncio
import logging
from collections import defaultdict

from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever

log = logging.getLogger(__name__)


async def _one(name: str, retriever: BaseRetriever, query: str, timeout_s: float) -> list[Document]:
    try:
        docs = await asyncio.wait_for(retriever.ainvoke(query), timeout=timeout_s)
    except TimeoutError:
        return []                         # a slow backend degrades the answer, it does not block it
    except Exception:
        log.exception("retriever %s failed", name)
        return []                         # and so does a failing one, instead of failing the whole gather
    for doc in docs:
        doc.metadata["retriever"] = name
    return docs


async def fan_out(query: str, retrievers: dict[str, BaseRetriever], timeout_s: float = 0.3,
                  rrf_k: int = 60, top_n: int = 20) -> list[Document]:
    """Run every retriever concurrently, then fuse the rankings with reciprocal rank fusion."""
    ranked_lists = await asyncio.gather(*(_one(n, r, query, timeout_s) for n, r in retrievers.items()))
    scores: dict[str, float] = defaultdict(float)
    by_id: dict[str, Document] = {}
    for docs in ranked_lists:
        for rank, doc in enumerate(docs, start=1):
            chunk_id = doc.metadata["chunk_id"]
            scores[chunk_id] += 1.0 / (rrf_k + rank)
            by_id.setdefault(chunk_id, doc)
    best = sorted(scores, key=scores.get, reverse=True)[:top_n]
    return [by_id[chunk_id] for chunk_id in best]

Spring AI parallelises retrieval inside RetrievalAugmentationAdvisor: when a QueryExpander produces several queries, the advisor retrieves for each of them on the TaskExecutor passed to its builder, and a DocumentJoiner merges the results. On Spring Boot 4.1 with spring.threads.virtual.enabled=true, the auto-configured applicationTaskExecutor runs each retrieval on a virtual thread:

RagPipelineConfig.java — parallel retrieval in the advisor (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.architecture;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.ai.rag.retrieval.join.ConcatenationDocumentJoiner;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;

@Configuration
public class RagPipelineConfig {

    @Bean
    RetrievalAugmentationAdvisor ragAdvisor(ChatClient.Builder builder, VectorStore vectorStore,
                                            TaskExecutor applicationTaskExecutor) {
        return RetrievalAugmentationAdvisor.builder()
                // three paraphrases plus the original question, retrieved concurrently
                .queryExpander(MultiQueryExpander.builder()
                        .chatClientBuilder(builder.clone())
                        .numberOfQueries(3)
                        .includeOriginal(true)
                        .build())
                .documentRetriever(VectorStoreDocumentRetriever.builder()
                        .vectorStore(vectorStore)
                        .similarityThreshold(0.5)
                        .topK(8)
                        .build())
                .documentJoiner(new ConcatenationDocumentJoiner())      // deduplicates by document ID
                .queryAugmenter(ContextualQueryAugmenter.builder().allowEmptyContext(false).build())
                .taskExecutor(applicationTaskExecutor)
                .build();
    }

    @Bean
    ChatClient docsChatClient(ChatClient.Builder builder, RetrievalAugmentationAdvisor ragAdvisor) {
        return builder.defaultAdvisors(ragAdvisor).build();
    }
}

LangChain4j’s DefaultRetrievalAugmentor does the same with a QueryRouter that returns several ContentRetriever instances: the augmentor calls them on the Executor it is given and hands every result list to the ContentAggregator, here a ReRankingContentAggregator backed by a cross-encoder ScoringModel:

FanOutAugmentor.java — routing to two retrievers in parallel and reranking (Java, LangChain4j 1.20.2)
package com.example.docsassistant.l4j.architecture;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import dev.langchain4j.model.scoring.ScoringModel;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
import dev.langchain4j.rag.RetrievalAugmentor;
import dev.langchain4j.rag.content.aggregator.ReRankingContentAggregator;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.query.router.DefaultQueryRouter;

public final class FanOutAugmentor {

    private FanOutAugmentor() {
    }

    public static RetrievalAugmentor create(ContentRetriever vectorRetriever, ContentRetriever keywordRetriever,
                                            ScoringModel crossEncoder) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        return DefaultRetrievalAugmentor.builder()
                .queryRouter(new DefaultQueryRouter(vectorRetriever, keywordRetriever))
                .contentAggregator(ReRankingContentAggregator.builder()
                        .scoringModel(crossEncoder)
                        .minScore(0.3)
                        .maxResults(6)
                        .build())
                .executor(executor)            // both retrievers run concurrently
                .build();
    }
}

Source: LangChain4j — RAG (Advanced RAG: query router, content aggregator).

Independent scaling tiers

The components have different resource profiles, so they scale on different metrics and often on different node pools:

Tier Bound by Scale on

API and orchestrator

I/O (waiting on stores and models)

Concurrent requests or open SSE streams; cheap CPU pods, many replicas, virtual threads or asyncio.

Retrieval (vector, keyword, graph)

Memory and I/O in the store

Query rate and index size; read replicas of PostgreSQL, Neo4j read replicas, see Production Considerations.

Reranker and embedding of queries

GPU or CPU matrix work

Batch queue depth; a small GPU pool or a hosted endpoint.

Generator

GPU memory and tokens per second

Tokens per second and queue time; a hosted API with rate-limit headroom, or a vLLM fleet as in vLLM & SGLang.

Ingestion workers

CPU (parsing, OCR) and GPU (VLM parsing, embedding)

Backlog of the ingestion topic or job queue; scale to zero between loads.

Scaling the orchestrator without scaling the generator only moves the queue. Track queue time at the model tier separately from the model’s own latency; see Observability & Tracing.

The latency budget

Users judge a streaming answer by time to first token (TTFT) and by how fast the text arrives afterwards (time per output token, TPOT). For a single-turn RAG request the end-to-end latency is approximately

where is the number of generated tokens, grows with the prompt length (the retrieved context is prefilled before the first token) and is the parallel retrieval time from the previous section. A worked DocsAssistant budget, at P95 rather than the mean, because the tail is what users remember:

Stage P95 budget How to stay inside it

Auth, guardrails, condensation

150 ms

Small classifier for input checks; condensation only when there is history.

Retrieval fan-out

300 ms

Parallel with a 300 ms deadline per retriever; HNSW ef_search tuned on the golden set.

Rerank 40 candidates

150 ms

Batched cross-encoder on GPU; skip when the top vector score is decisive.

TTFT with 4 000 prompt tokens

800 ms

Prompt caching of the stable prefix; a smaller model for easy questions (model cascades).

300 output tokens

Streamed, so the user reads while it generates; cap max_tokens.

Everything before the first token — about 1.4 s here — is perceived latency; the generation time is mostly hidden by streaming. That is why the first budget line to cut is almost never the model: it is sequential retrieval, an unconditional reranker or a long prompt. The cost side of the same trade-offs is on Caching, Latency & Cost.

DIY or a RAG platform

A managed "RAG as a service" platform (hosted ingestion, retrieval and generation behind one API) and a DIY stack built from a framework and your own stores are both legitimate. The decision is mostly about control and data boundaries, not features:

Criterion DIY (framework + your stores) Platform

Parsing and chunking control

Full: your parser ladder, your metadata.

The platform’s pipeline; custom parsing is usually a pre-processing step you run.

Access control

Your ACL model, enforced in your filters.

Whatever the platform’s document permissions support; check it maps to your identity provider.

Data residency and privacy

Wherever you deploy; on-premises models possible.

The vendor’s regions and sub-processors.

Time to first release

Weeks.

Days.

Operating cost

Infrastructure plus the team that runs it.

Per-query or per-document pricing; low fixed cost.

Lock-in

Framework-level only.

API, index format and evaluation tooling.

A common path is to prototype on a platform to learn which questions users ask, then move the high-volume or high-sensitivity flows to a DIY stack. Whichever you choose, keep an internal interface such as POST /v1/ask in front of it so that the choice can change without touching clients, and keep a platform-independent golden set (see Evaluation at Scale) so that the two can be compared on your own questions. Many small, uncoordinated RAG deployments inside one organisation ("RAG sprawl") are a governance problem: each one re-ingests the same documents with its own permissions. A shared ingestion plane with per-application query planes avoids it.

Total cost of ownership

The monthly cost of a RAG service is the sum of a per-request part and a fixed part:

with questions per month, and the price per input and output token, and dominated by the retrieved context. For most question-answering workloads the generator term dominates, which is why context size (fewer, better chunks after reranking), caching and model cascades are the biggest levers. The fixed part is often underestimated: re-parsing and re-embedding the corpus after a model change, the vector store’s memory, evaluation runs, and the people on call. Put a price on a full re-index before choosing an embedding model; see Generating Embeddings in Practice for re-embedding strategies.

From proof of concept to production

A plan that has worked for DocsAssistant-sized services, in the order that reduces risk fastest:

  1. Collect real questions from the proof of concept and turn a hundred of them into a golden set with expected sources (Evaluation at Scale).

  2. Fix ingestion first: parse the hardest documents correctly and attach ACLs and content hashes (Document Parsing, Access Control & Privacy).

  3. Freeze the API contract and stream from day one (Serving RAG as an API).

  4. Persist conversations keyed by the authenticated user (Conversational RAG & Memory).

  5. Add citations and an abstain path before widening the audience (Citations & Grounding).

  6. Instrument every stage and set the latency and cost budgets as alerts (Observability & Tracing).

  7. Gate changes in CI on the golden set, then roll out models and prompts blue/green (Deployment Patterns).

The latency budget, stateless orchestration, scaling tiers, TCO and platform choice follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapters 4 and 5), and Huyen, AI Engineering (Chapter 10), paraphrased; see the bibliography.