mirror of
https://github.com/qdrant/qdrant-client.git
synced 2026-07-29 14:11:34 -05:00
* 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>
173 lines
5.7 KiB
Python
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,
|
|
)
|