RAG Latency Optimization: Making Retrieval Feel Instant
The 4-Second Problem Nobody Talks About
You built a RAG system. The answers are accurate. The citations are correct. Your demo to stakeholders went great. Then you shipped it, and the first support ticket says: "Why does it take 4 seconds to answer a simple question?"
This is the part of RAG that tutorials skip. Every "build a RAG app in 20 minutes" guide gets you to a working pipeline, but working and fast are different problems. A naive RAG stack — embed the query, hit a vector database, stuff the top-k chunks into a prompt, call an LLM, stream the response — can easily rack up 3 to 6 seconds of latency before the user sees a single token. Users tolerate that for exactly one query before they start calling your product "slow."
The good news is that RAG latency is not one problem, it's five or six smaller problems stacked on top of each other, and each one has a well-understood fix. This article walks through where the milliseconds actually go, and what to do about each bottleneck: embedding calls, vector search, reranking, context assembly, and generation. None of this requires switching vector databases or buying a bigger GPU. Most of it is architecture and defaults you got wrong the first time.
Where the Milliseconds Actually Go
Before optimizing anything, instrument it. The single biggest mistake teams make is optimizing the part of the pipeline that "feels slow" instead of the part that measurably is. Wrap every stage in a timer and log it per request.
import time
from contextlib import contextmanager
@contextmanager
def timed(label, sink: dict):
start = time.perf_counter()
yield
sink[label] = round((time.perf_counter() - start) * 1000, 1)
timings = {}
with timed("embed_query", timings):
query_vector = embed_model.encode(query)
with timed("vector_search", timings):
candidates = vector_db.search(query_vector, top_k=25)
with timed("rerank", timings):
ranked = reranker.rank(query, candidates, top_k=5)
with timed("context_assembly", timings):
context = build_context(ranked)
with timed("llm_generation", timings):
answer = llm.generate(prompt=context + query)
print(timings)
# {'embed_query': 42.1, 'vector_search': 18.7, 'rerank': 310.4,
# 'context_assembly': 3.2, 'llm_generation': 2140.6}Run this against a representative sample of real queries — not your five favorite demo questions — and you'll usually find a distribution like this: embedding is fast (tens of milliseconds), vector search is fast (tens of milliseconds if your index is sane), reranking is often the silent killer (hundreds of milliseconds if you're calling a hosted cross-encoder API), context assembly is basically free, and generation dominates everything else because it's bound by token count and time-to-first-token.
That means the two places worth attacking first are reranking and generation, with vector search and embedding usually needing only small tuning. Let's go stage by stage anyway, because each one has failure modes that show up under load even if they look fine in a demo.
Fixing the Embedding Step
Embedding the incoming query is usually cheap, but two mistakes make it expensive: calling a remote embedding API on the hot path, and re-embedding things you've already embedded.
If you're using a hosted embedding API (OpenAI, Cohere, etc.) for the query itself, you're paying a network round trip — often 100-300ms depending on region — for every single query, on top of whatever the model inference costs. For query embedding specifically, a local, small, fast embedding model is almost always the better trade. You don't need the same model you used to embed your corpus; you need a model whose vectors live in a compatible space, and for most use cases a distilled sentence-transformer running on CPU is fast enough that it stops mattering.
from sentence_transformers import SentenceTransformer
# Loaded once at process startup, not per-request
local_embedder = SentenceTransformer("BAAI/bge-small-en-v1.5", device="cpu")
def embed_query_fast(query: str):
# normalize_embeddings=True avoids a separate normalization pass later
return local_embedder.encode(
query,
normalize_embeddings=True,
show_progress_bar=False,
)On a modern CPU this runs in single-digit milliseconds for short queries. If you're on GPU already for generation, batch the embedding call onto the same device and you'll barely notice it.
The second mistake — re-embedding repeated or near-duplicate queries — is solved with a simple cache. In production RAG systems, a meaningful fraction of queries are repeats or paraphrases of previous questions ("what's your refund policy" asked five different ways in an hour). An exact-match cache on the normalized query string catches the easy cases:
from functools import lru_cache
@lru_cache(maxsize=10_000)
def embed_query_cached(normalized_query: str):
return tuple(local_embedder.encode(normalized_query, normalize_embeddings=True))For paraphrase-level caching (semantic cache), store recent query embeddings and check cosine similarity against a threshold like 0.97 before doing a full retrieval pass — if it's a near-exact match, reuse the cached retrieval result instead of hitting the vector DB again. This is a bigger lift architecturally but pays off at scale.
Vector Search Tuning That Actually Moves the Needle
Vector search itself is rarely the bottleneck people assume it is — a well-configured HNSW or IVF index answers top-k queries in single-digit to low double-digit milliseconds even against tens of millions of vectors. When it isn't, the cause is almost always one of these:
- top_k set too high. Pulling 100 candidates "to be safe" when you only ever use the top 5 doubles or triples search time for no benefit. Retrieve what your reranker actually needs, nothing more.
- Index not tuned for the corpus size. Default HNSW parameters (
ef_search,M) are tuned for a generic case. If you have under 100k vectors, a flat index can outperform HNSW because there's no graph traversal overhead. If you have tens of millions, undertunedef_searchforces the index to visit far more nodes than necessary. - Filtering after search instead of during it. If you're retrieving 50 candidates and then filtering by metadata (tenant ID, date range, document type) in application code, you're wasting retrieval budget on candidates that get thrown away. Push filters into the vector database's native filter support so the index itself skips ineligible vectors.
- No connection pooling. If every request opens a fresh connection to your vector database, you're paying TCP/TLS handshake cost on top of the actual query. This shows up as latency that scales with concurrent load rather than corpus size — a telltale sign it's a connection problem, not a search problem.
# Bad: filter after retrieval
candidates = vector_db.search(query_vector, top_k=100)
filtered = [c for c in candidates if c.metadata["tenant_id"] == tenant_id][:5]
# Good: filter is pushed into the index
candidates = vector_db.search(
query_vector,
top_k=5,
filter={"tenant_id": tenant_id},
)If you're on Postgres with pgvector, make sure you've actually built an index (CREATE INDEX ... USING hnsw) rather than relying on a sequential scan — it's a common oversight in early-stage RAG builds where the table started small and nobody revisited it after the corpus grew to hundreds of thousands of rows.
It's also worth checking where your vector database physically lives relative to your application server. A vector search that takes 8ms to execute can still cost 60-80ms end-to-end if the database is in a different cloud region than the service calling it. This is easy to miss because the query itself looks fast in the database's own metrics — the cost is hiding in network hops that never show up in the vector DB's dashboard. Co-locating the retrieval service and the vector store in the same region, or the same availability zone if your traffic volume justifies it, removes latency that no amount of index tuning will touch.
# Quick sanity check: separate network time from actual search time
import time
t0 = time.perf_counter()
response = vector_db.search(query_vector, top_k=10)
t1 = time.perf_counter()
client_side_ms = (t1 - t0) * 1000
server_side_ms = response.metadata.get("search_time_ms", None)
if server_side_ms and (client_side_ms - server_side_ms) > 30:
print(f"Network overhead: {client_side_ms - server_side_ms:.1f}ms — check region/co-location")The Reranking Bottleneck
Reranking is where naive RAG pipelines quietly bleed the most time, because cross-encoder rerankers are far more expensive per-document than the bi-encoder retrieval step. A bi-encoder just compares two precomputed vectors; a cross-encoder runs the query and each candidate document jointly through a transformer, which is orders of magnitude more compute per comparison.
If you're calling a hosted reranking API and reranking 25 or 50 candidates per query, you're paying that cost on every request, and it often dominates total latency more than generation does for shorter answers.
Three fixes, roughly in order of effort:
- Rerank fewer candidates. Going from top-50 to top-15 before reranking often loses almost nothing in answer quality if your first-stage retrieval is decent, and cuts reranker latency proportionally.
- Use a smaller, local cross-encoder instead of a hosted API call. Models like
cross-encoder/ms-marco-MiniLM-L-6-v2run in tens of milliseconds on CPU for a batch of 15-25 candidates, versus hundreds of milliseconds for a network round trip to a hosted reranking endpoint. - Batch the cross-encoder call. Don't score each candidate individually — cross-encoders are built to take a list of (query, document) pairs and score them in one forward pass.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", max_length=512)
def rerank(query: str, candidates: list[str], top_k: int = 5):
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs, batch_size=32) # one batched forward pass
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:top_k]]A subtler option worth considering: skip reranking entirely for queries where your first-stage retrieval confidence is already high (the top result's similarity score is well clear of the second result). Reranking exists to disambiguate close calls — if there's no close call, you're spending latency to reorder a list that was already correctly ordered.
Speeding Up Generation Without Losing Quality
Generation is usually the largest single chunk of end-to-end latency, and it's also the stage where teams most often reach for the wrong fix — swapping to a bigger, "smarter" model when the actual problem is architecture, not model choice.
Stream, don't wait. If your application waits for the full completion before showing anything, users perceive the entire generation time as dead air. Streaming tokens as they're produced doesn't reduce total generation time, but it collapses perceived latency to time-to-first-token, which is usually a fraction of total generation time.
def stream_answer(prompt: str):
for chunk in llm.stream(prompt=prompt, max_tokens=500):
yield chunk.text
# send chunk to the client immediately, don't bufferTrim the context you send. A common anti-pattern is stuffing all 5-10 retrieved chunks into the prompt at full length "just in case." Longer prompts mean longer prefill time before the first token even starts, especially on self-hosted models without prompt caching. Trim chunks to the passages that are actually relevant — often a sentence-level extraction from each chunk beats sending the whole chunk verbatim, and it's cheaper for the model to process.
Use prompt caching if your provider supports it. If your system prompt and instructions are static across requests and only the retrieved context and user query change, prompt caching lets the model skip re-processing the static prefix on every call. For RAG specifically, this matters less for the retrieved context (which changes per query) and more for your instruction template, output format rules, and few-shot examples — put those first in the prompt, and put the variable retrieved context after them, so the cacheable prefix is as long as possible.
Match model size to task difficulty. Not every query needs your largest model. A classification step that routes "what's your return policy" to a smaller, faster model and routes genuinely complex multi-hop questions to a larger one can cut average latency significantly, since most real-world RAG queries are simpler than the hardest queries your eval set is designed to catch.
def choose_model(query: str, retrieved_chunks: list[str]) -> str:
# cheap heuristic: short factual queries with a single strong match
# don't need the expensive model
if len(query.split()) < 12 and len(retrieved_chunks) <= 2:
return "fast-model"
return "large-model"Parallelizing What Doesn't Need to Be Sequential
A lot of RAG latency isn't from any single stage being slow — it's from stages running one after another when they don't have to. Two patterns worth checking in your own pipeline:
Overlap embedding with anything independent of it. If you're also doing a keyword/BM25 search alongside vector search (hybrid retrieval), run both concurrently instead of sequentially.
import asyncio
async def hybrid_retrieve(query: str, query_vector):
vector_task = asyncio.create_task(vector_db.search_async(query_vector, top_k=25))
keyword_task = asyncio.create_task(bm25_index.search_async(query, top_k=25))
vector_results, keyword_results = await asyncio.gather(vector_task, keyword_task)
return merge_and_dedupe(vector_results, keyword_results)Speculatively start generation-adjacent work while retrieval is still running. For multi-step RAG (query rewriting, then retrieval, then generation), see if any step can be reordered or run in parallel rather than strictly sequentially. Query rewriting/expansion, for instance, doesn't need to block on anything except the raw user input — it can run concurrently with an initial coarse retrieval pass on the unrewritten query, and you reconcile the two results afterward.
Batch requests when you control the traffic pattern. If your RAG system serves internal batch jobs (nightly report generation, bulk document tagging) rather than only live user queries, batch multiple queries into a single generation call where your model and serving stack support it. Batched inference amortizes fixed overhead across requests and can meaningfully improve throughput even though it doesn't reduce any single request's floor latency.
Caching Strategy for RAG Systems
Caching in RAG is not one thing, it's a stack of independent caches, each catching a different kind of repetition:
- Query embedding cache — catches identical queries (see the embedding section above).
- Retrieval result cache — keyed on the query (or its embedding, with a similarity threshold), storing the final reranked chunk list. Skips vector search and reranking entirely for repeat queries.
- Full response cache — keyed on query plus retrieved-context hash, storing the final generated answer. Only safe if your underlying documents don't change frequently, since a stale cache will serve outdated answers.
- Prompt-prefix cache (provider-side) — catches the static instruction portion of your prompt template, as discussed above.
A practical pattern for many products is a two-tier cache: an in-memory LRU for the hottest queries (support FAQs, common product questions) that resets on deploy, backed by a Redis-based cache with a TTL for broader reuse across instances.
import redis
import hashlib
import json
r = redis.Redis(host="localhost", port=6379, db=0)
def cache_key(query: str, tenant_id: str) -> str:
raw = f"{tenant_id}:{query.strip().lower()}"
return "rag:" + hashlib.sha256(raw.encode()).hexdigest()
def get_cached_answer(query: str, tenant_id: str):
key = cache_key(query, tenant_id)
cached = r.get(key)
return json.loads(cached) if cached else None
def set_cached_answer(query: str, tenant_id: str, answer: dict, ttl_seconds: int = 3600):
key = cache_key(query, tenant_id)
r.setex(key, ttl_seconds, json.dumps(answer))The TTL matters more than people expect. Too long and you serve stale answers after a knowledge base update; too short and you lose most of the benefit. A reasonable default is to tie the TTL to how often your source documents actually change, and to invalidate proactively (not just via TTL) whenever a document backing a cached answer is updated.
Measuring It Like an SLO, Not a Vibe
Once you've applied the fixes above, the temptation is to eyeball a few queries, see they feel faster, and call it done. That's how regressions creep back in six weeks later when someone adds "just one more" reranking step or bumps top_k "to improve recall."
Treat RAG latency as a proper SLO with percentiles, not just an average:
- p50 tells you what a typical user experiences.
- p95 tells you what your unluckiest-but-still-normal users experience — this is usually the number that actually drives complaints.
- p99 catches pathological cases: cold caches, cross-region calls, retry storms.
import numpy as np
def report_latency_percentiles(samples_ms: list[float]):
p50, p95, p99 = np.percentile(samples_ms, [50, 95, 99])
print(f"p50={p50:.0f}ms p95={p95:.0f}ms p99={p99:.0f}ms")Set a budget per stage (for example: embedding under 50ms, retrieval under 100ms, reranking under 150ms, generation time-to-first-token under 800ms) and alert when any stage's p95 breaches its budget. This catches regressions at the stage level instead of only noticing "the app feels slow" after users complain, and it tells you exactly which stage to look at instead of re-profiling the whole pipeline from scratch.
It's also worth tagging your latency logs with metadata beyond just the timing numbers: query length, number of retrieved chunks, cache hit or miss, which model handled generation. Without that context, a p95 regression tells you something got slower but not why. With it, you can slice the data and often find the real cause in minutes — for example, discovering that your p95 breach only happens on queries longer than 40 tokens, which points straight at context assembly or prefill time rather than a vague "the reranker is slow today."
def log_request_latency(timings: dict, meta: dict):
record = {**timings, **meta, "timestamp": time.time()}
# ship to your metrics store (Prometheus, Datadog, a plain log line
# that gets parsed downstream — the destination matters less than
# having the dimensions to slice by)
metrics_client.record("rag_latency", record)
log_request_latency(
timings,
meta={
"query_length_tokens": len(query.split()),
"chunks_retrieved": len(candidates),
"cache_hit": cache_hit,
"model": model_used,
},
)This is the difference between a monitoring setup that tells you something is wrong and one that tells you what to fix. Teams that skip this step tend to re-litigate the same "is it the reranker or the model?" debate every time latency regresses, because nobody kept the data that would settle it in the first place.
Bringing It All Together
None of the individual fixes here are exotic — a local embedding model, a smaller reranker, streaming output, a two-tier cache, and percentile-based monitoring. What makes the difference is applying them as a system rather than picking one and hoping. A RAG pipeline that embeds locally, retrieves with a tuned index, reranks a trimmed candidate set with a fast cross-encoder, streams generation with a cached instruction prefix, and serves repeat queries from cache can realistically go from several seconds to a few hundred milliseconds of perceived latency, without touching answer quality.
The order of operations matters too: instrument first, then fix the stage that's actually slow in your specific pipeline, not the stage that's slow in someone else's blog post. Reranking and generation dominate more often than not, but the only way to know for your system is to measure it.
If you're still getting comfortable with how retrieval, chunking, and generation fit together before diving into performance tuning, our Introduction to RAG course covers the full pipeline from first principles, so you have a solid mental model of what you're optimizing before you start pulling levers.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.