AdvancedAnalysisArchitecture

RAG at Production Scale

Retrieval topology beyond the demo - index freshness, authorisation applied before retrieval rather than after, and the corpus as a supply chain.

Updated Reviewed

The problem

Grounding a model in an organisation's own documents means putting a search index inside the trust boundary, where it must stay current with the source of truth, must respect the permissions of the source of truth, and must not be steerable by whoever can write to the source of truth.

What makes it hard

  • The index is a derived copy, so every source change opens a window in which retrieval returns something the source no longer says.
  • Permissions live in the source systems and change independently of the corpus, so an index built once is authorised as of a date.
  • Approximate vector indexes change which rows a query returns, not just how fast, so an index build is a silent quality change.
  • Chunking decisions are baked into the embeddings, so revisiting them means re-embedding the corpus rather than changing a parameter.
  • Retrieval is the model's instruction channel in practice, so anyone who can add a document has a write path into every answer.
  • Relevance and safety pull apart - the retrieved passage most similar to the query may be the one that was planted.

Components

RAGThe overall pattern - retrieve candidate passages, then condition generation on them rather than on parametric memory alone.
pgvectorStores embeddings alongside the source rows in PostgreSQL, so filters, joins and row-level security compose with similarity search inside one transaction.
PostgreSQLHolds the corpus, the chunk metadata, the permission tuples and the vectors in one place, which is what makes authorisation-before-retrieval expressible as SQL.
Embedding modelMaps text to vectors. Its identity and version are part of the index schema - changing it invalidates every stored vector.
Dedicated vector storeThe alternative storage tier, chosen when index size or update rate exceeds what a relational primary should carry. Costs you transactional consistency between rows and vectors.
PrometheusTracks indexing lag, retrieval recall against a fixed evaluation set, and the empty-result rate - the three signals that reveal a degrading corpus before users do.

How it fails

FailureHow you noticeWhat bounds it
Stale or ghost retrieval - a document that has been changed or deleted at source is still returned and quoted.Indexing lag as a distribution rather than an average, plus a reconciliation job that counts index entries with no live source row.Drive indexing from a change feed rather than a crawl, make deletion a synchronous operation against the index, and stamp every chunk with the source version it was derived from.
Authorisation applied after retrieval, so unauthorised documents influence ranking, consume the context budget, and leak their existence.Audit the retrieval call rather than the response - log the candidate set before filtering and check whether any candidate was outside the caller's grant.Push the permission predicate into the query so unauthorised rows are never candidates. With pgvector this is a WHERE clause or a row-level security policy on the same table as the vector.
Corpus poisoning - content added to a source system to steer answers, with no prompt injection involved.New or recently edited documents appearing disproportionately in retrieved sets; a fixed evaluation set whose answers change after an ingestion run that should not have affected them.Treat write access to an indexed source as write access to the model's context. Separate trusted and untrusted corpora into different indexes, and require provenance on every chunk.
Recall regression from an approximate index or a changed embedding model.Measured recall against exact nearest-neighbour search on a held-out query set, run as a build gate rather than an investigation.Version the index by embedding model and index parameters, build the replacement alongside the incumbent, and compare before cutting over.
Retrieval succeeds and returns nothing useful, so the model answers from parametric memory without saying so.Empty and low-score result rates per query class, and a citation rate that is lower than the answer rate.Threshold on similarity and refuse to answer below it. An abstention is a correct output; a confident ungrounded answer is not.
Tenant or user bleed through a shared index.A test that issues each tenant's queries against the index with another tenant's credentials and asserts an empty candidate set.Partition by tenant at the storage level where the blast radius justifies it, and never rely on a metadata field being filtered correctly by every caller.

Obligations that attach

