RAG Over Private Documents: The Full Architecture, Failure Modes, and Cost Per Answer
- All five RAG failure stages produce the same user-visible symptom. The only way to localise a bad answer is to test each stage in isolation: retrieval first, generation last.
- Post-filtering for permissions after an ANN search silently destroys recall: a top-50 retrieval where 8% of chunks pass the user's ACL leaves four usable chunks. Permissions belong in the pre-filter.
- A worked example puts one answer at about 2.6 cents uncached and 1.5 cents with a cached prefix. Reranking 50 candidates down to 8 costs less than stuffing 20 chunks into context, and is more precise.
Why does RAG return wrong answers?
Because five different stages can fail and all five produce the identical user-visible symptom: it gave me a wrong answer. Ingestion can drop the fact. Chunking can split it across a boundary. Retrieval can rank it 34th. Reranking or an ACL filter can drop it from the final context. Generation can have it and ignore it. Nothing in the answer tells you which.
So the first thing this post gives you is a diagnostic, not a prescription. Given one wrong answer, you can localise the failing stage in under ten minutes with four checks, in order, and you should do that before you change a single line of your prompt. The ordering matters: test retrieval first and generation last, because generation is the stage everyone reaches for and the one least often at fault.
Now the note I most want quoted. The RAG statistics circulating in 2026, that 73% of failures are retrieval, 80% trace to ingestion, naive RAG fails to retrieve correct context 40% of the time, do not trace to any locatable published study. They circulate between blog posts citing each other. I will not repeat them as fact, and neither should you in front of your board. Measure your own system: run retrieval in isolation against a golden set before you touch anything else. The harness for that is later in this post and takes an afternoon to write.
The reader this post is for is not building RAG. They have RAG, it passed a demo, and it is now giving wrong answers to real customers. That reader does not need another chunking tutorial; they need to know which of five things is broken. For the layer context first, this system is layers three and six of the full AI product architecture reference.
| Stage | The one check that localises it | Typical root cause | Fix | Cost of the fix |
|---|---|---|---|---|
| Ingestion / parsing | Grep the raw chunk text for the correct fact. If it is not there, it never entered the system | A parser returning table cells as prose, headers as body, or a scanned page as an empty string | Route by document type; check for an embedded text layer before rasterising | 2-5 engineer-days per document family |
| Chunking | Find the chunk containing the fact. If the fact is split across two chunks, or the chunk has no heading context, it is chunking | Fixed-size chunking cutting a table or a clause in half | Structure-aware chunking; parent-document retrieval; heading path in metadata | 3-5 engineer-days |
| Retrieval | Run retrieval alone and look at the top 50. If the right chunk is at rank 34, it is retrieval | Dense-only search on a query with rare proper nouns or identifiers | Hybrid search: vector plus BM25, fused with reciprocal rank fusion | 2-3 engineer-days |
| Rerank / ACL filter | Check whether the chunk survived into the final context. If it was in the top 50 and not in the context, it is here | Post-filtering for permissions after the ANN search, or an over-aggressive reranker threshold | Pre-filter the candidate space by ACL; tune the over-fetch factor from measured selectivity | 3-5 engineer-days |
| Generation | Put the correct chunk in the context by hand. If the answer is still wrong, it is generation | Distractors in context, a prompt that permits speculation, no refusal path | Citation-required output with span validation; an evaluated I-do-not-know path | 2-4 engineer-days |
- 11. Is the fact present in any raw chunk?2 minutes · SQL
Full-text search your chunk table for a distinctive phrase from the correct answer. No hit means the failure is upstream of retrieval entirely — a parser or a chunker dropped it, and no amount of prompt work will recover it.
- 22. Is that chunk in the top 50 of raw retrieval?3 minutes · one script
Run retrieval with the reranker and the ACL filter disabled. If the chunk ranks below 50, retrieval is your problem: usually dense-only search on a query full of identifiers, which BM25 would have caught.
- 33. Is it in the final context after rerank and ACL?2 minutes · trace
Log the chunk ids that actually entered the prompt. If the right chunk was in the top 50 and is not in the context, it was dropped by the reranker or the permission filter — and the permission filter is the more likely of the two.
- 44. Does the model answer correctly with only that chunk?3 minutes · one call
Hand-build a context containing exactly the right chunk and re-ask. A wrong answer here is a generation problem. A correct answer here means the problem is distractors or ordering in context assembly, which is a different fix.
How do you handle permissions without destroying recall?
By applying the ACL as a pre-filter over the candidate space, never as a filter over the result set. This is the most consequential architectural decision in enterprise RAG, and most published architectures treat it as a parameter rather than a design.
The arithmetic is brutal and worth stating precisely. You retrieve the top 50 chunks by similarity, then filter them by what this user may see. If 8% of your corpus is visible to this user, roughly four chunks survive, and those four are the four most similar chunks the user happens to be allowed to see, which is not the same set as the four most relevant chunks the user is allowed to see. Recall collapses, and it collapses invisibly, because the system returns an answer either way.
Pre-filtering means the approximate-nearest-neighbour search runs over only the chunks the caller may see. In Postgres with pgvector that is an indexed array-overlap predicate on acl_principals combined with tenant_id, evaluated as part of the query rather than after it. You still over-fetch, because filtered ANN search degrades recall at the index level, but derive the over-fetch factor from your measured filter selectivity rather than guessing it. If a typical user can see 8% of the corpus, fetching 50 candidates from a filtered space is 50 valid candidates, not four.
The failure mode nobody plans for is permission drift. A document's ACL changes in the source system and the index does not know. Every enterprise RAG system needs a reconciliation job that re-reads ACLs on a schedule and a tombstone path that removes a deleted document from the vector index, the lexical index and the answer cache. That last one is three deletes and a reconciler, and skipping it is how a compliance incident starts.
| Strategy | Recall impact | Latency | Isolation guarantee | When to use |
|---|---|---|---|---|
| Post-filter after ANN | Catastrophic and invisible: top-50 at 8% selectivity leaves ~4 chunks | Lowest | Correct but useless | Never in production. It is the default in most tutorials |
| Pre-filter (metadata-filtered ANN) | Preserved, with an over-fetch factor derived from measured selectivity | +5-25ms depending on selectivity and index type | Enforced in the query; one code path | The default for multi-tenant and per-document ACLs |
| Namespace or index per tenant | Preserved | Lowest, per query | Strongest — physical separation | Few, large tenants; regulatory separation requirements |
| Row-level security in a SQL vector store | Preserved | +5-15ms | Enforced by the database, not by your code | Postgres shops. The policy is auditable, which security reviewers like |
| Hybrid: RLS plus tenant partition | Preserved | +10-20ms | Belt and braces; survives an application bug | Enterprise deployments where an ACL bug is a contractual event |
- Candidate space
- Whole corpus, 1.2M chunks
- ANN search
- Top 50 by cosine similarity
- ACL filter applied
- After retrieval, in application code
- User's visible share of corpus
- 8%
- Chunks surviving the filter
- ~4
- What reaches the reranker
- 4 chunks, none guaranteed relevant
- Symptom
- Confident answers built from whatever survived
- Detectability
- None — no error, no metric moves
- Candidate space
- Chunks where acl_principals && caller_principals
- ANN search
- Top 50 within the permitted space
- ACL filter applied
- Inside the query, on an indexed array
- User's visible share of corpus
- 8% (unchanged)
- Chunks surviving the filter
- 50
- What reaches the reranker
- 50 chunks, all valid, ranked by relevance
- Symptom
- None
- Detectability
- recall@50 on the golden set is stable across users
-- indexes that make this work
create index on chunk using gin (acl_principals);
create index on chunk using gin (tsv);
create index on chunk using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
create index on chunk (tenant_id, embedding_model_version);
-- :over_fetch — derive from measured selectivity, do not guess.
-- If the median caller can see 8% of the corpus, 50 final candidates
-- means asking the index for ~200 before fusion.
with permitted as (
select chunk_id, text, heading_path, embedding, tsv
from chunk
where tenant_id = :tenant
and embedding_model_version = :emb_version -- never mix versions
and acl_principals && :principals -- THE pre-filter
and (effective_to is null or effective_to > now())
),
dense as (
select chunk_id,
row_number() over (order by embedding <=> :qvec) as rank
from permitted
order by embedding <=> :qvec
limit :over_fetch
),
lexical as (
select chunk_id,
row_number() over (order by ts_rank_cd(tsv, plainto_tsquery(:q)) desc) as rank
from permitted
where tsv @@ plainto_tsquery(:q)
limit :over_fetch
)
select c.chunk_id, c.heading_path, c.text,
coalesce(1.0 / (60 + d.rank), 0) +
coalesce(1.0 / (60 + l.rank), 0) as rrf_score
from permitted c
left join dense d on d.chunk_id = c.chunk_id
left join lexical l on l.chunk_id = c.chunk_id
where d.chunk_id is not null or l.chunk_id is not null
order by rrf_score desc
limit 50;How should you chunk private documents?
Structure-aware, because the unit of retrieval should be the unit of meaning in that document type. A contract clause, a policy section, a support article, a table with its header row: those are the units people actually ask about. Fixed-size chunking with a token overlap is a reasonable default for prose and a genuinely bad default for anything with structure, which in an enterprise corpus is most of it.
Three metadata fields matter more than chunk size, and they are the three most commonly missing. The source path and document version, so an answer can be traced back and a superseded document can be tombstoned. The heading path, the chain of section headings above this chunk, because a chunk that says the limit is thirty days is useless without knowing the section is titled Termination and Notice. And the effective date range, because half the wrong answers in a corporate corpus are correct answers from a superseded policy.
Parent-document retrieval solves the tension between the two things you want. Embed and retrieve small chunks, because small chunks are precise. Then generate from the larger parent section they belong to, because larger sections carry the context the model needs to reason. That is one extra column, one join, and it removes most of the chunk-size argument entirely.
One field deserves its own sentence: embedding_model_version, stored on every chunk row. Mixing embeddings produced by two model versions in one index is a silent, catastrophic and extremely common bug. Similarity comparisons across model versions are meaningless, the system returns confident nonsense, and nothing in your monitoring will tell you. Store the version, filter on it at query time, and make a re-embed a deliberate migration rather than an accident.
create table chunk (
chunk_id uuid primary key,
doc_id uuid not null,
doc_version int not null,
tenant_id uuid not null,
source_uri text not null,
heading_path text[] not null default '{}', -- ['Policies','Leave','Carry-over']
parent_chunk_id uuid, -- retrieve small, generate large
text text not null,
token_count int not null,
effective_from date,
effective_to date, -- null = current
acl_principals text[] not null, -- GIN indexed; the pre-filter
embedding vector(1536) not null,
embedding_model_version text not null, -- never mix versions in one index
tsv tsvector generated always as
(to_tsvector('english', text)) stored,
ingested_at timestamptz not null default now()
);
-- the tombstone path: three deletes, one reconciler.
-- deleting a document must remove it from all three surfaces.
create or replace function tombstone_doc(p_doc uuid) returns void as $$
begin
delete from chunk where doc_id = p_doc; -- vector + lexical, same row
delete from answer_cache where p_doc = any(source_doc_ids);
insert into tombstone (doc_id, removed_at) values (p_doc, now());
end; $$ language plpgsql;- Chunk on document structure, not on a token countHeadings for policy docs, clauses for contracts, rows-with-header for tables, turns for transcripts.
- Store the heading path with every chunkA fragment without its section title is a fragment the model will misread.
- Store effective_from and effective_toSuperseded policies are the most common source of confidently wrong enterprise answers.
- Use parent-document retrievalRetrieve the precise child, generate from the surrounding parent. One column, one join.
- Never mix embedding model versions in one indexStore the version on the row and filter on it. Re-embedding is a migration, not a background job.
- Do not chunk at all when the document is shortUnder about 2,000 tokens, the document is the chunk. Chunking it only creates boundaries to fall through.
Do you need a reranker?
Usually yes, because it is cheaper than the alternative. A reranker fixes precision, not recall. It cannot surface a chunk your retrieval never found, but it is very good at demoting the eleven plausible-looking chunks that would otherwise crowd out the one correct one.
The alternative teams reach for is stuffing more chunks into a bigger context window, and the arithmetic says it is worse on both axes. On the worked example below, retrieving 50 candidates and reranking down to 8 costs about 2.5 cents per query. Stuffing the top 20 unranked chunks costs about 3.2 cents and gives the model twelve more distractors. You pay more for a less precise context.
That is the whole argument, and it holds because reranking can run on a cheap model over a compact representation of each candidate while generation input is priced at your mid or frontier tier. The gap widens as your generation model gets more expensive, which means the case for a reranker is strongest exactly where teams are most tempted to skip it.
The measured-precision column in the table below is deliberately left as a method rather than a number. Context precision depends entirely on your corpus and your query distribution, and any published figure for it is a figure about somebody else's documents. The eval harness that produces it for your system is two sections down.
| Approach | Chunks in context | Generation input tokens | Rerank cost | Generation cost | Total per query |
|---|---|---|---|---|---|
| Top-5, no rerank | 5 | 8,040 | $0.0000 | $0.0201 | $0.0201 |
| Top-20 stuffed, no rerank | 20 | 14,040 | $0.0000 | $0.0321 | $0.0321 |
| Top-50 reranked to 5 | 5 | 8,040 | $0.0027 | $0.0201 | $0.0228 |
| Top-50 reranked to 8 | 8 | 9,240 | $0.0027 | $0.0225 | $0.0252 |
| Measured context precision | Run the harness in this post against your own corpus — a published number here would be a fact about someone else's documents | ||||
| Assumptions | 400 tokens per chunk; 6,000-token static prefix, uncached; 400 output tokens | Mid tier $2/$10 per 1M | Listwise rerank on a cheap tier, 50 x 250 tokens in | Checked 24 Aug 2026 | Excludes embedding, search and trace storage |
How do you stop it from making things up?
Require citations, then validate them mechanically. Ask the model for an answer plus a list of quoted spans, and check every quote as a substring of the chunk it claims to come from. That converts hallucinated attribution from a silent failure into a loud one. The check is about fifteen lines and it is the difference between a retrieval system and a very fluent guessing machine.
When validation fails, retry once with the validation error appended to the context, and only once. This is the same rule that governs every LLM retry: an identical retry against an unchanged context reproduces the same output, because the model is close to deterministic given identical input. Append the specific failing quote and the chunk it did not appear in, and the second attempt usually succeeds or correctly refuses.
The refusal path deserves to be a first-class, evaluated output, not a fallback. I do not have enough information to answer is a correct answer for a large fraction of enterprise questions, and a system that never says it is guessing on those questions. Put refusals in the golden set with expected-refusal labels, and track the refusal rate as a monitored metric. A refusal rate that drops after a prompt change is usually bad news, not good.
A groundedness check as a second, cheap model call is worth its cost. On the worked example it adds about 0.08 cents per answer, roughly 5% of the total, in exchange for catching the class of failure where every citation is real and the synthesis over them is not. That trade is one of the better ones available in this architecture.
const AnswerSchema = {
type: "object",
required: ["answer", "citations", "sufficient"],
properties: {
sufficient: { type: "boolean" },
answer: { type: "string" },
citations: {
type: "array",
items: {
type: "object",
required: ["chunk_id", "quote"],
properties: {
chunk_id: { type: "string" },
quote: { type: "string", minLength: 12 },
},
},
},
},
} as const;
const norm = (s: string) =>
s.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
export function validateCitations(
out: Answer, chunks: Map<string, string>,
): { ok: true } | { ok: false; bad: Citation[] } {
const bad = out.citations.filter(c => {
const src = chunks.get(c.chunk_id);
return !src || !norm(src).includes(norm(c.quote));
});
return bad.length === 0 ? { ok: true } : { ok: false, bad };
}
// one mutated retry, then refuse. Never an identical retry.
export async function answerWithCitations(ctx: Ctx): Promise<Answer> {
let out = await ctx.model.json(AnswerSchema, ctx.messages);
const v = validateCitations(out, ctx.chunks);
if (v.ok) return out;
ctx.messages.push(userMessage(
"These citations do not appear in the chunks you attributed them to: " +
JSON.stringify(v.bad) +
". Quote exactly, or set sufficient=false and explain what is missing."
));
out = await ctx.model.json(AnswerSchema, ctx.messages);
return validateCitations(out, ctx.chunks).ok
? out
: { sufficient: false, answer: REFUSAL, citations: [] };
}How do you evaluate retrieval separately from generation?
With two golden sets and two harnesses, and you build the retrieval one first. This is the artefact that unblocks everything else in this post, because without it every change you make to chunking, embeddings, hybrid weighting or the reranker is a guess with a demo attached.
The retrieval golden set is a list of questions paired with the chunk identifiers that must be retrieved to answer them. Building it takes an afternoon to write the harness and about a week to populate honestly, because populating it honestly means having a human confirm which chunks actually contain the answer. Once it exists, recall at k and mean reciprocal rank are computable in seconds, and every retrieval decision becomes an experiment rather than an argument.
Four metrics cover the ground and each catches a different thing. Context recall asks whether the needed material was retrieved at all. Context precision asks how much of what was retrieved was needed. Faithfulness asks whether the answer is supported by the retrieved material. Answer relevancy asks whether the answer addresses the question. A system can be perfect on the last two and useless, because it faithfully and relevantly answers from the wrong documents.
On set size, be careful how you state it. Practitioner guidance in 2026 converges on several hundred cases before aggregate metrics are stable enough to act on, with numbers around five hundred cited most often. But that is a heuristic, not a statistical law, and the right number depends on your effect size and how many failure classes you stratify across. Fifty cases is still worth having on day two; just do not make an architecture decision on a three-point move across fifty cases. The full eval architecture is in how to build the eval set this depends on.
"""Retrieval-only eval. No judge, no generation. Python 3.10+."""
from dataclasses import dataclass
from statistics import mean
@dataclass
class Case:
question: str
must_retrieve: set[str] # human-confirmed chunk ids
principals: list[str] # evaluate WITH the caller's ACL, always
def recall_at_k(retrieved: list[str], gold: set[str], k: int) -> float:
if not gold:
return 1.0
return len(gold & set(retrieved[:k])) / len(gold)
def mrr(retrieved: list[str], gold: set[str]) -> float:
for i, cid in enumerate(retrieved, start=1):
if cid in gold:
return 1.0 / i
return 0.0
def run(cases: list[Case], retrieve) -> dict[str, float]:
r5, r20, r50, rr = [], [], [], []
for c in cases:
got = retrieve(c.question, principals=c.principals, k=50)
ids = [x.chunk_id for x in got]
r5.append(recall_at_k(ids, c.must_retrieve, 5))
r20.append(recall_at_k(ids, c.must_retrieve, 20))
r50.append(recall_at_k(ids, c.must_retrieve, 50))
rr.append(mrr(ids, c.must_retrieve))
return {
"recall@5": mean(r5), "recall@20": mean(r20),
"recall@50": mean(r50), "mrr": mean(rr), "n": len(cases),
}
# Two rules that make this trustworthy:
# 1. Always evaluate with a real principal set. A retrieval eval run as
# an admin measures a system no user has.
# 2. Stratify: identifiers, synonyms, negations, multi-hop, superseded
# documents, and questions whose correct answer is a refusal.What breaks in production?
Ten things, and the five that actually happen are all silent. That is the defining property of RAG failure: nothing throws. A re-index halves recall, a parser regresses on one document family, a deleted document stays in the index, two documents contradict each other and both retrieve, an embedding model version drifts between the index and the query path. The system returns a fluent answer in every one of those cases.
The re-index regression deserves special mention because it is so common and so invisible. Someone changes the chunker or the embedding model, re-indexes, and recall at 50 drops from 0.91 to 0.62. No alert fires. No error rate moves. The only way to catch it is to run the retrieval eval as a gate in the re-index pipeline and refuse to promote an index that regresses. That is ten lines of CI and it prevents a month of confused debugging.
The deleted-document failure is the one with legal consequences. A document is removed from the source system and remains in the vector index, the lexical index and the answer cache. Someone asks a question and receives a confident, cited answer from a document that legally no longer exists. Three deletes and a reconciliation job close it, and the reconciler matters because at least one of the three deletes will fail eventually.
Contradiction is the failure with no clean fix and it should be surfaced rather than resolved. When two retrieved chunks disagree, the correct behaviour is to say so and cite both, not to silently pick the higher-scoring one. Effective-date metadata resolves the common case where one is simply superseded; for the rest, an answer that names the disagreement is more useful than one that hides it.
| Failure | Blast radius | Detection | Time to detect | Auto-recoverable? | Mitigation |
|---|---|---|---|---|---|
| Re-index silently halves recall | All queries | recall@50 on the golden set, run as a promotion gate | Immediate with a gate; weeks without | No | Refuse to promote an index that regresses |
| Embedding model version mismatch between index and query | All queries | embedding_model_version filter returns zero rows, or similarity scores collapse | Minutes if you filter; never if you do not | No | Store the version on the row; filter on it |
| Parser regression on one document family | One document family | Chunk count per document, and mean chunk length, by source type | Days | No | Alert on ingestion shape metrics per document type |
| Deleted document remains retrievable | Regulatory | Reconciliation job comparing source inventory to index | Until someone notices | Yes, with the reconciler | Three deletes plus a scheduled reconciler |
| ACL drift after a permission change in the source | Security | Reconciliation job re-reading ACLs on a schedule | Hours to weeks | Yes | Scheduled ACL re-sync; short TTL on the principal cache |
| Contradictory documents both retrieved | Single answer | Contradiction rate in judged samples | Only via eval | No | Surface both with dates; never silently pick one |
| Chunk boundary splits a table from its header | Queries about that table | Manual review of chunks containing numeric-heavy text | Only via eval | No | Structure-aware chunking; keep header rows with body rows |
| Query in a language the index was not built for | Those users | Language distribution of queries vs corpus | Days | No | Multilingual embeddings, or route to translation before retrieval |
| Vector store degrades under a highly selective filter | Some tenants | p95 retrieval latency by filter selectivity | Minutes | Partially | Tune ef_search and the over-fetch factor per selectivity band |
| Answer cache serves a stale answer after a source update | All users of that document | Cache invalidation lag; source updated_at vs cache created_at | Until the TTL expires | Yes | Invalidate the cache on ingestion, keyed by source doc id |
What does one answer cost?
About 2.6 cents uncached and 1.5 cents with a cached prefix on the worked example below, at August 2026 list prices. The largest line is generation input, the second largest is generation output, which makes answer length a cost lever almost nobody pulls. Capping a verbose answer at 250 tokens instead of 400 removes about 6% of the total, costs nothing in quality on most questions, and takes ten minutes.
Budget ingestion and query costs separately, because they behave completely differently. Ingestion is capex: roughly 22 cents per thousand pages on this model, paid once per document version. Query is opex: paid per answer, forever, scaling with usage. Presenting them as one number is the fastest way to have a cost conversation that goes nowhere.
The re-index multiplier is the ingestion number teams forget. You will re-index: a better chunker, a new embedding model, a schema addition, a parser fix. Each re-index is the full ingestion cost again across the whole corpus. Three to six re-indexes in the first year is normal, so budget ingestion as its per-thousand-pages cost times your corpus size times four, not times one.
Caching changes the shape, not the total. The static prefix, system prompt, citation schema, few-shot examples, is stable and should sit first in the context so it caches; the retrieved chunks differ every query and never will. On this model that takes the answer from 2.6 cents to 1.5 cents. The full method, including the four mistakes that silently set your hit rate to zero, is in how caching changes the cost of every RAG query.
| Line | Quantity | Unit price (24 Aug 2026) | Uncached | Cached | % of cached total |
|---|---|---|---|---|---|
| Query embedding | 40 tokens | $0.02 / 1M | $0.00001 | $0.00001 | 0.1% |
| Vector + BM25 search | 1 query, pgvector + tsvector | Amortised instance cost | $0.00002 | $0.00002 | 0.1% |
| Rerank 50 candidates | 12,500 in / 200 out | Cheap tier $0.20 / $1.20 per 1M | $0.00274 | $0.00274 | 17.8% |
| Generation input — static prefix | 6,000 tokens | Mid $2 / 1M; cached $0.20 / 1M | $0.01200 | $0.00120 | 7.8% |
| Generation input — retrieved chunks | 3,240 tokens | Mid $2 / 1M | $0.00648 | $0.00648 | 42.1% |
| Generation output | 400 tokens | Mid $10 / 1M | $0.00400 | $0.00400 | 26.0% |
| Groundedness check | 3,600 in / 60 out | Cheap tier | $0.00079 | $0.00079 | 5.1% |
| Trace storage | ~8 spans | Backend-dependent | $0.00020 | $0.00020 | 1.3% |
| Total per answered question | $0.02624 | $0.01544 | 100% |
- Generation input — retrieved chunks$0.0065
- Generation output (400 tokens)$0.0040
- Rerank 50 candidates$0.0027
- Static prefix (cached at 0.10x)$0.0012
- Groundedness check$0.0008
- Embedding, search, trace storage$0.0002
When is RAG the wrong architecture?
More often than the current discourse suggests, and three cases are clear enough to check before you build anything.
First, when the corpus fits in the context window, and it fits far more often than teams assume. A 200,000-token window holds roughly 150,000 words: a substantial employee handbook, a full product specification, or a year of a small team's meeting notes. If your corpus is that size and stable, putting the whole thing in a cached prefix is cheaper, simpler and strictly more accurate than any retrieval system, because there is no retrieval step to get wrong. Cached input at a tenth of list price makes this economically viable in a way it was not two years ago.
Second, when the question is aggregate. How many contracts expire in Q3, what is our average time to resolution, which suppliers appear in more than five agreements: these are SQL problems wearing a RAG costume. Retrieval returns the top k chunks by similarity, which is structurally the wrong operation for a question whose answer requires seeing all of them. The right architecture is extraction into a table at ingestion time and a query at answer time.
Third, when freshness requirements are sub-minute. Every RAG system has an ingestion lag, and shrinking it below about a minute means rebuilding your ingestion pipeline as a streaming system with all the operational cost that implies. If the answer must reflect a change made ten seconds ago, query the system of record directly and give the model a tool rather than an index.
No retrieval step means no retrieval bugs. Cached input at a tenth of list price makes this cheaper than a RAG stack at low query volume, and it is strictly more accurate.
Top-k similarity cannot answer how many. Extract structured fields at ingestion, expose SQL as a tool, and let the model write the query.
Every index has ingestion lag. If the answer must reflect a change from ten seconds ago, query the system of record live.
This is the case RAG is genuinely for. Build the retrieval eval before the prompt, and put permissions in the candidate space.
Retrieval becomes one tool among several and inherits the tool layer's budgets, validation and idempotency rules.
Production RAG: common questions
→Why does my RAG system return wrong answers?
Because one of five stages failed and all five look identical from the outside. Run four checks in order: is the fact present in any raw chunk (if not, it is ingestion), is that chunk in the top 50 of raw retrieval (if not, it is retrieval), is it in the final context after reranking and permission filtering (if not, it is one of those two), and does the model answer correctly when given only that chunk (if not, it is generation). Ten minutes, and it beats a week of prompt tuning.
→How do you handle document permissions in RAG?
As a pre-filter on the candidate space, not as a filter on the result set. Post-filtering a top-50 approximate-nearest-neighbour result for access control silently destroys recall: if a user can see 8% of the corpus, roughly four chunks survive. In Postgres with pgvector, index the ACL principals array with GIN and combine it with the tenant predicate inside the retrieval query, then over-fetch by a factor derived from your measured filter selectivity.
→How much does a RAG query cost?
On a worked example at August 2026 list prices, about 2.6 cents uncached and 1.5 cents with a cached static prefix: query embedding and search are negligible, a listwise rerank of 50 candidates is about 0.27 cents, generation input is the largest line, generation output at 400 tokens is about 0.4 cents, and a groundedness check adds about 0.08 cents. Ingestion is separate and roughly 22 cents per thousand pages per index build.
→Do I need a reranker for RAG?
Usually yes, because it is the cheaper option. Retrieving 50 candidates and reranking down to 8 costs about 2.5 cents per query on the model in this post, against about 3.2 cents for stuffing the top 20 unranked chunks, and the reranked context has twelve fewer distractors. A reranker fixes precision, not recall: it cannot surface a chunk your retrieval never found.
→What chunk size should I use for RAG?
Chunk on document structure rather than on a token count. The unit of retrieval should be the unit of meaning in that document type: a clause for contracts, a section for policies, a row with its header for tables. If you are still arguing about 512 versus 1,024 tokens, that argument is a symptom of not having a retrieval eval to settle it. Then use parent-document retrieval: embed the small chunk, generate from the larger parent.
→How do you evaluate a RAG system?
Separately at each stage, and build the retrieval eval first. A golden set of questions paired with human-confirmed chunk ids gives you recall at k and mean reciprocal rank, computable in seconds and independent of your prompt. Then add faithfulness and answer relevancy for generation. Practitioner guidance converges on several hundred cases before aggregate movement is trustworthy, but that is a heuristic rather than a statistical law; fifty cases is still worth having on day two.