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
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. |
|
Chunk, embed, index writer |
Splits along structure, embeds in batches, upserts and deletes by stable IDs. |
|
API gateway |
Authenticates, rate-limits and versions the public contract. |
|
Input guardrails and condensation |
Screens the request and rewrites a follow-up into a standalone query. |
|
Fan-out retrievers |
Vector, keyword and graph or SQL retrieval in parallel, each with the caller’s ACL filter. |
|
Gather and rerank |
Deduplicates, fuses (reciprocal rank fusion) and reranks with a cross-encoder. |
|
Generator and output checks |
Streams the answer with citations, then verifies them and the faithfulness of the answer. |
|
Caches and conversation store |
Response, retrieval and semantic caches; checkpoints and long-term memory. |
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:
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]
Source: LangChain — Retrieval and Python — asyncio.gather.
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:
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();
}
}
Source: Spring AI — Retrieval Augmented Generation (modular RAG) and Spring Boot — Task Execution and Scheduling.
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:
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 |
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 |
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 |
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:
-
Collect real questions from the proof of concept and turn a hundred of them into a golden set with expected sources (Evaluation at Scale).
-
Fix ingestion first: parse the hardest documents correctly and attach ACLs and content hashes (Document Parsing, Access Control & Privacy).
-
Freeze the API contract and stream from day one (Serving RAG as an API).
-
Persist conversations keyed by the authenticated user (Conversational RAG & Memory).
-
Add citations and an abstain path before widening the audience (Citations & Grounding).
-
Instrument every stage and set the latency and cost budgets as alerts (Observability & Tracing).
-
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.