Build1 publisher2 min readPublished
Post-filtering a RAG query spends its LIMIT 10 on chunks the user cannot read
A dev.to walkthrough of a permission-aware Postgres project puts the ACL test in a CTE that the vector ranking reads from, so the nearest-neighbour search only ever orders rows the caller may see.
The Engineer · Build desk

What happened
- A dev.to post on a permission-aware Postgres project called vaultrag names the pattern it wants dropped: fetch the ten nearest chunks by vector distance, then remove the ones the caller cannot see.
- Post-filtering leaves logging, metrics counters, debug endpoints and a cache keyed by query text upstream of the check, each of them holding chunks the caller is not allowed to see.
- The rewrite puts the ACL test in a CTE named visible and runs the distance ordering and the LIMIT over that CTE, so the ranking only ever sorts permitted rows.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- capability A request for ten results now returns ten permitted chunks whenever ten exist, so a user with narrow permissions stops seeing a truncated result list.
- exposure The cache is the sharpest of the upstream holders, because a key built from query text alone does not record who asked, and whatever it stored is what the next caller gets.
- decision Adopting the pattern forces a decision about where principals come from, since a group membership the client asserts in its own request lets the client decide what the ACL allows.
- constraint The promise that no forbidden row is ever materialised is a statement about a query plan, so a team has to verify it against its own ACL distribution before treating the query as a boundary.
Read the query in the order the database has to evaluate it. The EXISTS against doc_acl sits inside a CTE called visible, which also drops documents whose deleted_at is set [7]. The distance ordering and the LIMIT then select from visible, not from chunks [8], so the top-k is the top-k of the rows this caller may read [9].
Eight of the ten nearest chunks belong to documents the user cannot read, the post-filter drops them, and the caller gets two rows [3]. That is 20 percent of the k the application asked for [17], and material the user was entitled to sat at rank 11 and never entered the candidate set [4]. Over-fetching replaces the bug with a threshold: pick whatever multiplier you like, and a caller whose permissions exclude most of the corpus still comes back short.
A plain join on doc_acl duplicates a chunk once per matching ACL row, so a document with three principals produces three copies of each of its chunks [11]. Those copies spend the LIMIT: if every matching document carried three ACL rows, a LIMIT 10 would return at most four distinct chunks [18]. EXISTS short-circuits on the first match, so each visible chunk appears exactly once [11].
Principals come from the server. The post's resolve_principal reads the caller's row from the users table and returns the user id plus every group they belong to, and it is explicit that this must never come from the request [12]. "If a client can assert its own group membership, the ACL is decorative," the author wrote [13].
Defining the boundary once pays off in hybrid search. In vaultrag the vector arm and the full-text arm both select from visible before reciprocal rank fusion, so neither arm can surface a chunk the other could not [14].
What I would check before adopting this is the plan. The post says the planner never materialises a forbidden row into the candidate set [10], and it publishes the SQL and the principal lookup without an EXPLAIN [16]. For that to hold on your data, Postgres has to apply the ACL predicate before the ordering, instead of scanning for the distance operator first and filtering afterwards. The permission shape has to transfer too. The design assumes a document owns a list of principals, over a chunks table with VECTOR(1536) embeddings [6], and per-chunk or attribute-based rules need a different predicate in the same slot.
What to watch
- An EXPLAIN plan for the visible CTE on a table big enough that the distance operator's index matters, showing whether the ACL predicate really runs first.
- Recall and latency numbers for the CTE version against an over-fetch-and-filter baseline on a corpus where most documents are forbidden to the caller.
- Whether the same visible CTE holds up under per-chunk or attribute-based permissions instead of a per-document principal list.