Filtering & Payload Indexing

This section documents the current Qdrant 1.x line (latest 1.19.1), written and verified against the official Qdrant documentation. No specific patch version is pinned. Some capabilities (Qdrant Cloud-only features and the full breadth of client-library APIs beyond Python and Java) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every point’s payload is arbitrary JSON, but it is also a filterable schema: declaring a payload index on a field lets Qdrant narrow a search to the points matching a boolean condition tree before (and during) the vector comparison, instead of scanning payload values one by one.

Payload

A point’s payload — introduced alongside the point itself on Collections & Points — is a free-form JSON object: strings, numbers, booleans, arrays, and nested objects, stored and returned with every point. Filtering treats that same JSON as a queryable schema: any payload field can appear in a filter condition, indexed or not, though an unindexed field is checked with a full scan of the field’s stored values rather than a lookup structure. See Payload for the complete set of supported value types and payload operations (set, overwrite, delete individual keys).

Payload indexes

A payload index is created explicitly, per field, and per value type — Qdrant does not infer an index from the values it sees. Four field schema types cover the condition types below:

Index type What it accelerates

keyword

Exact-match filtering on string values (and arrays of strings) — category codes, tags, status enums.

integer

Exact match and numeric range filtering on integer values.

geo

Location filtering with geo_radius and geo_bounding_box against a \{"lon", "lat"\} payload value.

text

Full-text, word-level matching (tokenized, so a match condition can test for a substring word rather than the whole field value).

PUT /collections/products/index
{
  "field_name": "category",
  "field_schema": "keyword"
}
PUT /collections/products/index
{
  "field_name": "price",
  "field_schema": "integer"
}
PUT /collections/products/index
{
  "field_name": "location",
  "field_schema": "geo"
}
PUT /collections/products/index
{
  "field_name": "description",
  "field_schema": "text"
}
from qdrant_client.models import PayloadSchemaType

client.create_payload_index(
    collection_name="products",
    field_name="category",
    field_schema=PayloadSchemaType.KEYWORD,
)

client.create_payload_index(
    collection_name="products",
    field_name="price",
    field_schema=PayloadSchemaType.INTEGER,
)

Full field-schema reference (including the numeric/UUID/datetime index variants beyond the four above): Filtering and Payload.

Filter clauses

A filter is a tree of three boolean clauses, each holding a list of conditions:

Clause Semantics

must

Every condition in the list has to match — logical AND.

should

At least one condition in the list has to match — logical OR.

must_not

No condition in the list may match — logical NOT, applied to the whole list.

The three can be combined in one filter (e.g. must narrows the set while should adds an either/or preference among a subset of fields), and clauses can nest to build arbitrary boolean expressions. Each condition itself is one of a few condition types:

  • match — exact value equality against a keyword or integer field (or "any of these values" via match.any).

  • range — numeric or date bounds (gt, gte, lt, lte) against an integer/float/datetime field.

  • geo — geo_radius (points within a distance of a center) or geo_bounding_box (points inside a lat/lon rectangle) against a geo field.

POST /collections/products/points/scroll
{
  "filter": {
    "must": [
      { "key": "category", "match": { "value": "audio" } },
      { "key": "price", "range": { "lte": 300 } }
    ],
    "should": [
      { "key": "brand", "match": { "any": ["acme", "globex"] } }
    ],
    "must_not": [
      { "key": "discontinued", "match": { "value": true } }
    ]
  },
  "limit": 10
}

A geo condition narrows to points whose location field falls inside a radius or a rectangle:

POST /collections/products/points/scroll
{
  "filter": {
    "must": [
      {
        "key": "location",
        "geo_radius": {
          "center": { "lon": 13.403, "lat": 52.520 },
          "radius": 5000.0
        }
      }
    ]
  },
  "limit": 10
}

geo_bounding_box takes top_left and bottom_right corners instead of a center and radius, for a rectangular region rather than a circle. Full condition and clause reference: Filtering.

from qdrant_client.models import (
    Filter, FieldCondition, MatchValue, MatchAny, Range,
    GeoRadius, GeoPoint, IsNullCondition, PayloadField,
)

results, _ = client.scroll(
    collection_name="products",
    scroll_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value="audio")),
            FieldCondition(key="price", range=Range(lte=300)),
        ],
        should=[
            FieldCondition(key="brand", match=MatchAny(any=["acme", "globex"])),
        ],
        must_not=[
            FieldCondition(key="discontinued", match=MatchValue(value=True)),
        ],
    ),
    limit=10,
)

Combining a filter with a vector query

Passing a filter alongside a query vector in the same search request does not run the vector search first and thin the results afterwards (a post-filter, which can return fewer than limit hits — or none — when the filter is highly selective). Qdrant’s HNSW graph is filterable: each node carries enough payload-index information that graph traversal itself skips neighbors the filter would exclude, so the search keeps exploring until it has limit matching points (or the true candidate set is exhausted), rather than collecting limit unfiltered neighbors and only then checking which ones pass. This is the same problem Elasticsearch’s filter inside knn and Solr’s fq pre-filter into \{!knn\} solve, each with its own mechanism — Dense vector search contrasts Solr’s implicit fq pre-filter, which restricts the HNSW traversal at the query-parser level, against Qdrant’s richer must/should/ must_not boolean model expressed as one clause inside the same search request.

POST /collections/products/points/query
{
  "query": [0.11, -0.95, 0.30, 0.42],
  "filter": {
    "must": [
      { "key": "category", "match": { "value": "audio" } },
      { "key": "price", "range": { "lte": 300 } }
    ]
  },
  "limit": 10
}
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

results = client.query_points(
    collection_name="products",
    query=[0.11, -0.95, 0.30, 0.42],
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value="audio")),
            FieldCondition(key="price", range=Range(lte=300)),
        ],
    ),
    limit=10,
).points

Because the filter is honored while the graph is walked, indexing the fields a filter regularly restricts on (via Payload indexes) matters even more than it does for a filter-only query: an unindexed field forces the filter check on each visited node to fall back to a direct payload lookup, which slows exactly the traversal the filterable HNSW graph is meant to keep fast.

Where to go next

  • Vector Indexing & HNSW — how the HNSW graph these filters run over is built and tuned.

  • Hybrid & Sparse Search — combining a filtered dense-vector query like the one above with a sparse-vector query in the same request.

  • Dense vector search — Solr’s fq implicit pre-filter combined with \{!knn\}, contrasted against Qdrant’s must/should/must_not filter model above.