Where time is spent in a typical RAG turn
- Query embedding — 30–200 ms (hosted) or 5–20 ms (local small model)
- Vector search — 5–50 ms (Chroma, single-node) or 1–10 ms (pgvector with HNSW)
- (optional) BM25 — 5–30 ms
- (optional) Cross-encoder rerank on 50 — 100–500 ms (CPU) or 30–150 ms (GPU)
- LLM generation — 500 ms (small) to 10 s (large + long output). Dominates everything else.
The order of optimization
Profile first, optimize the dominant cost. Cutting query embedding from 100 ms to 10 ms is wasted effort if the LLM call is 4 seconds. Streaming the LLM response is usually a bigger UX win than cutting retrieval latency in half.
Async, not threading
Retrieval is I/O bound. Use async (asyncio in Python, async/await everywhere else) so you can issue the embedding call, vector search, and BM25 in parallel when the architecture allows. Threading is the wrong abstraction for this work.