# Your local RAG isn't slow — it re-reads every document on every question

> Source: <https://dev.to/dreamdeck/your-local-rag-isnt-slow-it-re-reads-every-document-on-every-question-18jg>
> Published: 2026-08-26 10:18:51+00:00

A user opens a project with nine files in it, types the most obvious question

anyone types at a document app — "what are these documents about?" — and waits.

291 seconds.

Then they ask a second question, about one of those documents, and wait again.

Minutes, not seconds. At that point the app has told them something about

itself, and what it has told them is: this model is slow and probably stupid.

The model was neither. It was reading. It read the entire retrieved corpus,

from token zero, for question one. Then it read it again for question two.

Setup, for symptom matching: an offline desktop app on llama.cpp, M4 Pro with

24 GB, a 14B at Q5, nine files in the project.

Two throughput numbers, both from my own logs:

```
generation:  ~8 tokens/second     <- normal for a 14B at Q5 on this box
prefill:   ~100 tokens/second     <- also normal
```

Neither is a bug. The bug is the ratio, and what got multiplied by it. That one

question assembled a **13,773-token prompt**, and the turn as a whole pushed

**17,373 prompt tokens** through the model once you count the service calls it

makes on the side. At a hundred tokens a second, reading dominated writing by

roughly five to one; the profile came out north of 85% prefill.

Divide those numbers yourself and you'll land a few dozen seconds off. Prefill

throughput sags as the context grows, and one "question" is more than one model

call. The shape is the point, not the arithmetic: the machine spent its evening

reading, and the part the user was waiting for — the answer — was cheap.

I want to be precise about the thing I had gotten wrong for a long time,

because I don't think I'm alone in it. I had indexing. Chunks, embeddings, a

vector store, the whole ritual, run once when files are added. What I assumed

that ritual bought me was that the documents were, in some sense, *already
read*.

It buys nothing of the kind. Retrieval is a table of contents, not a memory. It

finds the right pieces quickly; it does not make the model read them faster,

and it does not make the model remember having read them. Every question ships

fresh text into the context window and the model chews it from the first token,

at prefill speed, every time. In classical RAG, indexing time and reading time

are separate budgets, and only one of them is ever spent in advance.

Three things came out of the logs before I got to the interesting part.

I run lexical search by default and vector search behind a flag. On the

machines where both are on, both contribute passages to the prompt. They union

their results, deduplicated by chunk id.

Chunk id. Not content. The two engines index with different chunk boundaries,

so the same paragraph arrives as two different ids with substantially the same

text, and the union happily keeps both.

Roughly **9,000 tokens per question** were the same passages, twice. Not once

in a while — on every question, for as long as both engines had been on. Nobody

caught it by reading the prompt, because nobody reads the prompt; it is a wall

of text that scrolls past in a debug log and looks exactly like a wall of text

is supposed to look.

Deduplicating on normalized content instead of id took the question from **291
to 150 seconds**. That is the least interesting bug here and it was

llama.cpp keeps a per-slot prompt cache. Send a prompt that shares a prefix

with the last one that slot saw, and it skips prefill for the shared part. In a

chat app that should be most of the system prompt, most of the time.

Mine, trimmed:

```
slot update_slots: id  0 | task 412 | n_past = 3, cache_tokens = 3, n_prompt_tokens = 13773
```

Three tokens. Beginning-of-sequence and a bit of chat template. The cache had

never once helped, on any turn, since the feature existed.

The cause was a helper I'd been pleased with. Before retrieval, a small service

call rewrites the user's question into a better search query — resolving "it"

and "that contract" against the conversation. It's a good feature. It ran with

its own system prompt, and it ran **into the same slot** as the conversation.

Slot caches key on the longest common prefix. Two different system prompts

diverge at token three. So the service call evicted the conversation's cached

prefix, the conversation's next turn evicted the service call's, and the two

took turns doing this forever. The cache was working exactly as designed. It

was caching a conversation that alternated, every single turn, with a

completely different conversation, in the same chair.

Splitting the slots — a pool for conversations, a pool for service calls, never

shared — moved the hit rate from **0% to 45-48%**.

There was already a background "study" pass in the codebase, meant to

pre-summarize documents after import. It kept its queue in process memory.

Close the app, and every unfinished item is gone. Not retried — gone, with no

record that it had been scheduled. And it only ran while the app was open and

otherwise idle, which on a desktop app is a narrow and unreliable window.

Net effect: summaries were essentially never present when a question arrived,

so every question fell back to reading document bodies. A background job that

doesn't survive a restart is a background job that never finishes, because the

user closes the window constantly and does not consider this an unusual thing

to do.

Here is the reframe the whole thing turned on. Prefill is not a cost you can

optimize away — a 14B reading N tokens has to read N tokens. It is a cost you

can **move**. The question is whether the model reads a document at question

time, while a human watches a spinner, or at indexing time, when nobody is

waiting.

Three pieces, in order of how cheap they are.

For each file, a small structured record: document type, parties, dates,

amounts, page count, and a table of contents extracted from heading patterns

with a regular expression. Not with the model — with a regex, at import, in

milliseconds.

