The biggest bottleneck constraining the time to first token in most RAG systems for chatbots or agents is definitely the prefill stage, especially for long contexts.
For large financial firms, however, the bottleneck is more skewed. Retrieval in RAG in this setting is especially expensive compared to most others, for two main reasons.
- First, financial companies deal with gigantic volumes of data: financial reports are long and complex, and companies must keep many records for logging and auditing purposes, among other reasons.
- Second, RAG systems need to deal with especially complex metadata filtering. Metadata for financial documents might include region, sensitivity, compliance info, temporal constraints - all of which might be not at the document level, but more fine-grained because of specific redactions or permissions. This latter point, especially, is what breaks the nice properties of ANN algorithms during regular RAG retrieval. Because of these two observations on financial RAG pipelines specifically, the filtering and retrieval (before prefill even occurs) take up a nontrivial portion of TTFT.
My solution for this was to make the process more interactive using an idea called speculative retrieval. As the user types the query into the chat interface, the system intermittently triggers background jobs to embed the current partial query and asynchronously start the filtering and retrieval process. The process finishes in the background and stores the candidate chunks for that partial query. Later processes overwrite earlier ones, since only the most recent partial query is relevant. When the user finally submits the full query, it goes through a series of checks to see how much of the process can be bypassed:
- We first check the last turn’s embedded query vector. If the questions/prompts are similar enough, we can simply keep the chunks from the last turn’s retrieval.
- Then, we check the embedded query vector of the last speculative job that completed while the user was typing. Again, if the two are similar enough, simply load in the pre-retrieved chunks.
- If neither, it is likely that the user typed very quickly or the meaning suddenly shifted, in which case the full filtering and retrieval process is performed for correctness.
I created a demo with a chat interface to demonstrate the difference in interactivity between the naive version and the optimized path with speculative retrieval and prefix caching. I created around 40 fake financial documents, chunked and embedded them with OpenAI embeddings, and served the LLM responses through Claude Haiku 4.5 (using Anthropic’s prefix caching API for the optimized path). I then tested the system on different types of conversations: first, on single turn, cold start prompts, and second, on longer (12-15 turns), multi-turn conversations. For the latter, one conversation aimed to have subsequent queries follow closely in context behind each other, similar to how a real analyst might prompt for info and reasoning. The other aimed to be more adversarial and jump around to different contexts.
Looking at the results, the optimized system yields consistently lower TTFTs for the single turn tests, resulting in a faster, more interactive experience. For the multi turn conversations, the optimizations result in much better average and p95 TTFTs, which is a stronger guarantee especially for latency-sensitive workflows. Full results are below and can be rerun with the perf script.
- Next.js App Router + TypeScript
- Supabase (Postgres + pgvector +
match_chunksRPC) - Anthropic Claude Haiku for streaming answer generation
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
ANTHROPIC_API_KEY=...
OPENAI_API_KEY=...npm install
npm run devnpm run seedinserts 30+ fake documents / 100+ chunks across several companies, with mixed metadata:
doc_type, quarter, access_scope.
npm run perfruns all of the performance tests. For both implementations, we run these tests:
- single prompt response (15 different prompts)
- multi turn conversation (2 conversations)
- conversation 1 is written to stay within similar context
- conversation 2 is written to be more adversarial, jumping around unrelated questions
- Client-side TTFT (
request send->first streamed token) - Client-side total response time
- Retrieved chunk count
- Retrieval-stage latency (server-side)
- Retrieved chunk debug panel (title + metadata + snippet)
All numbers below are from the final full-suite run (naive vs optimized).
| Mode | TTFT avg | TTFT p50 | TTFT p95 | TTFT min | TTFT max | Total avg | Total p50 | Total p95 | Total min | Total max |
|---|---|---|---|---|---|---|---|---|---|---|
| Naive | 2230ms | 1585ms | 4350ms | 1337ms | 5306ms | 3443ms | 2795ms | 5715ms | 2132ms | 6854ms |
| Optimized | 1689ms | 1266ms | 3323ms | 1044ms | 3368ms | 2830ms | 2742ms | 4134ms | 1524ms | 4723ms |
Optimized reuse paths (single-turn): speculative reuse hit 15/15.
| Mode | TTFT avg | TTFT p50 | TTFT p95 | TTFT min | TTFT max | Total avg | Total p50 | Total p95 | Total min | Total max |
|---|---|---|---|---|---|---|---|---|---|---|
| Naive | 1899ms | 1381ms | 1778ms | 1138ms | 6830ms | 2905ms | 2462ms | 3026ms | 1764ms | 8158ms |
| Optimized | 1458ms | 1466ms | 1624ms | 1054ms | 1864ms | 2393ms | 2305ms | 3013ms | 1695ms | 3391ms |
Optimized reuse paths (conversation 1): speculative reuse hit x6, evidence reuse hit x3, retrieval fallback triggered x3.
| Mode | TTFT avg | TTFT p50 | TTFT p95 | TTFT min | TTFT max | Total avg | Total p50 | Total p95 | Total min | Total max |
|---|---|---|---|---|---|---|---|---|---|---|
| Naive | 1946ms | 1362ms | 2464ms | 1281ms | 6590ms | 2915ms | 2484ms | 3717ms | 1514ms | 7525ms |
| Optimized | 1422ms | 1405ms | 1700ms | 1026ms | 1794ms | 2469ms | 2490ms | 2842ms | 1432ms | 3638ms |
Optimized reuse paths (conversation 2): speculative reuse hit x12, evidence reuse hit x1, retrieval fallback triggered x2.