Metadata Filters in Retrieval
Filter by tenant, product, language, and date before you rank. Vector search without metadata is how one customer sees another customer's contract.
- rag
- metadata
- security
Similarity does not know GDPR. Your where-clause does. Cosine will happily rank another tenant’s contract next to the question if that contract is the nearest neighbor in vector space.
Metadata filtering is the WHERE on retrieval: tenant, product, language, date, ACL. Apply it in the index query, not in the prompt, not after the model has already “seen” the text.
Key takeaways
- Put tenant id (and every other ACL) on every vector. Filter on every query.
- Pre-filter in the database. Post-filter of top-k can return zero hits and still leak if you log prompts.
- Never retrieve then ask the model to ignore other customers. That is not a control.
- Date windows and language tags stop stale and wrong-locale policies from winning cosine.
- Filters are security. Test that a forbidden id never appears in the packed prompt.
Similarity is not authorization
A vector database ranks by distance. Distance is not “this user may read this.” If the CEO’s draft is in the same namespace as the intern’s question, the intern’s top-8 may include the draft.
This is the same class of bug as putting a secret in the JS bundle. The model is not a wall. See secrets in frontend bundles and the sibling permission-aware retrieval for the ACL-heavy version. This page is the filter mechanics.
Pinecone’s filtering writeup names the two naive approaches: filter then search, or search then filter. Both have sharp edges. Your product still has to pick one and test it.
Metadata filtering is the where-clause
type ChunkMeta = {
tenantId: string;
productId: string;
lang: 'en' | 'de' | 'fr';
updatedAt: number; // unix seconds, filterable
visibility: 'public' | 'internal';
};
type SearchRequest = {
tenantId: string;
productId?: string;
lang: ChunkMeta['lang'];
asOf?: number;
queryVector: number[];
topK: number;
};
function filterFor(req: SearchRequest): Record<string, unknown> {
const clauses: Record<string, unknown>[] = [
{ tenantId: { $eq: req.tenantId } },
{ lang: { $eq: req.lang } },
];
if (req.productId) clauses.push({ productId: { $eq: req.productId } });
if (req.asOf) clauses.push({ updatedAt: { $lte: req.asOf } });
return { $and: clauses };
}
async function search(index: VectorIndex, req: SearchRequest) {
// Failure: omitting tenantId here is a cross-tenant read.
return index.query({
vector: req.queryVector,
topK: req.topK,
filter: filterFor(req),
namespace: req.tenantId, // extra isolation, not a substitute for filter
});
}
namespace: tenantId is belt. filter.tenantId is suspenders. Use both when the vendor gives you namespaces. A coding error that forgets the filter should still fail closed if the namespace is empty for that tenant, not open onto the default corpus.
Operator names ($eq, $and) vary by vendor. Postgres is WHERE tenant_id = $1. The invariant does not vary: the query always includes tenant.
Pre-filter vs post-filter
| Approach | What happens | Risk |
|---|---|---|
| Pre-filter | Restrict the candidate set, then ANN / k-NN inside it | Some ANN indexes degrade if the filter is extremely selective; vendors differ. Quality is the one you want for ACLs. |
| Post-filter | Take top-k globally, drop rows that fail the predicate | Top-k may contain zero allowed rows even though allowed rows exist. Also, the forbidden rows entered your process. |
| Over-retrieve then post-filter | Take top-200, drop | Slower, still can miss. Not an ACL strategy. |
For security predicates (tenant, user ACL, confidentiality), pre-filter or a true per-tenant index. Do not post-filter.
For soft preferences (prefer productId = "pro" but allow fallback), you can retrieve twice or boost, then merge. That is ranking, not auth.
OpenAI retrieval and every serious vector store expose metadata filters because this problem showed up on day one of putting more than one customer in an index.
Date windows and language
Stale policy: the 2021 PDF and the 2026 HTML both embed as “refunds.” Without updatedAt, cosine will pick whichever chunk is closer to the question’s phrasing, which might be the old one because it is longer.
Patterns that work:
- As-of queries for audits:
updatedAt <= asOf. - Freshness bias for support: filter
updatedAt >= now - windowor retrieve both and pack the newest first. - Language: filter
lang = user.lang. Multilingual embeddings still retrieve the wrong locale. Do not rely on the model to “notice” the chunk is in German.
Product and version tags belong here too: apiVersion: "2024-10-01". Docs RAG that mixes v1 and v2 examples will invent APIs that never shipped.
Never filter in the prompt
This is the bug:
// WRONG: model is told to ignore other tenants after seeing them.
messages: [
{
role: 'system',
content: `Only use sources for tenant ${tenantId}. Ignore the rest.`,
},
{ role: 'user', content: formatSources(unfilteredHits) },
];
The forbidden contract is now in the context window, in logs, in traces, in the provider’s retention. Prompt injection in a retrieved chunk can also say “ignore the tenant rule.” Authorization belongs in code, the same way you would not innerHTML a stranger’s markdown and hope CSS hides it.
Tests worth writing:
- Two tenants, two unique secret phrases in their corpora. Query as tenant A. Assert B’s phrase never appears in retrieved ids, packed prompt, or traces.
- Filter so selective that only one chunk matches. Assert you still get that chunk (pre-filter), not an empty list from post-filter.
- Missing
tenantIdon upsert fails closed (schema), not “public.”
The RAG mental model still holds: the generator only sees what you packed. Metadata filters decide what you are allowed to pack.
Interview answer (out loud)
“I treat metadata filters as a WHERE clause on retrieval. Tenant id lives on every vector and on every query, preferably with a per-tenant namespace too. Security filters are pre-filters, never post-filters of top-k, and never instructions in the prompt. I also filter language and date so cosine cannot prefer a stale or wrong-locale policy. I would test that a forbidden document never appears in the packed prompt.”
Further reading (primary sources)
- The missing WHERE clause in vector search (Pinecone)
- OpenAI retrieval
- OWASP Top 10 for LLM applications
Related on this site
- Permission-aware retrieval
- Vector databases explained
- RAG mental model
- Secrets in frontend bundles
- Prompt injection
Related guides
- Permission-Aware RetrievalRetrieval must respect ACLs. Filter by what the user can read before ranking, and never let the model "see" a forbidden chunk to decide.
- Building a Docs RAG ChatbotBuild a docs chatbot on MDX: ingest pages, embed, retrieve, then stream a cited answer. The shape you can ship on a content site.
- Chunking Strategies for RAGChunking decides what the retriever can return. Size, overlap, headings, and why splitting on a 500-character timer wrecks tables and code.
- Citations and GroundingGrounding means the answer is entailed by retrieved text. Citations are the UI. How to require quotes, and how models still hallucinate a source.
- Client vs Server LLM CallsAn LLM API key in the browser is public. Stream from your origin, quota per user, and treat client SDKs as an explicit product choice.