When not to use this

  • The answer set is small and stable. A curated prompt or a lookup table is cheaper, auditable, and cannot be poisoned by an edit to a wiki.
  • The corpus is small enough to fit in the model's context window with room to spare. Retrieval adds a failure mode and removes nothing.
  • The requirement is exact recall of structured facts - balances, entitlements, inventory. Query the system of record; similarity search over its documentation is a worse version of the same answer.
  • The corpus is high-sensitivity and lexical search is adequate. Retrieval without embeddings inherits the freshness and authorisation problems but has no embedding-layer attack surface at all.
  1. Change capture

    A queue of created, updated and deleted source items

    Crawls miss deletions entirely

  2. Extraction

    Text plus structural metadata

    Extractors fail silently on a subset of formats and produce empty chunks

  3. Chunking

    Passages with stable identifiers

    Chunk identity is not stable across re-extraction, so updates duplicate rather than replace

  4. Embedding

    Vectors, tagged with the model and version that produced them

    Mixed-model vectors in one index give distances that are meaningless

  5. Indexing

    An approximate index over the vectors

    Building the index changes which rows queries return

  6. Retrieval

    A candidate set, filtered by authorisation before scoring

    Filtering after scoring leaks existence and wastes context

A retrieval prototype is a function: text in, similar text out. A production retrieval system is three things that happen to share a table: an incremental data pipeline, an access-control boundary, and an ingestion surface that anyone with write access to a source system can reach. The pattern itself is covered in RAG. What changes below is everything that follows from a corpus being large, sources being live, and readers not all holding the same permissions.

The framing that makes the rest coherent: the index is a derived, stale, and separately authorised copy of your documents. Every production failure below is one of those three words being ignored.

The pipeline, end to end

Six stages. The interesting properties live in the seams, not the stages.

Stage Output The property that breaks in production
Change capture A queue of created, updated and deleted source items Crawls miss deletions entirely
Extraction Text plus structural metadata Extractors fail silently on a subset of formats and produce empty chunks
Chunking Passages with stable identifiers Chunk identity is not stable across re-extraction, so updates duplicate rather than replace
Embedding Vectors, tagged with the model and version that produced them Mixed-model vectors in one index give distances that are meaningless
Indexing An approximate index over the vectors Building the index changes which rows queries return
Retrieval A candidate set, filtered by authorisation before scoring Filtering after scoring leaks existence and wastes context

The single most consequential decision is at the top. Drive the pipeline from a change feed, not a crawl. A crawl can discover new and modified documents; it cannot reliably discover deletions, because a deleted document is indistinguishable from one the crawl did not reach. That is how you get ghost retrieval: a document that was removed from the wiki six weeks ago, still in the index, still being quoted as current policy, with a citation that 404s. Deletion has to be an explicit operation against the index, propagated with the same urgency as a permission revocation. That is frequently what it is.

Stamp every chunk with the source version it was derived from, and make the retrieval path capable of returning that. Without it, you cannot answer the question a user will eventually ask, which is “where did that come from, and was it current?”

Freshness is a distribution, not a number

Indexing lag behaves like replication lag: the average is reassuring and the tail is what hurts. One document class with an extractor that times out, one source system whose webhook has been failing since a credential rotation, and the mean stays healthy while a whole category of content is weeks behind.

Measure the age of the oldest unprocessed change per source, not the mean processing time. Set a freshness budget per corpus and treat a breach as an incident. The failure mode is not “slow” but “confidently wrong about something that changed”.

A related and less obvious point: re-embedding is not free and not rare. Chunk boundaries and the embedding model are baked into the stored vectors, so changing either invalidates the entire index. A chunking strategy is therefore a schema decision with a migration cost. Treat it with the seriousness of one. Build the new index alongside the old, with the model identity in the index name, and cut over on measured results, not on the assumption that a newer embedding model is better for your corpus.

Authorisation before retrieval

This is the design decision that separates a system that can hold sensitive documents from one that cannot, and it is usually got wrong in the same way: retrieve the top k by similarity, then drop the ones the caller may not see.

That post-filter is not equivalent to a pre-filter, for reasons that have nothing to do with convenience.

  • The context budget is already spent. If eight of ten candidates are dropped, the answer is built from two passages, and the model has no idea it is reasoning from a truncated view. Quality degrades exactly for the users with the narrowest permissions.
  • Existence leaks. Result counts, latency and score distributions are all observable. “No results” and “results you may not see” are different, and a determined caller can tell them apart.
  • The unauthorised documents influenced ranking. Relevance scores are computed relative to the candidate set, so a document the caller cannot see has already shaped what they do see.
  • It fails open. A missing filter in one code path is invisible. A missing predicate in a query is a query that returns nothing.

