
I have a RAG pipeline indexing documents across multiple teams and product lines. When a user on the payments team asks a question, I do not want the retrieval system returning documentation from the infrastructure team, even if those documents are semantically similar to the query.
The solution is metadata filtering: storing structured attributes alongside each document chunk and using those attributes to restrict which documents are searched. This post covers how to implement metadata filtering in Qdrant, what happens to retrieval quality when you filter aggressively, and how to avoid the common pitfalls.
What Metadata Filtering Is
Every document chunk in a vector database can carry a payload alongside the vector. The payload is a JSON object of key-value pairs: team, document_type, date, language, access_level, or anything else relevant to your use case.
A metadata filter is a predicate applied to the payload before or during vector search. Instead of searching all 500,000 documents, you search the subset matching the filter (for example, all documents where team = "payments" and date >= "2026-01-01").
This is different from post-filtering (retrieving candidates and then discarding non-matching ones). Post-filtering reduces your effective K without finding more candidates, which degrades recall. Qdrant applies filters during HNSW traversal, so the search only visits matching segments.
Designing Your Metadata Schema
The right metadata schema depends on how your users filter content. Common attributes:
Source/ownership: team, department, product_line, author
Document type: doc_type (e.g., "runbook", "api_reference", "policy", "ticket")
Temporal: date, last_updated, version
Access: access_level, visibility
Content attributes: language, region, topic
Store metadata at index time and keep it normalized. Inconsistent values in the same field ("Payments", "payments", "PAYMENTS") will split your filter into three buckets, each too small to search effectively.
Indexing with Metadata in Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
import ollama
client = QdrantClient(host="localhost", port=6333)
def embed(text: str) -> list[float]:
return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]
def index_chunk(chunk_id: str, text: str, metadata: dict) -> None:
vector = embed(text)
client.upsert(
collection_name="your_collection",
points=[
PointStruct(
id=chunk_id,
vector=vector,
payload={"text": text, **metadata}
)
]
)
# Example with metadata
index_chunk(
chunk_id="doc_001_chunk_003",
text="The payment gateway timeout is configured to 30 seconds by default.",
metadata={
"team": "payments",
"doc_type": "runbook",
"date": "2026-06-15",
"access_level": "internal"
}
)
Querying with Filters
Qdrant's filter syntax lets you combine conditions:
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
def retrieve_filtered(
query: str,
team: str,
doc_types: list[str] = None,
k: int = 10
) -> list[dict]:
vector = embed(query)
conditions = [
FieldCondition(key="team", match=MatchValue(value=team))
]
if doc_types:
conditions.append(
FieldCondition(key="doc_type", match=MatchValue(value=doc_types))
)
results = client.search(
collection_name="your_collection",
query_vector=vector,
query_filter=Filter(must=conditions),
limit=k,
with_payload=True
)
return [
{"id": str(r.id), "text": r.payload.get("text", ""), "score": r.score}
for r in results
]
How Filtering Affects Retrieval Quality
Filtering always reduces the candidate pool. Smaller candidate pools mean the HNSW graph has fewer connections to traverse, which can reduce recall. The relevant document is in the corpus, but the filtered subgraph may not find the path to it.
The practical rule: keep filtered candidate pools above a few thousand documents. Filtering to a very small subset (fewer than a few hundred documents) is often better served by full-text search or a direct lookup rather than vector search.
We measured the impact on our eval set by progressively restricting the filter:

| Filter scope | Corpus size | Recall@10 | Recall@1 |
|---|---|---|---|
| No filter (full corpus) | 500,000 docs | 89% | 81% |
| Single team filter | ~50,000 docs | 88% | 80% |
| Team + doc_type filter | ~5,000 docs | 85% | 77% |
| Team + doc_type + recent date window | ~500 docs | 71% | 63% |
Filtering to a single team had almost no impact. Filtering to a specific document type within a team had a modest impact. Restricting to a recent date window on a small corpus caused a significant drop. In our runs, filtering to roughly 500 documents meant vector search was no longer the right tool.
Handling the Fallback Case
When a filter produces too few results, you have two options:
Widen the filter: remove the most restrictive condition and retry. If team + doc_type + date returns too few results, retry with team + doc_type only.
Fall back to full-text search: for very small filtered sets, BM25 or exact match often outperforms vector search anyway.
def retrieve_with_fallback(
query: str,
team: str,
doc_type: str,
k: int = 10,
min_results: int = 20
) -> list[dict]:
# Try narrow filter first
results = retrieve_filtered(query, team=team, doc_types=[doc_type], k=k)
if len(results) >= min_results:
return results
# Widen to team only
results = retrieve_filtered(query, team=team, k=k)
if len(results) >= min_results:
return results
# Fall back to no filter
return retrieve_filtered(query, team=None, k=k)
Qdrant's Payload Indexing
By default, Qdrant scans payloads at query time. For large collections with frequent filtering on specific fields, create payload indexes:
from qdrant_client.models import PayloadSchemaType
client.create_payload_index(
collection_name="your_collection",
field_name="team",
field_schema=PayloadSchemaType.KEYWORD
)
client.create_payload_index(
collection_name="your_collection",
field_name="date",
field_schema=PayloadSchemaType.DATETIME
)
Keyword indexes speed up equality filters. DateTime indexes enable efficient range queries. Add indexes for fields you filter on frequently. For fields you filter on rarely, the scan overhead is acceptable.
Access Control via Metadata
Metadata filtering is a clean pattern for access control: store access_level or user_group in the payload and filter by the current user's permissions at query time. The user never sees documents outside their access level because those documents are excluded from the search.
This is not a security boundary on its own. It depends on the application layer correctly passing the user's access level to the retrieval function. Treat it as a retrieval constraint, not a security control.
Get the next one
I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.
Reader challenge: are you using metadata filtering in your RAG pipeline? What attributes do you filter on? Reply with your schema.
Sources
- Qdrant filtering documentation: https://qdrant.tech/documentation/concepts/filtering/
- Qdrant payload indexing: https://qdrant.tech/documentation/concepts/indexing/#payload-index
- Qdrant HNSW with filters: https://qdrant.tech/articles/filtrable-hnsw/
About the Author
Toc Am
Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.
Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.
Get These In Your Inbox
Weekly deep-dives on AI engineering, no fluff. Join the newsletter →
Or grab the book ($39, ~100 pages) · Buy me a coffee
☕ Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter
No comments:
Post a Comment