Skip to content

    Forward Deployed Engineer

    Metadata Filters and Tenant-Aware Access Control in RAG

    Apply the access filter inside the retrieval query instead of after it, and prove with tests that a post-filter starves results and leaks signals.

    By Published 9 min read

    • fde
    • rag
    • access-control
    • multi-tenancy
    • retrieval
    • metadata-filters
    Share

    This lesson is part of Forward Deployed Engineer: RAG, Search and Data. Enroll for free, then sign in, to read it in full.

    What you'll learn: why the access filter belongs inside the retrieval query, how a post-filter starves the result list and leaks signals (measured on the capstone), how to extend the same predicate to tenants, and why authorization is never the model's job.

    At a glance

    Figure 1. The same predicate before and after the search

    flowchart TD accTitle: The same predicate before and after the search accDescr: Two lanes handle the same query from a public user. In the post-filter lane the index scores all 174 chunks, takes the top 6, and the predicate then removes 5 partner-only chunks, leaving 1. In the in-query lane the predicate first keeps the 169 allowed chunks, the index scores only those, and 6 chunks are returned. subgraph POST["Post-filter: search first, filter after"] P1["Query"] --> P2["Score all 174 chunks, take top 6"] P2 --> P3["Predicate removes 5 partner-only chunks"] P3 --> P4["1 chunk left"] end subgraph PRE["In-query filter: filter first, search after"] Q1["Query"] --> Q2["Predicate keeps 169 allowed chunks"] Q2 --> Q3["Score those, take top 6"] Q3 --> Q4["6 chunks"] end

    How to read this: both lanes use the identical predicate; only the moment it runs differs, and that moment decides how many results survive and what the scores reveal.

    • Run the access predicate inside the query, before scoring.
    • A post-filter returns fewer than k results, and its scores and "removed" count describe hidden content.
    • Role and tenant come from a verified token, never from the request body or the model.
    • "Do not reveal partner prices" in a prompt is not access control; a context that never contained them is.
    • Fail closed: an empty tenant or an unknown owner returns nothing.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply keeps a partner programme guide with partner-only prices next to its public datasheets. The first version of the assistant searched everything, then removed partner chunks from the result list.

    In testing, questions about a partner product returned one or two sources instead of six, and a debug panel said "5 results hidden". The sales director asks whether this is safe. All numbers here come from the capstone's data and are illustrative.

    What you will be able to do

    • Explain the difference between a post-filter and an in-query filter and state the cost of each in one table.
    • Measure starvation and score leakage of a post-filter with a test against the capstone's chunks.
    • Write a tenant predicate that fails closed and prove it with a test.
    • Show with a test that hidden text never reaches the model, whatever the user types.

    Prerequisites

    Search: keyword, vector, hybrid, and choosing a store for the BM25 index, and Prompts and untrusted input. The design view is in Data and retrieval architecture.

    Core ideas

    One predicate, two places

    Post-filter In-query filter
    When the predicate runs after the top k is chosen before any chunk is scored
    Scores computed with hidden chunks in the statistics computed over allowed chunks only
    What the caller can infer how many results were removed nothing about hidden chunks
    How it fails silently, when someone forgets the second step loudly, when the argument is missing

    The capstone's predicate is chunk_filter: it checks the visibility class and optionally drops superseded documents or restricts to given SKUs.

    Verified: capstone/fde-product-discovery-copilot/app/retrieval/filters.py

    def chunk_filter(
        chunks: Mapping[str, Chunk],
        visibilities: Collection[str],
        exclude_superseded: bool = False,
        skus: Collection[str] | None = None,
    ) -> Callable[[str], bool]:
        wanted = set(skus) if skus else None
    
        def allowed(chunk_ref: str) -> bool:
            c = chunks[chunk_ref]
            return (
                c.visibility in visibilities
                and not (exclude_superseded and c.status == "superseded")
                and (wanted is None or bool(wanted.intersection(c.skus)))
            )
    
        return allowed

    The index takes this function as where and applies it first, so its statistics cover allowed chunks only.

    Verified: capstone/fde-product-discovery-copilot/app/retrieval/keyword.py

            allowed = [i for i, item_id in enumerate(self.ids) if where is None or where(item_id)]
            if not allowed:
                return []
            allowed_set = set(allowed)
            avg_len = (sum(self.length[i] for i in allowed) / len(allowed)) or 1.0

    What a post-filter costs, measured

    m3_l4_filtering.py builds the tempting version: search every chunk, take the top 6, then apply the same chunk_filter.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L4/m3_l4_filtering.py

    def post_filter_search(
        store: EvidenceStore, index: BM25Index, query: str, visibilities: Collection[str], top_k: int = 6
    ) -> PostFilterResult:
        """Take the top k of ALL chunks, then drop the ones the caller may not see."""
        allowed = chunk_filter(store.chunks, visibilities)
        ranked = index.search(query, top_k)
        kept = [(ref, score) for ref, score in ranked if allowed(ref)]
        return PostFilterResult([r for r, _ in kept], [s for _, s in kept], removed=len(ranked) - len(kept))

    Four public-user queries against the capstone's 174 chunks (5 are partner-only):

    Query Post-filter returns In-query returns
    partner price LUM-VS-900 1 6
    partner programme vibration sensor 1 6
    white label discount partner programme 1 2
    Which vibration sensor has the widest frequency range you can sell us? 6 6

    Row 3 is short in-query because few allowed chunks match. Row 1 is pinned by this test:

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L4/test_m3_l4_filtering.py

    def test_post_filter_starves_the_result_list(svc: Services) -> None:
        post = post_filter_search(svc.store, full_index(svc.store), QUERY, PUBLIC, top_k=6)
        refs, _ = in_query_search(svc.store, QUERY, PUBLIC, top_k=6)
        assert len(post.refs) == 1 and post.removed == 5  # asked for 6, got 1
        assert len(refs) == 6  # the same predicate inside the query still fills all 6 slots

    Predict first. For the first query, the post-filter reports removed = 5. What does that number tell a caller who is not allowed to see the partner guide?

    Answer

    It equals the number of chunks in the hidden guide (test_removed_count_equals_the_hidden_document_size: 5 removed, 5 partner-only). The caller learns that a five-chunk document matches, which is already a disclosure. Never show such a count.

    A quieter signal: the visible chunk faq-licensing#7 scores 4.309962 under the post-filter and 4.384224 in-query, because hidden chunks changed the term statistics. In-query scores do not change when hidden documents are added (test_in_query_scores_do_not_change_when_hidden_documents_change).

    NOTE

    Vector stores have the same shape. As of 2026-11-02 the pgvector README says that with approximate indexes "filtering is applied after the index is scanned", and gives the example that a condition matching 10% of rows with the default hnsw.ef_search of 40 leaves about 4 rows on average. Check your store's documentation before trusting a filter argument.

    The filtered query end to end

    Figure 2. Who decides what the model may see

    sequenceDiagram accTitle: Who decides what the model may see accDescr: A client sends a question and a bearer token to the API gateway. The gateway verifies the token and reads role and tenant. It sends the query and the allowed visibilities to the retriever, which searches the index with the predicate. The index returns only allowed chunks. The gateway passes the question and that context to the LLM provider and returns the validated answer to the client. participant C as Client app participant G as API gateway participant R as Retriever participant I as Index participant M as LLM provider C->>G: question and bearer token G->>G: verify token, read role and tenant G->>R: query and allowed visibilities R->>I: search with predicate I-->>R: top 6 allowed chunks R-->>G: context with citation ids G->>M: question and allowed context only M-->>G: draft answer G-->>C: validated answer

    How to read this: the model call happens after the predicate, so the model never holds text it is not allowed to repeat.

    Authorization is never the model's job

    Ask the model to hide it Never give it to the model
    What the model sees the partner chunks and a rule allowed chunks only
    Who enforces the model, probabilistically code, deterministically
    A hostile or odd request may talk the model round has nothing to reveal
    How you test it you cannot prove absence you assert on the context

    The capstone's evaluation counts access leaks per strategy. The baseline (no filter) leaks on 5 of 36 questions (q08, q10, q12, q33, q34); strategies A and B on none. Four are ordinary questions, not attacks, and in q08, q10 and q34 the baseline even recommended a partner-only SKU. The lesson test tells the pipeline to ignore the rules, then asserts on every strategy.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L4/test_m3_l4_filtering.py

    def test_authorization_is_code_not_a_prompt(svc: Services) -> None:
        asked = INJECTED
        assert _leaks(asked, BASELINE, svc)  # no filter: partner chunks and a partner SKU reach the answer
        for strategy in (STRATEGY_A, STRATEGY_B):
            assert _leaks(asked, strategy, svc) == []  # same words, same user, nothing hidden gets through
    WARNING

    The MockProvider ignores instructions in the question, so a clean answer proves nothing about a real model. What is proved is about the context: a second test records every prompt and asserts the partner text never appears.

    Tenants: the same filter with one more field

    The capstone's Identity carries a tenant, used today to scope idempotency keys, not retrieval. Per-customer isolation uses the same mechanism with an owner on each chunk.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L4/m3_l4_filtering.py

    def tenant_predicate(owners: Mapping[str, str], tenant: str, shared: str = "shared") -> Callable[[str], bool]:
        """Allow a chunk when its owner is the caller's tenant or the shared pool. Fails closed."""
        if not tenant:
            raise ValueError("a tenant filter needs a tenant id; refusing to search without one")
        return lambda ref: owners.get(ref) in (tenant, shared)  # an unknown owner is not allowed

    The next test builds a synthetic shared index of 303 chunks (illustrative): 300 short notes owned by a large tenant and 3 longer records owned by a small one.

    Verified: capstone/fde-product-discovery-copilot/lessons/M3-L4/test_m3_l4_filtering.py

    def test_small_tenant_starves_under_post_filtering() -> None:
        items, owners = tenant_corpus()
        index = BM25Index(items)
        assert tenant_post_filter(index, owners, "smallco", "pump sensor", 5) == []
        assert len(tenant_in_query(index, owners, "smallco", "pump sensor", 5)) == 3  # all it owns
        assert all(owners[r] == "smallco" for r in tenant_in_query(index, owners, "smallco", "pump sensor", 5))

    For "pump sensor" with k = 5, the post-filter returns nothing to the small tenant (the top 5 are all the large tenant's); in-query returns all 3 of its records.

    Worked example

    A public user asks: "partner price LUM-VS-900". Assumptions: k = 6, the capstone's 174 chunks, 5 of them partner-only, all from one guide.

    1. Unfiltered BM25 puts five guide chunks at ranks 1 to 5 and faq-licensing#7 at rank 6.
    2. Post-filter: 6 retrieved, 5 removed, 1 returned. Fill rate is 1 of 6, about 17 percent.
    3. In-query: the predicate leaves 169 chunks; the top 6 are all public. Fill rate is 6 of 6.
    4. The survivor's score differs by 0.074 (4.384224 minus 4.309962).

    Build it

    Run the lesson tests from capstone/fde-product-discovery-copilot:

    ./.venv/Scripts/python -m pytest lessons/M3-L4 -v

    Then read the capstone's tests/test_retrieval.py, whose test_filter_is_inside_the_query_so_k_results_come_back pins the production behaviour. Catalog.get(sku, visibilities) applies the same rule to structured search; keep one rule for both paths.

    Decide first, then reveal.

    Where does the filter run? (interactive when JavaScript is on)

    Restricted chunks are removed from the top 6, so some users get one or two sources. What is the fix?

    • Raise k
    • Run the predicate inside the query
    • Add a prompt rule
    Answer

    Run the predicate inside the query. An in-query predicate fills every slot from allowed chunks; a larger k keeps the post-filter.

    An endpoint reads the tenant from the JSON body. What changes?

    • Read it from the verified token
    • Check it against known tenants
    • Tell the model to use only that tenant's documents
    Answer

    Read it from the verified token. The body is caller-controlled, so any known tenant can still be claimed.

    A test tells the pipeline to ignore the rules and the answer has no partner text. What makes this proof?

    • The reply sounded careful
    • The mock followed the rule
    • The test records every prompt and asserts the partner text never appears
    Answer

    The test records every prompt and asserts the partner text never appears. A clean answer proves nothing about a real model. The proof is that the context never held the text.

    Exercise

    Spot the flaw. A teammate proposes this design for a tenant-aware assistant.

    Pseudocode.

    hits = index.search(query, k=5)
    visible = [h for h in hits if tenant_of(h) == request.json["tenant"]]
    prompt = "Answer from these passages. Never reveal other tenants' data.\n" + join(visible)
    log("filtered", len(hits) - len(visible), "results")
    Answer

    Four defects. The filter runs after the top 5, so a small tenant is starved. The tenant comes from the request body, so any caller can claim another. The "never reveal" sentence is the only barrier for chunks that slip through. The log line records a count of hidden results. Fix: take the tenant from the verified token and pass a predicate into the search.

    Task. Write test_naming_a_hidden_sku_stays_clean. For each of the five partner-only SKUs, run a public in-query search for price of <SKU> vibration sensor with k = 6. Done when each search returns exactly 6 chunks, every one public, and the test discovers the SKU list from the chunks instead of hard-coding it.

    Hint 1: where do the SKUs come from?

    A hidden SKU appears on partner-only chunks and on no public chunk.

    Hint 2: why does the query add words?

    A bare SKU matches nothing public. Words such as "vibration sensor" give the index public chunks to return.

    Common mistakes

    • Filtering the result list and calling it access control. Check where the predicate runs relative to the top k.
    • Reading the tenant or role from the request body instead of the verified token.
    • Returning or logging "N results hidden". The count is a disclosure.
    • Relying on a system prompt rule. It is a request, not a control, and you cannot test its absence.

    At the customer: "Could the assistant ever show one customer's data to another?" A good answer names the mechanism (a predicate inside the query, from a verified identity), the test (assert on the context, not the answer), and the limit: the capstone's identity is a teaching stub, not production isolation.

    Knowledge check

    1. Why does a post-filter return fewer than k results?

    The index picks the top k first, then the predicate removes hidden chunks. Hidden chunks that rank high take slots that would have gone to allowed chunks.

    2. What can a caller infer from a post-filter that an in-query filter does not give away?

    The removed count and the shifted scores. The count can equal a hidden document's size, and scores depend on statistics that include hidden chunks.

    3. Why is "Never reveal partner data" in the system prompt not an access control?

    The model must hold the text to follow the rule, and compliance is probabilistic. A control must make the outcome impossible, which you test by asserting the text was never in the context.

    4. What does "fail closed" mean for the tenant predicate in this lesson?

    A missing tenant raises and an unknown owner is not allowed. Returning nothing is safe; returning everything turns a data bug into a disclosure.

    5. In a vector store, when can you not rely on a filter argument alone?

    When the store applies it after scanning an approximate index, as pgvector documents. The filter then sees only the scan's candidates, so selective filters leave few rows.

    You can now

    • Say where the access predicate must run and why, with one measured example.
    • Show starvation and score leakage of a post-filter in a test.
    • Write a tenant predicate that fails closed.
    • Prove that hidden text never reaches the model by recording the prompts.

    Further reading

    Last verified: 2026-11-02

    Next

    Context assembly and citations

    Enroll to keep reading

    Lesson 4 of 10 · Forward Deployed Engineer: RAG, Search and Data

    Apply the access filter inside the retrieval query instead of after it, and prove with tests that a post-filter starves results and leaks signals.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: RAG, Search and Data, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments