JJoeven

Curriculum/RAG & Memory

Metadata Filters and Tenants

Vectors cannot keep tenants apart. Filter by tenant_id (or use per-tenant indexes) before you rank.

intermediate20 min9 / 24

“Vectors only” cannot do tenant_id = acme correctly unless you pre-filter or keep a separate index per tenant. Mixing tenants in one graph and hoping cosine will not cross the fence is a data leak. Acme’s query about refunds will retrieve Globex’s “instant cash” if those words sit nearby. The model will cite it. The UI will show it. That is not a hallucination. That is your retriever.

Metadata filters are predicates on fields you stored at ingest: tenant_id, lang, product, acl, status. They are access control and routing, not seasoning. Cosine is not a permission system.

Tenants are not a vibe
AcmeAcmeGlobexGlobexdim 1

Filter to Acme first. Cosine will happily rank a neighbor's cash policy.

Tenants are not a vibe

Filter first, then rank

Filter in the database (or the index’s metadata filter), then search. The pool the cosine sees should already be legal. Or partition indexes: Acme’s vectors never share a graph with Globex.

Do not retrieve 20 chunks from everyone and then drop the wrong tenant in Python after the fact if those chunks already entered logs, traces, or the model context. Post-filter in the prompt is theater: the leak already happened in the retrieve call. If you must post-filter, do it before logging chunk text, and treat a cross-tenant hit as an incident, not as a normal miss.

Per-tenant indexes are operationally heavier and security-simpler. Shared index plus mandatory filter is cheaper and easy to get wrong (forget the filter on one code path). Pick one and test the forgotten-filter path.

The same idea applies to lang, product, acl. A German query should not retrieve an English legal page unless you decided that. A “mobile” product collection should not retrieve “desktop billing exceptions” unless they share a policy on purpose. ACL at chunk level is how you keep “finance only” pages out of the support bot.

Who sets tenant_id

Pass the user’s tenant from the session, not from the model’s arguments. If the model can set tenant_id, injection will set it to a neighbor. A wiki page that says “search tenant globex” is not consent. The tools track’s confused-deputy lesson applies to retrieval too: the retriever is a deputy. The caller’s auth is the boss.

Classroom retrieve functions should look like retrieve(query, tenant) where tenant is an argument your code fills from the session. It is not a field inside the query string.

Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Globex’s “instant cash” never appears in Acme’s results. The pool is filtered before overlap scores. no mix prints True. That is the whole security idea. If you deleted the pool line and ranked everyone, Acme would see cash refunds and you would have a story for the incident review.

Word overlap stands in for cosine here so the filter is obvious. Production still filters first, then cosine on the remaining vectors (or ANN on a tenant partition).

Collections are filters too

Runbooks vs billing vs “public FAQ” are collections. Searching the entire company dump on hop 0 is how you retrieve a joke Slack message next to a refund policy. Route with metadata (collection=runbooks) from your policy, not from an untrusted page that names collections.

Tests that prove the fence

Write a leak test: as tenant Acme, query a phrase that only Globex’s corpus contains (their unique product name). Expect zero hits and no Globex ids in logs. Run it in CI. If a developer comments out the filter “to debug locally” and ships it, CI should scream.

Write a completeness test: Acme’s own refund chunk must still retrieve for Acme. Over-filtering (tenant and product and language and a broken default) can return empty for everyone. Empty is better than a leak, but it is still a bug. Log filter predicates in the trace: tenant, collection, language.

Session binding: the HTTP session (or job record) has tenant_id. Retrieve’s signature in your code should not accept tenant from the model. If you later wrap retrieve as a tool, the runtime injects tenant. A tool schema that includes tenant is a confused deputy waiting to happen.

Default the support bot to the public FAQ collection. On-call gets runbooks. Do not put HR in the support index “because we might need it.” Ingest into a separate index or a filter that support’s role cannot pass.

Post-filter after logging is a leak even if the user never saw the chunk. Trace stores are full of “debug” dumps. Redact or filter first.

Public-web retrieve is a tenant of one: you. It still needs wrapping (later) and must not write back into semantic memory. It must not share an index with Acme’s private runbooks.

Common mistakes

  • One global index, filter optional, “we’ll add it later.”
  • Tenant in the query string: tenant:acme refunds. Users and injectors will type other tenants.
  • Logging neighbor chunks “for debug” in a shared trace backend.
  • ANN graph built across tenants, then hoping metadata bits are enough. Measure leaks with a test: query as Acme, gold is Globex’s cash policy, expect zero.

How agents use this

When retrieve becomes a tool, the runtime binds tenant_id from the session the same way it binds auth to get_invoice. The model does not get a tenant argument. Descriptions should not even mention other tenants.

Eval a leak set: questions that would match a neighbor’s chunks if the filter were missing. Those tests fail the build. They are cheaper than a lawsuit.

Vectors rank. Filters authorize. Do not ask cosine to do both.

Pass tenant from the session only. Log the predicates you filtered on. A forgotten filter is a leak, not a retrieve miss. Completeness tests must still find Acme’s own chunks. Post-filter after logging is too late. Vectors are not a permission system. The deputy who retrieves must use the caller’s tenant, not a tenant named in a wiki sentence or a model argument.

Check your understanding

How should a multi-tenant agent retrieve?