That's about 200 tokens per document. For a nine-file project, "what are these

documents about?" now has a **1,800-token** answer surface where it used to

have 13,773.

The honest limit: heading extraction works on documents that have formatting

and fails flat on an unstructured wall of text. Those fall through to the pass

below.

Per file: chunk it, summarize each chunk, summarize the summaries. This costs

exactly the minutes you were paying before — the model still reads the whole

document — except it costs them **once**, on import, and never again.

The queue rules matter more than the summarization prompt:

The user does pay for this. They pay in fan noise, once per file, at a moment

when they are not staring at a progress bar.

Routing is the risky part and I'd rather name the risk than sell around it.

Misroute a specific question into the digest lane and you answer from a summary

that dropped the exact number the user wanted, confidently and wrongly. Two

mitigations, both boring: the router is biased to escalate — if a question

mentions a term that appears in a passport's table of contents but not in the

summary, it goes to chunks — and the app shows which lane answered, so a thin

answer has an obvious "go read it properly" next to it.

| before | after | ||
|---|---|---|---|
| "what are these documents about?", 9 files | 291 s | 102 s | 2.9x |
| prompt for that question | 13,773 tok | 5,709 tok | 2.4x |
| the same question asked verbatim again | minutes | ~1 s | exact-match cache |
| prefix cache hit rate, specific questions | 0% | 45-48% | slot split |

102 seconds is not a good number. It is a much better number, and it is an

honest one: a 14B on a laptop reading five thousand tokens has to read five

thousand tokens, and no amount of architecture argues with that.

The exact-match cache is the cheapest line in the table and it exists because

of a behavior I did not predict. Users re-ask the identical question. They

close the app, come back, and type the same words to see whether it's still

right. Hashing the normalized question plus the resolved context set and

keeping the answer turns that into a second.

The reusable part of a prompt is the part that doesn't change. System prompt

and passports are stable, so they cache. Retrieved excerpts change with the

question — by construction, since changing them is the entire job of retrieval.

You could force them to cache. Retrieve once per conversation, freeze the

context, and every subsequent turn shares a long identical prefix. I sat with

that for a while and turned it down. The second question in a conversation is

usually about something the first question didn't retrieve; freezing the

context buys cache hits and pays for them in wrong answers. 45-48% is what the

stable prefix is genuinely worth in this layout, and I'd rather report that

number than a better one I bought with accuracy.

One thing that is free: **order the prompt by volatility**. Stable first

(system prompt, passports), volatile last (excerpts, then the question). Get

that backwards and your hit rate is zero no matter how much of the prompt is

technically stable.

Before claiming any of this was novel I looked at what the neighbors ship: LM

Studio, AnythingLLM, Jan, GPT4All, Open WebUI with Ollama.

All of them chunk and embed at index time. Not one of them precomputes

per-document digests or summaries. The best of them keep a prompt cache, which

helps with the system prompt and does nothing for the retrieved half.

Which means that on "what are these documents about?" — the single most common

opening question a human asks a document app, the one they type before they

type anything else — every one of these re-reads the corpus at full prefill

cost, every time.

I'm supposed to call that a gap in the market. It's really a gap in the default

architecture: the reference RAG design does retrieval at question time and

nothing at index time except embeddings, everybody copied it faithfully, and

the copy is correct. It's just that "correct" and "the user waited five

minutes" are compatible states.

**Measure prefill against generation before you optimize anything.** If 85% of

your wall clock is reading, a faster sampler and a smaller quant are noise. The

engine prints both numbers; find the line.

**Deduplicate context sources by content, not by id.** Two retrievers with

different chunk boundaries will hand you the same paragraph twice and neither

will look wrong in isolation. This was 9,000 tokens a question in my app.

**Give service LLM calls their own KV slot.** Query rewriting, classification,

title generation — anything with its own system prompt sharing a slot with the

conversation will zero your prefix cache and the logs will still say the cache

is enabled.

**Precompute passports and summaries at index time.** A ~200-token structured

passport per document, with headings pulled by regex rather than by the model,

answers most broad questions on its own and costs no GPU at all.

**Put the study queue in the database.** In-memory queues on a desktop app do

not survive contact with users, who close windows. Checkpoint per chunk, resume

on launch, yield to live traffic, stop on battery.

**Cache the literal repeats.** People re-ask the same question verbatim more

than you'd think. Hash the normalized question plus the resolved context set.

If you want to know whether any of this is worth your afternoon, the arithmetic

is short enough to run before you commit to it: file size, prefill speed,

number of questions, and it tells you how many seconds of pure reading you have

already signed up for. That's `how_long_will_my_rag_wait.py`

— [https://github.com/JackYU96/rag-rereads-every-question](https://github.com/JackYU96/rag-rereads-every-question) — in the repo next

to this post — no dependencies, one file, bring your own numbers.

The last time I chased a number like this, the KV cache itself turned out to be

eating it: ["V cache quantization requires flash_attn" — the llama.cpp error
that quietly halves your context
window](https://dev.to/dreamdeck/v-cache-quantization-requires-flashattn-the-llamacpp-error-that-quietly-halves-your-context-1kdb).
