Symptoms
querywith afilteris 10–100× slower than without.- Latency depends heavily on which tenant or category is filtered.
- Some filtered searches return fewer than
limitresults. - CPU on Qdrant nodes rises sharply when a filtered feature launches.
How Qdrant handles a filtered search
Qdrant chooses a strategy per query based on how many points the filter matches (its cardinality), estimated from payload indexes:
| Filter matches | Strategy |
|---|---|
Few points (below full_scan_threshold) | Use the payload index to get matching points, then score them exactly |
| Many points | Search the HNSW graph and check the filter on each candidate |
Without a payload index, Qdrant can't estimate cardinality cheaply and has to check the payload of points one by one. That's the usual cause of slow filtered search.
For filters that cut out most of the graph, a normal HNSW graph can become disconnected for that subset. Qdrant builds extra graph links for indexed payload values so filtered traversal stays connected. That also only happens for indexed fields.
Step 1: check which fields are indexed
curl -s -H "api-key: $QDRANT_API_KEY" http://qdrant:6333/collections/kb_chunks | jq '.result.payload_schema'
Every field you filter on should appear with a type and a points count. If tenant_id or category is missing, that's the fix.
Step 2: create payload indexes
curl -X PUT http://qdrant:6333/collections/kb_chunks/index \
-H "api-key: $QDRANT_API_KEY" -H "Content-Type: application/json" \
-d '{"field_name": "category", "field_schema": "keyword"}'
Python:
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://qdrant:6333", api_key=API_KEY)
client.create_payload_index("kb_chunks", field_name="category",
field_schema=models.PayloadSchemaType.KEYWORD)
client.create_payload_index("kb_chunks", field_name="published_at",
field_schema=models.PayloadSchemaType.DATETIME)
| Field type | field_schema |
|---|---|
| Exact strings (IDs, categories) | keyword |
| Integers (ranges or exact) | integer (with lookup/range options) |
| Floats | float |
| Dates | datetime |
| Booleans | bool |
| Free text matching | text (full-text index with tokenizer) |
| Geo | geo |
| UUIDs | uuid |
Tip
Create payload indexes right after creating the collection and before uploading data. Indexes created later work, but the extra filter-aware HNSW links are built when segments are optimised, so the full benefit arrives after re-optimisation.
Step 3: mark the tenant field
If most queries filter by one field that splits the data into many groups (tenant, user, project), tell Qdrant:
client.create_payload_index(
"kb_chunks",
field_name="tenant_id",
field_schema=models.KeywordIndexParams(type="keyword", is_tenant=True),
)
is_tenant lets Qdrant co-locate points of the same tenant in storage, which speeds up per-tenant searches. For a time field most queries narrow on, the similar option is is_principal.
For many small tenants, Qdrant's multitenancy guidance also suggests building the graph per tenant instead of globally:
client.update_collection(
"kb_chunks",
hnsw_config=models.HnswConfigDiff(payload_m=16, m=0),
)
m=0 disables the global graph and payload_m=16 builds graphs per value of indexed payload fields. Only do this if every query filters by tenant; unfiltered searches would then be slow.
Step 4: check filter shape
| Filter | Comment |
|---|---|
must: [{key: tenant_id, match: {value: "t42"}}] | Efficient with index |
should with many alternatives | Each branch evaluated; keep it short |
must_not on a common value | Matches most points; behaves like an unfiltered search plus checks |
match: {text: "..."} on a non-text index | Won't work as full-text; needs text index |
Nested key meta.team | Index the full path meta.team |
Also make sure types match: a payload stored as "42" (string) won't match match: {value: 42} (integer).
Step 5: too few results
If a filtered search returns fewer than limit points although enough matching points exist, raise hnsw_ef for that query, or let Qdrant switch to exact search for it:
client.query_points(
"kb_chunks", query=qvec, limit=10,
query_filter=models.Filter(must=[models.FieldCondition(key="tenant_id", match=models.MatchValue(value="t42"))]),
search_params=models.SearchParams(hnsw_ef=256),
)
With a proper payload index this is rare, because Qdrant either uses the filter-aware graph or the exact path.
Step 6: measure
Compare latency per tenant size class before and after:
import time
for tenant in ["tiny-tenant", "medium-tenant", "huge-tenant"]:
t = time.perf_counter()
client.query_points("kb_chunks", query=qvec, limit=10,
query_filter=models.Filter(must=[models.FieldCondition(key="tenant_id", match=models.MatchValue(value=tenant))]))
print(tenant, round((time.perf_counter() - t) * 1000, 1), "ms")
Also check recall per tenant against an exact search (search_params=models.SearchParams(exact=True)).
Memory cost
Payload indexes live in memory (or on disk with on_disk: true for keyword/integer indexes in recent versions). Index only fields you filter on. A text index on long chunk text can be large.
Decision table
| Finding | Fix |
|---|---|
Filter field missing from payload_schema | Create payload index |
| Many tenants, all queries per tenant | is_tenant: true; consider m=0 + payload_m |
| Index added after upload, still slow | Wait for/trigger optimisation |
| Type mismatch in filter | Fix ingestion types |
| Fewer results than limit | Raise hnsw_ef; check index exists |
Prevent it next time
- Define the payload schema and indexes in the same code that creates the collection.
- Validate payload types at ingestion.
- Benchmark filtered queries for the smallest and largest tenants before launch.
- Review
payload_schemawhenever a new filter is added to the application.