Files
qdrant/tests/openapi/test_match_except_empty.py
qdrant-cloud-bot 99355603f9 Fix match: {except: []} returning zero results with payload index (#9055)
* Add integration test for `match: {except: []}` with integer index

Regression test for https://github.com/qdrant/qdrant/issues/9050

An empty `except` list (NOT IN []) should always match all points that
have the field, both with and without a payload index. Currently the
integer (and keyword) indexed path incorrectly returns zero results
because:

1. serde deserializes `except: []` as `AnyVariants::Strings([])` (first
   variant of the untagged enum)
2. The map index filter_impl returns `iter::empty()` for empty
   cross-type variant, instead of matching everything

The test covers:
- except: [] without any index (baseline, currently works)
- except: [] with an integer index (currently broken)
- except: [] with a keyword index (currently broken)

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix `match: {except: []}` returning zero results with payload index

Fixes https://github.com/qdrant/qdrant/issues/9050

Root cause: `except: []` deserializes as `AnyVariants::Strings([])`
due to the untagged serde enum trying `Strings` first. On an integer
index, the filter_impl matched `Except + Strings(empty)` and returned
`iter::empty()` (zero results). The same issue existed symmetrically
on keyword indexes with `Integers(empty)` and on UUID indexes with
`Integers(empty)`.

The fix: when the cross-type variant reaches the Except branch (e.g.
Strings on an integer index, Integers on a keyword/UUID index), return
`None` unconditionally — regardless of whether the set is empty. This
delegates to the fallback condition checker, which already handles
`except: []` correctly by matching all values.

The `estimate_cardinality_impl` functions had the same bug (returning
`CardinalityEstimation::exact(0)` for the empty cross-type case) and
are fixed the same way.

Affected index types: integer, keyword (str), UUID.
Bool index is NOT affected — it already returns `None` for all
Any/Except conditions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Expand match-except test to cover all index types and cross-type filters

Extends the regression test for #9050 to comprehensively cover:

- Integer, keyword, and UUID field indexes
- Empty except list (the original bug) for each index type
- Non-empty except list with matching types (normal filtering)
- Cross-type except values (e.g. strings on integer index, integers on
  keyword/UUID index) — type mismatch should exclude nothing → all match
- Exclude-all: listing every value should return zero results
- All scenarios tested both with and without a payload index

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <agent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 10:41:53 +02:00

306 lines
10 KiB
Python

import pytest
from .helpers.collection_setup import drop_collection
from .helpers.helpers import request_with_validation
COLLECTION_NAME = "test_match_except_empty"
@pytest.fixture(autouse=True, scope="module")
def setup():
create_collection(COLLECTION_NAME)
yield
drop_collection(collection_name=COLLECTION_NAME)
def create_collection(collection_name):
drop_collection(collection_name)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': collection_name},
body={
"vectors": {
"size": 2,
"distance": "Dot",
},
}
)
assert response.ok
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"points": [
{
"id": 1, "vector": [1.0, 0.0],
"payload": {
"n": 1,
"tag": "a",
"uid": "550e8400-e29b-41d4-a716-446655440001",
},
},
{
"id": 2, "vector": [0.0, 1.0],
"payload": {
"n": 2,
"tag": "b",
"uid": "550e8400-e29b-41d4-a716-446655440002",
},
},
{
"id": 3, "vector": [1.0, 1.0],
"payload": {
"n": 3,
"tag": "c",
"uid": "550e8400-e29b-41d4-a716-446655440003",
},
},
]
}
)
assert response.ok
def _scroll_ids(collection_name, filter_body):
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={
"filter": filter_body,
"limit": 10,
"with_payload": True,
}
)
assert response.ok
return sorted(p['id'] for p in response.json()['result']['points'])
def _create_index(collection_name, field_name, field_schema):
response = request_with_validation(
api='/collections/{collection_name}/index',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={
"field_name": field_name,
"field_schema": field_schema,
}
)
assert response.ok
# ---------------------------------------------------------------------------
# 1. Empty except list — no index (baseline)
# ---------------------------------------------------------------------------
def test_match_except_empty_integer_no_index():
"""except: [] without index should return all points that have the field."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": []}}]
})
assert ids == [1, 2, 3]
def test_match_except_empty_keyword_no_index():
"""except: [] without index should return all points that have the field."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": []}}]
})
assert ids == [1, 2, 3]
def test_match_except_empty_uuid_no_index():
"""except: [] without index should return all points that have the field."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": []}}]
})
assert ids == [1, 2, 3]
# ---------------------------------------------------------------------------
# 2. Non-empty except list with matching type — no index (baseline)
# ---------------------------------------------------------------------------
def test_match_except_integers_no_index():
"""except: [1] should exclude point 1."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": [1]}}]
})
assert ids == [2, 3]
def test_match_except_strings_no_index():
"""except: ["a"] should exclude point 1."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": ["a"]}}]
})
assert ids == [2, 3]
def test_match_except_uuids_no_index():
"""except one UUID should exclude that point."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": [
"550e8400-e29b-41d4-a716-446655440001"
]}}]
})
assert ids == [2, 3]
# ---------------------------------------------------------------------------
# 3. Create indexes for all three field types.
# Tests below run after index creation and verify indexed path matches
# the non-indexed semantics.
# ---------------------------------------------------------------------------
def test_create_indexes():
_create_index(COLLECTION_NAME, "n", "integer")
_create_index(COLLECTION_NAME, "tag", "keyword")
_create_index(COLLECTION_NAME, "uid", "uuid")
# ---------------------------------------------------------------------------
# 4. Empty except list — with index (the original bug from #9050)
# ---------------------------------------------------------------------------
def test_match_except_empty_integer_with_index():
"""
Bug: https://github.com/qdrant/qdrant/issues/9050
After creating an integer payload index, `match: {"except": []}` must
still return all points — same as the non-indexed path. An empty except
list means "exclude nothing", i.e. match everything.
"""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": []}}]
})
assert ids == [1, 2, 3], (
f"match except [] with integer index should return all points, got {ids}"
)
def test_match_except_empty_keyword_with_index():
"""Same check for keyword index."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": []}}]
})
assert ids == [1, 2, 3], (
f"match except [] with keyword index should return all points, got {ids}"
)
def test_match_except_empty_uuid_with_index():
"""Same check for UUID index."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": []}}]
})
assert ids == [1, 2, 3], (
f"match except [] with UUID index should return all points, got {ids}"
)
# ---------------------------------------------------------------------------
# 5. Non-empty except list with matching type — with index
# ---------------------------------------------------------------------------
def test_match_except_integers_with_index():
"""except: [1] with integer index should exclude point 1."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": [1]}}]
})
assert ids == [2, 3]
def test_match_except_strings_with_index():
"""except: ["a"] with keyword index should exclude point 1."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": ["a"]}}]
})
assert ids == [2, 3]
def test_match_except_uuids_with_index():
"""except one UUID with UUID index should exclude that point."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": [
"550e8400-e29b-41d4-a716-446655440001"
]}}]
})
assert ids == [2, 3]
# ---------------------------------------------------------------------------
# 6. Cross-type except: string values on integer index, integer values on
# keyword/UUID index. These are type mismatches, so no value in the index
# actually matches the exclusion list — meaning nothing is excluded and
# all points should be returned (same as except: []).
# ---------------------------------------------------------------------------
def test_match_except_strings_on_integer_index():
"""String except values on an integer-indexed field: type mismatch → match all."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": ["foo", "bar"]}}]
})
assert ids == [1, 2, 3], (
f"cross-type string except on integer index should return all points, got {ids}"
)
def test_match_except_integers_on_keyword_index():
"""Integer except values on a keyword-indexed field: type mismatch → match all."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": [999, 888]}}]
})
assert ids == [1, 2, 3], (
f"cross-type integer except on keyword index should return all points, got {ids}"
)
def test_match_except_integers_on_uuid_index():
"""Integer except values on a UUID-indexed field: type mismatch → match all."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": [999, 888]}}]
})
assert ids == [1, 2, 3], (
f"cross-type integer except on UUID index should return all points, got {ids}"
)
# ---------------------------------------------------------------------------
# 7. Exclude-all: when every value in the field is listed in except,
# no points should match.
# ---------------------------------------------------------------------------
def test_match_except_all_integers_with_index():
"""Excluding every integer value should return no points."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "n", "match": {"except": [1, 2, 3]}}]
})
assert ids == []
def test_match_except_all_strings_with_index():
"""Excluding every keyword value should return no points."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "tag", "match": {"except": ["a", "b", "c"]}}]
})
assert ids == []
def test_match_except_all_uuids_with_index():
"""Excluding every UUID value should return no points."""
ids = _scroll_ids(COLLECTION_NAME, {
"must": [{"key": "uid", "match": {"except": [
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002",
"550e8400-e29b-41d4-a716-446655440003",
]}}]
})
assert ids == []