skip to content

In Qdrant, why can two must conditions on an array payload match the wrong points?

level: middleimportance: should knowfreq 34%

answer

  1. arrays mean any element
  2. each condition, independently
  3. two conditions, two different elements
  4. bind them to one element
  5. index the full bracketed path

basics

~20 s

Each condition on an array field is evaluated independently and matches if any element satisfies it, so two conditions can be satisfied by two different elements. To require one element to satisfy both, wrap them in models.NestedCondition with models.Nested.

solid answer

~40 s

Qdrant evaluates every condition over an array payload with "any element matches" semantics, and it does so per condition. Given `items: [{"name": "a", "price": 5}, {"name": "b", "price": 50}]`, a filter with `must=[items[].name == "a", items[].price > 10]` matches the point: the first element satisfies the name condition, the second satisfies the price condition, and nothing ties them together. If your intent was "an item named a costing more than 10", that is a false positive. The fix is `models.NestedCondition(nested=models.Nested(key="items", filter=Filter(must=[...])))`, which applies the inner filter to a single array element at a time and matches only if some one element satisfies all of it. Note the inner conditions use keys relative to the array element. Payload indexes for these fields are created on the full path, `items[].price`.

code

python · 9 lines
python
from qdrant_client import models

# Matches if ANY item is named "widget" AND ANY item costs > 10 - possibly different items
loose = models.Filter(
    must=[
        models.FieldCondition(key="items[].name", match=models.MatchValue(value="widget")),
        models.FieldCondition(key="items[].price", range=models.Range(gt=10)),
    ]
)

go deeper

for a junior

Remember that a condition on an array payload matches when any element matches, and that this is per condition rather than per element.

for a middle

Explain the cross-element false positive concretely and write the NestedCondition fix, including that the inner filter's keys are relative to the element.

for a senior

Catch this in review before it ships, verify it with an exact count on both filter shapes, and make sure the nested paths carry their own payload indexes.

for a principal

Decide the data model: parent-with-array plus nested filters versus one point per element with a parent id, based on whether the application needs to know which element matched.

## Array semantics When a payload field holds an array, Qdrant's conditions mean "some element of this array satisfies it". That is usually the behaviour you want: `tags: ["ml", "db"]` matching `MatchValue(value="db")` needs no special syntax, and `must_not` over the same field means no element matches. The subtlety appears only for **arrays of objects**, where you write conditions against several fields of the same logical record. ## The false positive Consider a product point: ``` payload = {"items": [{"name": "widget", "price": 5}, {"name": "gadget", "price": 50}]} ``` and the filter `must=[FieldCondition(key="items[].name", match=MatchValue(value="widget")), FieldCondition(key="items[].price", range=Range(gt=10))]`. Each condition is resolved against the whole array separately. The name condition is satisfied by element 0. The price condition is satisfied by element 1. Both clauses pass, so the point matches — even though no single item is a widget costing more than 10. This is not a bug; it is the documented meaning of an unqualified condition on an array. It is simply almost never what the author intended, and it fails silently: the result set is too *wide*, so nobody notices until someone checks a specific result. ## NestedCondition `models.NestedCondition` binds conditions to the same element: ``` models.NestedCondition( nested=models.Nested(key="items", filter=models.Filter(must=[...])) ) ``` The `key` names the array. The inner `Filter` is evaluated **against one element at a time**, with keys written relative to that element (`name`, `price` — not `items[].name`). The point matches when at least one element satisfies the entire inner filter. `NestedCondition` is itself a condition, so it drops into `must`, `should` or `must_not` of an outer filter alongside ordinary conditions on top-level fields. A practical consequence of the "at least one element" semantics: putting a `NestedCondition` inside `must_not` means "no element satisfies the inner filter", which is a genuinely different statement from "some element fails it". Keep the inner filter to field conditions on the element's own keys and reason about the outer clause separately. ## Indexing nested paths Payload indexes follow paths literally. To index a field inside an array of objects you create the index on the full path with the array marker: `field_name="items[].price"`. Indexing `items` alone does nothing for conditions on its members, and a `NestedCondition` benefits from those inner-path indexes the same way a top-level condition benefits from a top-level index. This is a common omission — the nested filter is correct, the query is slow, and the payload schema shows no index for the nested path. ## Modelling alternative Sometimes the right answer is not a nested filter at all. If you routinely need to retrieve "the matching item", not "the parent that has one", the array element is really your unit of retrieval — store one point per item with the parent id in the payload. Nested filters answer "which parents qualify"; they never tell you *which* element matched, and there is no way to get that from the response. Choosing between the two shapes up front avoids a class of awkward post-processing. ## Quick check Before trusting a filter over an array of objects, run it through `count(count_filter=..., exact=True)` with and without the nesting. If the counts differ, the unnested version was matching across elements and you have just measured your false-positive rate.

  • How do you create a payload index for a field inside an array of objects?
    Index the full path including the array marker: `create_payload_index(collection_name=..., field_name="items[].price", field_schema=models.PayloadSchemaType.FLOAT)`. Indexing the array field itself does not help conditions on its members. Check `get_collection(...).payload_schema` afterwards to confirm the bracketed path is what got registered.
  • Does a nested filter tell you which array element matched?
    No. The response is a scored point with its payload; there is no indication of which element satisfied the inner filter, and no way to request one. If your application needs the matching element, model each element as its own point with a parent id in the payload, and treat the parent-level query as a separate lookup.
  • What does a NestedCondition inside must_not mean?
    It means no element of the array satisfies the inner filter. That is stronger than "some element fails it" — a point with one matching and one non-matching element is excluded. Reason about the inner filter as a per-element predicate first, then apply the outer clause's quantifier over elements, or the negation will surprise you.

Asking for a household containing a person named Ana and a person over 40 is not the same as asking for a household containing an Ana who is over 40.

saying these in an interview costs you the question

  • Assuming two conditions on an array bind to the same element
  • Writing the parent path inside a nested filter's conditions
  • Indexing the array field instead of the bracketed inner path
  • Expecting the response to say which array element matched
  • Treating the cross-element match as a Qdrant bug

context