Files
qdrant-client/tests/congruence_tests/test_scroll.py
Madan kumar 532997a781 Fix local mode filters cross-matching booleans and integers (#1259)
* Fix local mode filters cross-matching booleans and integers

Python treats bool as a subclass of int (True == 1, False == 0), but Qdrant
keeps booleans and integers as distinct payload value types. Local mode
compared them with a plain `==` / `in` / `isinstance(value, (int, float))`, so:

- MatchValue(value=1) matched a payload of True, and MatchValue(value=True)
  matched a payload of 1 (same for 0 / False)
- MatchAny / MatchExcept cross-matched the same way
- Range matched booleans as if they were 0 / 1

The server never cross-matches these (its ValueVariants keeps Integer and Bool
distinct, and booleans are not numeric for range conditions). Add a type-aware
equality helper used by the value-match conditions, and exclude booleans from
range checks. Adds an in-memory regression test.

* Cover MatchExcept in the bool/int cross-match test

MatchExcept also routes through values_match, so assert that except=[1]
keeps the True payload (bool is not the integer 1).

* Add isolated MatchAny and range asserts to the bool/int cross-match test

Lock the single-value MatchAny path and the check_range bool guard against
regressions, in addition to the existing combined-condition coverage.

* fix: handle floats in cross-match local mode filters, add congruence tests

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2026-07-22 02:17:20 +07:00

173 lines
5.7 KiB
Python

import random
import pytest
from qdrant_client import QdrantClient
from qdrant_client.client_base import QdrantBase
from qdrant_client.http.models import models
from tests.congruence_tests.test_common import (
COLLECTION_NAME,
compare_client_results,
generate_fixtures,
generate_sparse_fixtures,
init_client,
init_local,
init_remote,
sparse_vectors_config,
)
class TestSimpleScroller:
@classmethod
def scroll_all(cls, client: QdrantBase) -> list[models.Record]:
all_records = []
records, next_page = client.scroll(
collection_name=COLLECTION_NAME,
limit=10,
with_payload=True,
)
all_records.extend(records)
while next_page:
records, next_page = client.scroll(
collection_name=COLLECTION_NAME,
limit=20,
offset=next_page,
with_payload=True,
)
all_records.extend(records)
return all_records
def test_simple_search() -> None:
fixture_points = generate_fixtures(200)
scroller = TestSimpleScroller()
local_client = init_local()
init_client(local_client, fixture_points)
remote_client = init_remote()
init_client(remote_client, fixture_points)
compare_client_results(local_client, remote_client, scroller.scroll_all)
def test_simple_sparse_scroll() -> None:
fixture_points = generate_sparse_fixtures(200)
local_client = init_local()
init_client(local_client, fixture_points, sparse_vectors_config=sparse_vectors_config)
remote_client = init_remote()
init_client(remote_client, fixture_points, sparse_vectors_config=sparse_vectors_config)
scroller = TestSimpleScroller()
compare_client_results(local_client, remote_client, scroller.scroll_all)
def test_mixed_ids() -> None:
fixture_points = generate_fixtures(100, random_ids=True) + generate_fixtures(
100, random_ids=False
)
random.shuffle(fixture_points)
scroller = TestSimpleScroller()
local_client = init_local()
init_client(local_client, fixture_points)
remote_client = init_remote()
init_client(remote_client, fixture_points)
compare_client_results(local_client, remote_client, scroller.scroll_all)
def test_sparse_mixed_ids() -> None:
fixture_points = generate_sparse_fixtures(100, random_ids=True) + generate_sparse_fixtures(
100, random_ids=False
)
random.shuffle(fixture_points)
scroller = TestSimpleScroller()
local_client = init_local()
init_client(local_client, fixture_points, sparse_vectors_config=sparse_vectors_config)
remote_client = init_remote()
init_client(remote_client, fixture_points, sparse_vectors_config=sparse_vectors_config)
compare_client_results(local_client, remote_client, scroller.scroll_all)
def _init_value_type_collection(client: QdrantBase) -> None:
if client.collection_exists(COLLECTION_NAME):
client.delete_collection(COLLECTION_NAME)
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(size=2, distance=models.Distance.COSINE),
)
client.upsert(
collection_name=COLLECTION_NAME,
points=[
models.PointStruct(id=1, vector=[0.1, 0.2], payload={"n": 1}),
models.PointStruct(id=2, vector=[0.1, 0.2], payload={"n": True}),
models.PointStruct(id=3, vector=[0.1, 0.2], payload={"n": 0}),
models.PointStruct(id=4, vector=[0.1, 0.2], payload={"n": False}),
models.PointStruct(id=5, vector=[0.1, 0.2], payload={"n": 1.0}),
models.PointStruct(id=6, vector=[0.1, 0.2], payload={"n": 0.0}),
],
wait=True,
)
def _scroll_value_type(client: QdrantBase, scroll_filter: models.Filter) -> list[models.Record]:
records, _next_page = client.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=scroll_filter,
limit=10,
with_payload=True,
)
return records
@pytest.mark.parametrize("prefer_grpc", [True, False])
def test_bool_int_float_filters_do_not_cross_match(prefer_grpc) -> None:
"""Python cross-matches bool / int / float (bool is a subclass of int, and
1 == 1.0), but Qdrant keeps them as distinct payload value types for exact
match. Local mode must not cross-match them, matching the remote server
behaviour. Match condition operands can only be bool / int / str (never float),
so a float payload value is never matched by an exact-match condition.
"""
local_client: QdrantClient = init_local()
_init_value_type_collection(local_client)
remote_client: QdrantClient = init_remote(prefer_grpc=prefer_grpc)
_init_value_type_collection(remote_client)
filters = [
models.Filter(must=[models.FieldCondition(key="n", match=models.MatchValue(value=1))]),
models.Filter(must=[models.FieldCondition(key="n", match=models.MatchValue(value=True))]),
models.Filter(must=[models.FieldCondition(key="n", match=models.MatchValue(value=0))]),
models.Filter(must=[models.FieldCondition(key="n", match=models.MatchValue(value=False))]),
models.Filter(must=[models.FieldCondition(key="n", match=models.MatchAny(any=[1, 0]))]),
models.Filter(
must=[models.FieldCondition(key="n", match=models.MatchExcept(**{"except": [1, 0]}))]
),
models.Filter(must=[models.FieldCondition(key="n", range=models.Range(gte=1, lte=1))]),
models.Filter(must=[models.FieldCondition(key="n", range=models.Range(gte=0, lte=1))]),
]
for scroll_filter in filters:
compare_client_results(
local_client,
remote_client,
_scroll_value_type,
scroll_filter=scroll_filter,
)