Library// diagnostic

Recall collapsed the day you turned on permission filtering

In short

In most cases the filter is running after the nearest-neighbour search rather than inside it. The search returns a candidate set already fixed at k, the access filter then removes most of it, and a user who can see a small share of the corpus is left with one or two passages. Measure candidate survival per role, not per query, before redesigning anything.

Key takeaways

  • A post-filter cannot add candidates. It can only remove them from a list whose size was fixed before it ran.
  • Expected survivors are k multiplied by the share of the corpus that user can see. Do that sum first.
  • Measure per role. A blended recall number is dominated by administrators and hides everyone else.
  • Documents with no access list default to deny in most implementations, which silently removes the oldest material.
  • Group membership resolved at index time goes stale the day someone changes team, and nothing alerts.
  • Starved context does not produce an empty answer. It produces a confident one drawn from the model's memory.

Nothing about retrieval quality changed. A filter applied after the nearest-neighbour search cannot add anything to the candidate list — it can only take things out of a list whose size was decided before it ran. If the search returns 20 candidates and a user can see 8 percent of the corpus, the expected number of survivors is between 1 and 2. That is the whole failure, and it is arithmetic rather than a tuning problem.

It presents unevenly, which is why it gets misdiagnosed. Administrators and long-tenured staff see everything and report no problem at all. Contractors, new joiners and anyone in a narrow function get vague answers or none. Aggregate quality metrics average those together and show a mild dip, so the investigation starts in the wrong place.

Measure candidate survival per role, not per query

One measurement settles this before any redesign. It compares the candidate set with the filter on and off, for the same queries, segmented by the thing that actually varies: who is asking.

  1. Pick 30 real queries from logs and 5 representative principals — an administrator, a full-time member of a large team, someone in a narrow function, a contractor, and a new joiner in their first week.
  2. For each query and principal, run retrieval twice: once as the application runs it, and once with the access filter removed. Record the candidate count and the ordered chunk ids for both.
  3. Compute survival: filtered candidates divided by unfiltered candidates, averaged per principal. This single ratio is the diagnosis.
  4. Compute each principal's visible share of the corpus — the count of chunks they may see, divided by the total. This is the number the survival ratio should be compared against.
  5. List the queries where survival is zero. Those are the users currently receiving answers assembled from nothing, and they should be the first thing anyone looks at.
SurvivalReadingAction
Close to the visible shareThe filter is behaving as a post-filter and the maths is doing exactly what it mustOver-fetch, or move the filter inside the search
Far below the visible shareSomething is removing more than access requires — usually stale or missing metadataAudit the access field on chunks before touching the query path
Zero for some principals, healthy for othersA group or role is not resolving; the filter is matching nothing rather than matching a littleCheck how group membership is expanded, and whether an empty result is being treated as deny
Unchanged by the filterThe filter is not being applied on this path at allStop and treat this as an exposure question, not a recall question
Survival ratio per principal, and what it means

Four causes, ranked

CauseSignatureRepair
Post-filter starvationSurvival tracks the visible share closely, across all queries and all principalsFilter inside the search, or over-fetch by the multiplier above
Stale access metadata on chunksSurvival is well below the visible share, and worst on the oldest documentsRe-stamp access metadata on re-index; treat it as derived data, never as a one-time copy
Group membership resolved at index timeOne principal collapses to near zero after an org change while colleagues are fineResolve group membership at query time from the identity provider, not at ingest
No access list, defaulting to denyA specific document set is invisible to everyone including administratorsDecide and encode a default explicitly; audit how many chunks carry no access field at all
Cause, signature in the survival measurement, and the repair

The fourth cause is worth dwelling on because it is the most defensible decision with the worst side effect. Defaulting to deny is correct: a document whose permissions are unknown should not be served. But in a corpus assembled from several systems, the material with no access list is usually the oldest and most authoritative — the policies, the standards, the original contracts — because it predates whatever permission model was later adopted. Count those chunks. In most corpora the number is larger than anyone expects.

The third cause has a particular signature worth recognising. Membership expanded at ingest and frozen into each chunk means the index encodes an org chart from the day it was built. Someone changes team, keeps access they should have lost or loses access they should have kept, and nothing anywhere reports it. The permission model is not wrong; it is stale, which is harder to see and easier to leave.

A post-filter can only remove. If the list it receives was fixed at 20 and the user may see one in twelve, the answer was decided before the filter ever ran.

Three places a filter can sit, and what each costs

