Request a callbackBook a call
← All posts

RAG Over Private Documents: The Full Architecture, Failure Modes, and Cost Per Answer

TL;DR
  • 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.
Production RAG
Two pipelines, different schedules, different failure budgetschanged docs onlynormalised text + structurechunks + metadataSQL upsert + tombstonesHTTPSprincipals[]rewritten querycandidates, already ACL-validtop 50top 8 + heading pathsanswer + citations[]
SourceS3 / SharePoint / Drive / DB, with a change detector
Parse + normalisetext layer first, VLM only when absent
Chunk + enrichstructure-aware; heading path, effective dates, ACL
Embed + indexrecords embedding_model_version on every row
Postgrespgvector + tsvector + acl_principals (indexed)
User querywith the caller's principal set
ACL resolverexpand groups to principals, cached 60s
Query rewritedecontextualise; cheap tier; skippable
Hybrid retrievevector + BM25, RRF fused, ACL PRE-filtered
Rerank 50 -> 8precision, not recall
Generatecitation-required constrained output
Validate citationsevery quote must exist in its chunk
The two lanes run on different schedules and deserve separate failure budgets: ingestion is batch, and its cost is capex measured per thousand pages; the query lane is online, and its cost is opex measured per answer. The most consequential arrow is the one from the store into hybrid retrieve, labelled already ACL-valid because the permission filter runs over the candidate space, not the result set.

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.

StageThe one check that localises itTypical root causeFixCost of the fix
Ingestion / parsingGrep the raw chunk text for the correct fact. If it is not there, it never entered the systemA parser returning table cells as prose, headers as body, or a scanned page as an empty stringRoute by document type; check for an embedded text layer before rasterising2-5 engineer-days per document family
ChunkingFind the chunk containing the fact. If the fact is split across two chunks, or the chunk has no heading context, it is chunkingFixed-size chunking cutting a table or a clause in halfStructure-aware chunking; parent-document retrieval; heading path in metadata3-5 engineer-days
RetrievalRun retrieval alone and look at the top 50. If the right chunk is at rank 34, it is retrievalDense-only search on a query with rare proper nouns or identifiersHybrid search: vector plus BM25, fused with reciprocal rank fusion2-3 engineer-days
Rerank / ACL filterCheck whether the chunk survived into the final context. If it was in the top 50 and not in the context, it is herePost-filtering for permissions after the ANN search, or an over-aggressive reranker thresholdPre-filter the candidate space by ACL; tune the over-fetch factor from measured selectivity3-5 engineer-days
GenerationPut the correct chunk in the context by hand. If the answer is still wrong, it is generationDistractors in context, a prompt that permits speculation, no refusal pathCitation-required output with span validation; an evaluated I-do-not-know path2-4 engineer-days
The ten-minute diagnostic, in order
  1. 1
    1. 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.

  2. 2
    2. 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.

  3. 3
    3. 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.

  4. 4
    4. 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.

Four checks, ten minutes, one bad answer in hand. Run them in this order every time. The most common outcome in systems I have reviewed is check three: the chunk was retrieved and then quietly removed by a permission filter running in the wrong place, which is the subject of the section below.

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.

StrategyRecall impactLatencyIsolation guaranteeWhen to use
Post-filter after ANNCatastrophic and invisible: top-50 at 8% selectivity leaves ~4 chunksLowestCorrect but uselessNever 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 typeEnforced in the query; one code pathThe default for multi-tenant and per-document ACLs
Namespace or index per tenantPreservedLowest, per queryStrongest — physical separationFew, large tenants; regulatory separation requirements
Row-level security in a SQL vector storePreserved+5-15msEnforced by the database, not by your codePostgres shops. The policy is auditable, which security reviewers like
Hybrid: RLS plus tenant partitionPreserved+10-20msBelt and braces; survives an application bugEnterprise deployments where an ACL bug is a contractual event
The same query, the same user, two filter placements
Post-filter (the common bug)
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
Pre-filter (correct)
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
4 usable chunks vs 50
Same corpus, same user, same embedding model. The only difference is whether the permission predicate runs inside the query or after it, and the difference is a factor of twelve in how much material the reranker gets to choose from. This is the diagram to show anyone who describes permissions as a filter parameter.
hybrid_retrieve.sql
-- 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;
Pre-filtered hybrid retrieval in one query: a vector arm, a lexical arm, both constrained by tenant and ACL inside the CTEs, fused with reciprocal rank fusion. The over-fetch factor is a named parameter with a comment, because it should come from your measured filter selectivity and not from a round number someone liked.

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.

