FlowKit

Metadata filtering in an n8n RAG: target the right documents before the vector search

Published 29 July 2026 · 4 min read

A RAG that searches the whole corpus on every question eventually picks the wrong document: semantic similarity surfaces a convincing passage… from the wrong version of the docs, the wrong client, or an obsolete note. The remedy is metadata filtering: attach structured attributes to every chunk at ingestion (source, date, language, tenant), then restrict the vector search to the relevant slice at query time. It's the best effort-to-impact improvement in a RAG pipeline — and n8n handles it end to end.

Why similarity alone isn't enough

Vector search ranks by semantic proximity, nothing else. Yet many business constraints aren't semantic:

  • Version: "how do I configure the export?" must be answered from the v2 docs, not the archived v1 that's 95% identical;
  • Freshness: a 2023 pricing policy is semantically identical to the 2026 one, but wrong;
  • Scope: in a multi-client RAG, one client's question must never touch another's documents — a security issue, not a relevance one;
  • Document type: a signed contract and a draft email cover the same topic with a very different truth value.

Recent RAG literature confirms that retrieval quality — not model size — is the limiting factor: the reference survey by Yunfan Gao and coauthors, "Retrieval-Augmented Generation for Large Language Models: A Survey" (2023, see on Google Scholar), lists source filtering and routing precisely among the techniques that distinguish advanced RAG architectures from naive RAG. In other words: before reaching for a bigger model, filter better.

Step 1 — Enrich chunks at ingestion

Everything is decided at ingestion, in the Default Data Loader attached to your vector store node: its Metadata section adds key-value pairs to every chunk produced by the text splitter. Values can be fixed or computed by expression from the current item:

source        → {{ $json.file_name }}
type          → contract | product_doc | faq
document_date → {{ $json.modified_at }}
language      → en
tenant_id     → {{ $json.client_id }}
version       → v2

Three golden rules: stable keys (renaming one breaks every existing filter), normalized values (ISO 8601 dates, closed enumerations rather than free text), and parsimony — every metadata field should map to a filter you'll actually use. Storage-wise, Supabase/pgvector puts everything in the metadata (jsonb) column created by the standard schema from our RAG with Supabase guide; Qdrant uses its payload, indexable field by field.

Step 2 — Filter at query time

In the Supabase Vector Store node (retrieve mode, or mounted as an agent tool), the Metadata Filter option passes your criteria to the match_documents function, which filters the jsonb before ranking by similarity:

-- typical match_documents excerpt: the filter applies upstream of the vector sort
select id, content, metadata,
       1 - (embedding <=> query_embedding) as similarity
from documents
where metadata @> filter          -- the jsonb filter passed by n8n
order by embedding <=> query_embedding
limit match_count;

For conditions richer than equality — date ranges, value lists, negation — adapt match_documents: it's an ordinary SQL function, and metadata->>'document_date' >= '2025-01-01' is still standard PostgreSQL. At scale, index the filtered keys (create index on documents ((metadata->>'tenant_id'))): an unindexed filter that discards 95% of rows wastes the HNSW or IVFFlat index built next to it. On the Qdrant side, the node exposes the equivalent through payload filters (must/should), particularly efficient because they're natively indexed — one more point in the Qdrant vs pgvector match-up.

Step 3 — Make the filter dynamic: the self-query pattern

The next level up: deriving the filter from the question itself. "What does the Dupont contract signed this year say?" contains two implicit filters (type=contract, date >= 2026-01-01) and one semantic query ("Dupont contract clauses"). The pattern, known as self-query, is straightforward to build in n8n:

  1. A first LLM call with a Structured Output Parser extracts a JSON {filters: {...}, query: "..."} from the question;
  2. The filters feed the Metadata Filter option (or the custom SQL query);
  3. The rewritten query goes into the vector search, optionally complemented by hybrid search and reranking to refine the final ranking.

Keep a safety valve: if the extraction finds no reliable filter, search unfiltered rather than inventing a constraint — a wrong filter produces "no documents found" answers that frustrate more than a slightly broad result.

Special case: multi-tenant, where the filter becomes security

As soon as several clients or teams share an index, the tenant_id filtered on every query stops being an optimization and becomes a security requirement. And an application-level filter is fragile: one workflow copied without its filter is enough to leak cross-client data. Defense in depth:

  • Database-side: Postgres Row Level Security on the embeddings table, with one role per tenant — the query cannot see another client's rows even if the n8n filter is forgotten. Our article on the GDPR audit trail with Supabase applies the same philosophy to traceability;
  • Or physical isolation: one Qdrant collection (or one table) per client, at the cost of heavier ingestion management.

The metadata filter targets; the database barrier guarantees. Together they make a multi-tenant RAG you can present to an audit.

In short

Metadata filtering turns a RAG that "searches everywhere" into one that searches in the right place: enrich every chunk at ingestion (source, type, date, language, tenant), filter at query time via the Metadata Filter option or an adapted match_documents function, move to self-query when questions carry their own constraints, and back the filter with a real barrier (RLS, isolated collections) as soon as several clients coexist. That's exactly the architecture packaged in our RAG Assistant Pack — ready to plug into your own metadata.

FAQ

Frequently asked questions

What is metadata for in a RAG pipeline?

It restricts the vector search to a relevant subset of the corpus before similarity ranking: searching only the v2 product docs, only a given client's contracts, only documents less than a year old. Without a filter, semantic similarity alone can surface a very similar-looking passage from the wrong source, the wrong version or the wrong client.

How do I add metadata to my chunks in n8n?

At ingestion time, in the Default Data Loader node: the Metadata section attaches key-value pairs to every chunk (source, document type, date, language, tenant_id…), either fixed or computed by expression from the current item. They're stored alongside the vector — as jsonb in the metadata column for Supabase/pgvector, as payload for Qdrant.

How do I filter by metadata at query time in the Supabase Vector Store node?

The node's Metadata Filter option (in retrieve mode or as an agent tool) passes your key-value pairs to the match_documents function, which filters the jsonb metadata column before ranking by similarity. For richer filters (date ranges, value lists), adapt the match_documents SQL function in Supabase — it's standard PostgreSQL.

Is metadata filtering enough to isolate several clients' data in one index?

It's the indispensable baseline (a tenant_id filtered on every query), but for genuinely sensitive data, don't rely solely on an application-level filter a workflow can forget: add a database-side barrier, such as Postgres Row Level Security on the embeddings table, or separate Qdrant collections per client. Defense in depth: the filter targets, RLS guarantees.

Bundle FlowKit Complet

€269