PlacementRecall behaviourCostFits when
After the searchDegrades in proportion to how little the user can see; worst for the most restricted usersCheapest to build, and the default in most quick implementationsNearly everyone can see nearly everything, and you have measured that
Inside the searchRecall is preserved; the user competes only against documents they may seeRequires index support for filtered search, and a highly selective filter can force close to an exhaustive scanAccess varies widely between users — which is most real deployments
Separate index per boundaryRecall preserved, and no filter needed at query timeStorage multiplies, and shared documents are duplicated across partitionsA small number of hard boundaries — tenants, jurisdictions, legal walls
Filter placement, recall behaviour, cost and where it fits

The middle row carries a latency caveat that surprises teams who move the filter inwards and expect a pure improvement. A very selective filter inside an approximate index can push the search towards scanning far more of the graph than usual, so the median stays flat while the tail stretches. Where the service also scales to zero between requests, that tail lands on top of a cold start and turns into a timeout — the compound failure described in the first request after a quiet hour timing out. Measure p95 per role after the change, not overall.

What to fix, in order

  1. Audit access metadata coverage. Count chunks with no access field, and count chunks whose access field disagrees with the source system today. Fixing the query path over bad metadata produces a system that is confidently wrong instead of visibly broken.
  2. Resolve identity at query time. The request should carry a principal, and group expansion should happen against the identity provider on that request, not be baked into the index.
  3. Move the filter inside the search where the index supports it, and measure both survival and p95 per role before and after.
  4. Where it cannot move, over-fetch by the multiplier for that principal rather than a fixed number. A single global k cannot serve an administrator and a contractor at the same time.
  5. Add a floor alert. If a principal's candidate set after filtering is below a stated minimum, log it as an event. Silent starvation is the reason this survives for months.
  6. Re-run the survival measurement on a schedule. Access changes constantly and this is the only metric that notices.

What a starved candidate set actually does to the answer

It does not produce an empty response, which is what makes this dangerous. Handed one marginal passage, a model will usually still answer, filling the gap from its own parameters and adopting the confident register of the passage it did receive. That is the failure pattern in the model answering from memory instead of the passage, and permission starvation is one of its most reliable causes.

There is a second, subtler effect. When only one or two candidates survive an approximate search, small run-to-run variation in the candidate set changes the entire answer rather than reordering it, so the same person asking the same question twice gets materially different responses — the instability described in the same question returning different passages every run. Users report that as unreliability, not as an access problem, and the ticket goes to the wrong team.

One boundary to be explicit about: this page is about lost recall, not exposure. Whether a filter can be bypassed, what happens to a passage after a document is deleted, and how tenants are kept apart are separate questions with separate failure modes, and they live in security, privacy and access. If your survival measurement shows the filter having no effect at all, stop reading this page — that is an exposure finding and it is more urgent than recall.

Retrofitting permissions onto a pipeline that never had them is its own piece of work, sequenced in adding document-level permissions to a retrieval pipeline. The measurement harness described here — survival per role, run on a schedule, alerting on a floor — is a small internal tool rather than a project, and it is the sort of thing we scope under internal tooling and operations software. It sits with the rest of retrieval and grounding, inside the wider engineering library.

Frequently asked questions

Short answers to the follow-ups this page tends to raise.

Why did recall drop when we enabled permission filtering?

Because the filter almost certainly runs after the nearest-neighbour search, and a post-filter can only remove candidates from a list whose size was already fixed. If the search returns 20 results and a user may see 8 percent of the corpus, roughly 1 or 2 survive. Retrieval quality did not change; the number of usable candidates reaching the prompt did.

Is pre-filtering always better than post-filtering?

For recall, yes, and it is the right default when access varies widely between users. It is not free: a highly selective filter inside an approximate index can push the search towards scanning much more of the graph, which stretches the latency tail even when the median looks unchanged. Move the filter inwards, then measure p95 per role rather than overall.

How much should we over-fetch to survive a permission filter?

Divide the number of passages you need by the share of the corpus that user can see. Someone with 5 percent visibility who needs 10 passages requires a candidate set near 200. That multiplier is per principal, so a single global k cannot serve an administrator and a contractor at once — and over-fetching without collapsing near-duplicates afterwards mostly buys copies of the same passage.

Why do only some users report bad answers?

Because the loss is proportional to how little of the corpus each person may see. Administrators lose nothing and report nothing, while contractors and new joiners lose most of their candidates and get thin or invented answers. Any aggregate quality metric averages those groups together and shows a mild dip, which is why the measurement has to be segmented by principal rather than by query.

  • retrieval
  • access control
  • recall
  • filtering
// shipped work

The work behind this page

Builds from our portfolio that this page draws on.

Working on something in this space?

Tell us where you are in a sentence or two. We'll tell you honestly whether we're the right team, and what a sensible first slice of the work looks like.

Start the conversation