Metadata filtering is the difference between a demo that works on five documents and a production system that scales. This haystack retriever metadata filtering tutorial walks through setting up a document store, indexing documents with structured metadata, and running filtered retrieval queries that actually return what you need.
Prerequisites
You need Python 3.10+ and a virtual environment. Install the core packages:
pip install haystack-ai==2.6.0
pip install sentence-transformers==3.0.1
We use the InMemoryDocumentStore for this tutorial — no external services required. The same filter syntax applies to Elasticsearch, OpenSearch, Weaviate, Pinecone, and Qdrant backends.
Setting up the document store
Create a file called setup_store.py:
from haystack import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
docs = [
Document(
content="Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.",
meta={
"quarter": "Q3",
"year": 2024,
"department": "finance",
"confidentiality": "internal",
"tags": ["revenue", "enterprise", "renewals"]
}
),
Document(
content="The new onboarding flow reduced time-to-value from 14 days to 3 days.",
meta={
"quarter": "Q3",
"year": 2024,
"department": "product",
"confidentiality": "public",
"tags": ["onboarding", "metrics", "product"]
}
),
Document(
content="Security audit completed with zero critical findings. SOC2 Type II renewed.",
meta={
"quarter": "Q2",
"year": 2024,
"department": "security",
"confidentiality": "confidential",
"tags": ["audit", "soc2", "compliance"]
}
),
Document(
content="Marketing campaign generated 2,400 MQLs at $42 CPL.",
meta={
"quarter": "Q3",
"year": 2024,
"department": "marketing",
"confidentiality": "internal",
"tags": ["campaign", "mqls", "cpl"]
}
),
Document(
content="Engineering hired 12 senior engineers in Q3, bringing headcount to 87.",
meta={
"quarter": "Q3",
"year": 2024,
"department": "engineering",
"confidentiality": "internal",
"tags": ["hiring", "headcount", "team"]
}
),
]
document_store.write_documents(docs)
print(f"Indexed {document_store.count_documents()} documents")
Run it:
python setup_store.py
Expected output:
Indexed 5 documents
Understanding filter syntax
Haystack filters use a nested dictionary structure that maps to the document store’s native query language. The top-level keys are logical operators: operator (must be “AND” or “OR”) and conditions (a list of condition dictionaries).
Each condition has three fields:
field: the metadata key, prefixed withmeta.operator: comparison operator (==,!=,>,>=,<,<=,in,not in,contains,not contains)value: the value to compare against
Create filter_basics.py:
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
# Re-index documents from setup_store.py (omitted for brevity)
# ... same docs list as above ...
document_store.write_documents(docs)
# Filter: Q3 2024 documents from finance department
filter_q3_finance = {
"operator": "AND",
"conditions": [
{"field": "meta.quarter", "operator": "==", "value": "Q3"},
{"field": "meta.year", "operator": "==", "value": 2024},
{"field": "meta.department", "operator": "==", "value": "finance"},
]
}
results = document_store.filter_documents(filters=filter_q3_finance)
print(f"Q3 2024 finance docs: {len(results)}")
for doc in results:
print(f" - {doc.content[:60]}...")
Output:
Q3 2024 finance docs: 1
- Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.
Combining filters with retrieval
In practice you combine metadata filtering with vector or keyword search. The retriever applies the filter first, then ranks the remaining candidates.
Create retriever_with_filters.py:
from haystack import Document, Pipeline
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
document_store = InMemoryDocumentStore()
# ... index same docs as before ...
document_store.write_documents(docs)
# BM25 retriever with metadata filter
bm25_retriever = InMemoryBM25Retriever(document_store=document_store)
filter_internal_q3 = {
"operator": "AND",
"conditions": [
{"field": "meta.confidentiality", "operator": "==", "value": "internal"},
{"field": "meta.quarter", "operator": "==", "value": "Q3"},
]
}
query = "revenue growth"
results = bm25_retriever.run(query=query, filters=filter_internal_q3, top_k=3)
print(f"BM25 results for '{query}' (internal Q3 only):")
for doc in results["documents"]:
print(f" score={doc.score:.3f} | dept={doc.meta['department']} | {doc.content[:70]}...")
Output:
BM25 results for 'revenue growth' (internal Q3 only):
score=0.842 | dept=finance | Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.
score=0.311 | dept=marketing | Marketing campaign generated 2,400 MQLs at $42 CPL.
score=0.287 | dept=engineering | Engineering hired 12 senior engineers in Q3, bringing headcount to 87.
The filter runs before scoring. Only three documents match the filter, so all three are returned even though the last two have low BM25 relevance to “revenue growth.”
Vector retrieval with metadata filters
The embedding retriever works the same way. First, embed your documents:
from haystack.components.embedders import SentenceTransformersDocumentEmbedder
document_embedder = SentenceTransformersDocumentEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
document_embedder.warm_up()
docs_with_embeddings = document_embedder.run(docs)["documents"]
document_store.write_documents(docs_with_embeddings)
Now query with a vector retriever and filter:
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
text_embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
text_embedder.warm_up()
embedding_retriever = InMemoryEmbeddingRetriever(document_store=document_store)
filter_public = {
"operator": "AND",
"conditions": [
{"field": "meta.confidentiality", "operator": "==", "value": "public"},
]
}
query = "how to reduce customer onboarding time"
query_embedding = text_embedder.run(text=query)["embedding"]
results = embedding_retriever.run(query_embedding=query_embedding, filters=filter_public, top_k=3)
print(f"Vector results for '{query}' (public only):")
for doc in results["documents"]:
print(f" score={doc.score:.3f} | dept={doc.meta['department']} | {doc.content[:70]}...")
Output:
Vector results for 'how to reduce customer onboarding time' (public only):
score=0.721 | dept=product | The new onboarding flow reduced time-to-value from 14 days to 3 days.
Only the product document matches the confidentiality: public filter, so it’s the sole result despite other documents being semantically related to onboarding.
Complex filter expressions
Real systems need nested logic. Haystack supports arbitrarily nested AND/OR groups.
Create complex_filters.py:
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents(docs)
# (Q3 2024 AND (finance OR marketing)) AND (internal OR confidential)
filter_complex = {
"operator": "AND",
"conditions": [
{"field": "meta.quarter", "operator": "==", "value": "Q3"},
{"field": "meta.year", "operator": "==", "value": 2024},
{
"operator": "OR",
"conditions": [
{"field": "meta.department", "operator": "==", "value": "finance"},
{"field": "meta.department", "operator": "==", "value": "marketing"},
]
},
{
"operator": "OR",
"conditions": [
{"field": "meta.confidentiality", "operator": "==", "value": "internal"},
{"field": "meta.confidentiality", "operator": "==", "value": "confidential"},
]
},
]
}
results = document_store.filter_documents(filters=filter_complex)
print(f"Complex filter matches: {len(results)}")
for doc in results:
print(f" - {doc.meta['department']} | {doc.meta['confidentiality']} | {doc.content[:50]}...")
Output:
Complex filter matches: 2
- finance | internal | Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.
- marketing | internal | Marketing campaign generated 2,400 MQLs at $42 CPL.
The engineering document is excluded because it doesn’t match the department OR clause. The security document is excluded because it’s Q2, not Q3.
Filtering on array fields
The tags field in our documents is a list. Use contains and not contains for array membership:
filter_has_tag = {
"operator": "AND",
"conditions": [
{"field": "meta.tags", "operator": "contains", "value": "revenue"},
]
}
results = document_store.filter_documents(filters=filter_has_tag)
print(f"Docs tagged 'revenue': {len(results)}")
for doc in results:
print(f" - {doc.meta['tags']}")
Output:
Docs tagged 'revenue': 1
- ['revenue', 'enterprise', 'renewals']
Multiple tags with OR:
filter_multiple_tags = {
"operator": "OR",
"conditions": [
{"field": "meta.tags", "operator": "contains", "value": "audit"},
{"field": "meta.tags", "operator": "contains", "value": "soc2"},
{"field": "meta.tags", "operator": "contains", "value": "compliance"},
]
}
results = document_store.filter_documents(filters=filter_multiple_tags)
print(f"Docs with audit/soc2/compliance tags: {len(results)}")
Output:
Docs with audit/soc2/compliance tags: 1
Using filters in a pipeline
Haystack pipelines let you wire retrievers, generators, and other components together. Filters can be static (defined at pipeline construction) or dynamic (passed at runtime).
Create pipeline_with_dynamic_filters.py:
from haystack import Pipeline
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
import os
document_store = InMemoryDocumentStore()
document_store.write_documents(docs)
retriever = InMemoryBM25Retriever(document_store=document_store)
prompt_template = """
Answer the question using only the provided documents.
Documents:
{% for doc in documents %}
[{{ doc.meta.department }} | {{ doc.meta.confidentiality }}] {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
prompt_builder = PromptBuilder(template=prompt_template)
# Generator requires OPENAI_API_KEY - using a mock for tutorial
# generator = OpenAIGenerator(model="gpt-4o-mini")
pipeline = Pipeline()
pipeline.add_component("retriever", retriever)
pipeline.add_component("prompt_builder", prompt_builder)
# pipeline.add_component("generator", generator)
pipeline.connect("retriever.documents", "prompt_builder.documents")
# pipeline.connect("prompt_builder.prompt", "generator.prompt")
# Runtime filter: only show internal Q3 2024 docs to the generator
runtime_filter = {
"operator": "AND",
"conditions": [
{"field": "meta.quarter", "operator": "==", "value": "Q3"},
{"field": "meta.year", "operator": "==", "value": 2024},
{"field": "meta.confidentiality", "operator": "==", "value": "internal"},
]
}
question = "What were the key metrics this quarter?"
result = pipeline.run({
"retriever": {"query": question, "filters": runtime_filter, "top_k": 5},
"prompt_builder": {"question": question},
})
print("Retrieved documents for generator:")
for doc in result["retriever"]["documents"]:
print(f" [{doc.meta['department']}] {doc.content[:60]}...")
print("\nPrompt sent to generator:")
print(result["prompt_builder"]["prompt"])
Output:
Retrieved documents for generator:
[finance] Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.
[marketing] Marketing campaign generated 2,400 MQLs at $42 CPL.
[engineering] Engineering hired 12 senior engineers in Q3, bringing headcount to 87.
Prompt sent to generator:
Answer the question using only the provided documents.
Documents:
[finance | internal] Q3 revenue reached $14.2M, up 18% YoY driven by enterprise renewals.
[marketing | internal] Marketing campaign generated 2,400 MQLs at $42 CPL.
[engineering | internal] Engineering hired 12 senior engineers in Q3, bringing headcount to 87.
Question: What were the key metrics this quarter?
Answer:
The generator only sees internal Q3 documents. The public onboarding doc and confidential security audit are excluded at retrieval time.
Common pitfalls
Field name mismatch
The field value must exactly match the metadata key including the meta. prefix. meta.department works; department does not.
# Wrong - returns 0 results silently
{"field": "department", "operator": "==", "value": "finance"}
# Correct
{"field": "meta.department", "operator": "==", "value": "finance"}
Type coercion
Haystack does not coerce types. A string "2024" does not match an integer 2024.
# Wrong if year is stored as int
{"field": "meta.year", "operator": "==", "value": "2024"}
# Correct
{"field": "meta.year", "operator": "==", "value": 2024}
Case sensitivity
String comparisons are case-sensitive. "Q3" != "q3". Normalize at write time or use in with multiple values:
{"field": "meta.quarter", "operator": "in", "value": ["Q3", "q3"]}
Empty filter matches everything
An empty filter dictionary {} or {"operator": "AND", "conditions": []} returns all documents. This is useful for “no filter” code paths but dangerous if a bug produces an empty filter unintentionally.
Performance notes
Metadata filtering happens at the document store level before any scoring. For InMemoryDocumentStore this is a linear scan. For backed stores (Elasticsearch, OpenSearch, Pinecone, etc.) the filter pushes down to the index.
Index your filterable fields. In Elasticsearch, map metadata fields as keyword not text. In Pinecone, declare filterable metadata at index creation. The filter syntax stays the same; only the backend performance changes.
When using n4n.ai as an inference gateway, you can pass routing directives that include metadata filters alongside model selection — the gateway forwards them to the underlying provider without modification.
Next steps
- Add a hybrid retriever that combines BM25 and embedding scores with the same filter
- Implement filter validation at API boundaries to catch type mismatches early
- Log filter expressions alongside queries for debugging and audit trails
- Consider row-level security: derive filters from user roles rather than trusting client input
The filter syntax you learned here works across every Haystack document store. Master it once, apply it everywhere.