Home / AI & GenAI / Vector Databases

AI & GenAITroubleshootingAdvanced

Qdrant filtered searches are slow: payload indexes, tenant indexes and making filters part of the HNSW graph

Unfiltered Qdrant searches take a few milliseconds but the same search with a tenant or category filter takes hundreds, or returns fewer results than expected. Add the right payload indexes, mark tenant fields, and understand how Qdrant plans filtered searches.

Deepak Bhardwaj5 min read

Applies toQdrant 1.11+ (tenant and principal index options), self-hosted or Qdrant Cloud, REST and Python client

Symptoms

  • query with a filter is 10–100× slower than without.
  • Latency depends heavily on which tenant or category is filtered.
  • Some filtered searches return fewer than limit results.
  • CPU on Qdrant nodes rises sharply when a filtered feature launches.

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_schema whenever a new filter is added to the application.