Keeping the vectors in PostgreSQL with pgvector makes the correct version expressible directly, because similarity search is an operator inside an ordinary SQL query. The permission predicate goes in the same statement as the distance operator:

SELECT c.chunk_id, c.body, c.source_version,
       c.embedding <=> $1 AS distance
FROM   doc_chunk c
JOIN   doc_grant g ON g.doc_id = c.doc_id
WHERE  g.principal = ANY($2::text[])     -- the caller's groups, resolved per request
  AND  c.deleted_at IS NULL
  AND  c.tenant_id  = $3
ORDER  BY c.embedding <=> $1
LIMIT  10;

Row-level security is the stronger form of the same idea: a policy on doc_chunk that no query can forget, enforced by the database, not by the caller. The cost is that the policy has to be maintainable, and that a permission model expressed in a source system has to be projected into the database and kept current. That is the same freshness problem as the corpus, applied to grants, and it is the one with the shorter tolerance. A revoked grant that takes an hour to propagate is an hour of retrievable documents.

One performance consequence is worth naming before you discover it. An approximate index is built over the whole table; adding a restrictive predicate means the index scan walks candidates that the filter then discards, so a highly selective permission filter can return fewer rows than requested, or fall back to a slower plan. That is not a reason to filter afterwards. It is a reason to partition the index by tenant where the tenancy boundary is coarse, and to measure recall with the filter applied, which is the only measurement that describes what your users actually get.

The corpus is a supply chain

Retrieved content is, functionally, part of the prompt. So the set of people who can write to an indexed source system is the set of people who can influence every answer the system produces. Say that out loud during design, because the list is usually much longer than anyone expects: an onboarding wiki anyone can edit, a ticketing system open to customers, a shared drive with inherited permissions nobody has audited, a vendor’s documentation mirrored nightly.

This is a supply-chain problem, and it responds to supply-chain controls rather than to model-level ones. Note that it does not require any prompt injection: a document that contains no instructions at all, only plausible and wrong content, will be retrieved on relevance and believed on authority. Guardrails that look for imperative language in retrieved text do not address this. See vector and embedding weaknesses for the mechanisms, and supply chain for the broader category.

What actually helps:

Separate indexes by trust level. A corpus of reviewed, versioned internal policy and a corpus of customer-submitted attachments should not share an index, because a single index forces a single trust decision. Retrieve from each explicitly and label the provenance in what reaches the model.

Require provenance on every chunk. Source system, author, last editor, review status, version. This is the metadata that lets you answer “what changed?” after a bad answer, and it is the metadata that lets you exclude unreviewed content from high-stakes query paths.

Rate-limit influence, not just ingestion. A source that suddenly contributes a large share of retrieved passages is either newly relevant or newly gamed. Both are worth a look. Track retrieval share by source and by document age; a spike in retrievals of documents created in the last week is the signal that generalises.

Keep a fixed evaluation set and run it after every ingestion. Not for relevance scoring, but for change detection. If an ingestion run alters the answers to questions it had no business touching, something entered the corpus that is competing for retrieval more broadly than its content justifies. This is the cheapest poisoning detector available and it costs one scheduled job.

What to measure

Relevance metrics dominate the literature and are the least useful of these in operation, because relevance degrades slowly and the other three degrade suddenly.

Signal Why it is the one that matters
Age of the oldest unprocessed change, per source Detects a broken pipeline before users report stale answers
Recall against exact search, with the authorisation filter applied The only number that describes what a real user’s query returns
Empty and below-threshold result rate Rising means the corpus no longer covers what people ask; it is also how you catch a failed index build
Retrieval share by source and by document age The poisoning and drift signal
Candidate-set audit: unauthorised rows before filtering Should be identically zero. Anything else is a finding, not a metric

Finally, build in the ability to refuse. A retrieval system that cannot find good evidence should say so, and the plumbing for that - a similarity threshold, an abstention path, a response that names what it could not find - has to exist before it is needed. The alternative is a system whose worst outputs are indistinguishable from its best ones, which is the failure that makes the whole topology untrustworthy and not merely imperfect.