chunk_schema.sql
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;
The chunk record. Four fields carry more weight than they look like they should: heading_path gives a retrieved fragment its context, effective_from and effective_to keep superseded policies out of answers, acl_principals is the pre-filter, and embedding_model_version prevents the silent catastrophe of mixed-version similarity comparison.
Checklist
Chunking decisions, in order of impact
  • 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.
Chunk size is the parameter everyone tunes and roughly the fifth most important decision on this list. If you are still arguing about 512 versus 1,024 tokens, the argument is a symptom of not having a retrieval eval.

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.

ApproachChunks in contextGeneration input tokensRerank costGeneration costTotal per query
Top-5, no rerank58,040$0.0000$0.0201$0.0201
Top-20 stuffed, no rerank2014,040$0.0000$0.0321$0.0321
Top-50 reranked to 558,040$0.0027$0.0201$0.0228
Top-50 reranked to 889,240$0.0027$0.0225$0.0252
Measured context precisionRun the harness in this post against your own corpus — a published number here would be a fact about someone else's documents
Assumptions400 tokens per chunk; 6,000-token static prefix, uncached; 400 output tokensMid tier $2/$10 per 1MListwise rerank on a cheap tier, 50 x 250 tokens inChecked 24 Aug 2026Excludes 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.

citation-validation.ts
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: [] };
}
Constrained output plus mechanical validation. The normalisation step matters more than it looks: models reproduce quotes with different whitespace and smart quotes, so a validator that is too strict rejects correct citations while one that is too loose accepts fabricated ones. Normalise whitespace and quote characters, then require an exact substring match.

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_eval.py
"""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.
The harness that unblocks everything else. No framework, no judge, no LLM: retrieval only, scored against human-confirmed chunk ids. Run it on every change to chunking, embeddings, hybrid weights or the over-fetch factor, and you replace an argument with a number.
Four metrics, four different failures
Context recall
Was the needed material retrieved at all? Fix with hybrid search and the over-fetch factor
Context precision
How much of what was retrieved was needed? Fix with a reranker
Faithfulness
Is the answer supported by the retrieved chunks? Fix with citation validation
Answer relevancy
Does the answer address the question asked? Fix in the prompt — and only this one is a prompt problem
Only the fourth is fixed by prompt engineering, which is where most teams spend most of their time. Measure the first two before you touch the fourth, or you will tune a prompt to compensate for a retrieval bug and then be unable to explain why quality collapses when the corpus grows.

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.

FailureBlast radiusDetectionTime to detectAuto-recoverable?Mitigation
Re-index silently halves recallAll queriesrecall@50 on the golden set, run as a promotion gateImmediate with a gate; weeks withoutNoRefuse to promote an index that regresses
Embedding model version mismatch between index and queryAll queriesembedding_model_version filter returns zero rows, or similarity scores collapseMinutes if you filter; never if you do notNoStore the version on the row; filter on it
Parser regression on one document familyOne document familyChunk count per document, and mean chunk length, by source typeDaysNoAlert on ingestion shape metrics per document type
Deleted document remains retrievableRegulatoryReconciliation job comparing source inventory to indexUntil someone noticesYes, with the reconcilerThree deletes plus a scheduled reconciler
ACL drift after a permission change in the sourceSecurityReconciliation job re-reading ACLs on a scheduleHours to weeksYesScheduled ACL re-sync; short TTL on the principal cache
Contradictory documents both retrievedSingle answerContradiction rate in judged samplesOnly via evalNoSurface both with dates; never silently pick one
Chunk boundary splits a table from its headerQueries about that tableManual review of chunks containing numeric-heavy textOnly via evalNoStructure-aware chunking; keep header rows with body rows
Query in a language the index was not built forThose usersLanguage distribution of queries vs corpusDaysNoMultilingual embeddings, or route to translation before retrieval
Vector store degrades under a highly selective filterSome tenantsp95 retrieval latency by filter selectivityMinutesPartiallyTune ef_search and the over-fetch factor per selectivity band
Answer cache serves a stale answer after a source updateAll users of that documentCache invalidation lag; source updated_at vs cache created_atUntil the TTL expiresYesInvalidate 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.

LineQuantityUnit price (24 Aug 2026)UncachedCached% of cached total
Query embedding40 tokens$0.02 / 1M$0.00001$0.000010.1%
Vector + BM25 search1 query, pgvector + tsvectorAmortised instance cost$0.00002$0.000020.1%
Rerank 50 candidates12,500 in / 200 outCheap tier $0.20 / $1.20 per 1M$0.00274$0.0027417.8%
Generation input — static prefix6,000 tokensMid $2 / 1M; cached $0.20 / 1M$0.01200$0.001207.8%
Generation input — retrieved chunks3,240 tokensMid $2 / 1M$0.00648$0.0064842.1%
Generation output400 tokensMid $10 / 1M$0.00400$0.0040026.0%
Groundedness check3,600 in / 60 outCheap tier$0.00079$0.000795.1%
Trace storage~8 spansBackend-dependent$0.00020$0.000201.3%
Total per answered question$0.02624$0.01544100%
Where 1.5 cents goes (cached)
$0.0154per answer
  • 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
Two lines are 68% of the answer: the retrieved chunks going in and the answer coming out. That is why answer-length control and context precision are the two levers that matter here, and why swapping to a cheaper embedding model, which everyone tries first, moves 0.1% of the bill.
Monthly, at three volumes
$154
10,000 answers a month, cached, plus ~$60 of Postgres
$3,860
250,000 answers a month, cached, plus ~$400 of Postgres and trace storage
$30,880
2,000,000 answers a month, cached, before any human review line
$0.22
ingestion, per 1,000 pages, per index build — multiply by 4 for a realistic first year
Ingestion is capex and query is opex, and mixing them produces a number nobody can act on. The fourth figure is the one that ambushes teams: three to six re-indexes in year one is normal, and each one is the full corpus cost again.

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.

Check before you build
Is RAG the right architecture for this problem?
Corpus under ~150,000 words and stable
Put it all in a cached prefix

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.

The question is aggregate or analytical
Extract to a table, then query it

Top-k similarity cannot answer how many. Extract structured fields at ingestion, expose SQL as a tool, and let the model write the query.

Freshness must be sub-minute
Give the model a tool, not an index

Every index has ingestion lag. If the answer must reflect a change from ten seconds ago, query the system of record live.

Large corpus, per-document permissions, natural-language questions
RAG, with a pre-filter

This is the case RAG is genuinely for. Build the retrieval eval before the prompt, and put permissions in the candidate space.

The agent needs to decide when to retrieve
RAG as a tool inside an agent loop

Retrieval becomes one tool among several and inherits the tool layer's budgets, validation and idempotency rules.

Two of the five branches say do not build a RAG system. The first branch in particular is worth re-checking annually, because context windows grew faster than most corpora did and the cached-prefix option is now viable for a class of problem that genuinely needed retrieval in 2024.

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.

Ready to talk numbers?

Twenty minutes, straight to the engineer. No sales rep, no deck.