fix: local mode text/phrase and is-null semantics diverge from server — CI congruence failures investigated (#1394)

* fix: mirror server token-aware text/phrase matching on unindexed fields

qdrant/qdrant#10341 (dev) changed MatchText and MatchPhrase on fields
without a text index from a substring scan to token-aware matching via
the default word tokenizer: every query token must appear as a whole
document token (text, order-independent; consecutive for phrase), empty
queries match nothing. Local mode still substring-scanned, so congruence
tests randomly failed whenever the filter generator drew a MatchText
whose word is a substring of another fixture word ("fly" in "butterfly",
"ant" in "elephant"). MatchTextAny keeps substring semantics, matching
the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* fix: match null elements inside arrays in local IsNull condition

qdrant/qdrant#10101 (dev) made the unindexed IsNull check inspect array
elements: a value like [null, 1] now satisfies IsNull (one level deep).
Local mode only matched values that were null themselves. This was the
second divergence behind the congruence CI failures, previously masked
by the MatchText one because pytest runs with -x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* fix: close local client before reopening storage in persistence tests

The persistence tests released the storage lock with `del local_client`,
relying on garbage collection timing; when the lock outlived the del,
reopening the same directory raised "Storage folder is already accessed
by another instance". test_query.py was already fixed to call close()
(90913f8); apply the same fix to the remaining five persistence tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* fix: bound remote group hits by exact local hits instead of equality

Server-side grouping is best-effort within a request budget (qdrant
lib/shard/src/grouping/driver.rs): once the budget is spent, a group may
be filled with worse points than its true best, or stay below
group_size. Local mode groups exhaustively, so asserting exact per-rank
score equality of deep group hits randomly failed when the fill budget
missed a group member (test_query_group, local 0.6926 vs remote 0.6798
at rank 4). Compare one-sided instead: at any rank the remote hit may be
worse than the exact local one, never better; the top hit stays strict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* test: move local text-match and is-null tests to their conventional homes

The two new test files sat at the tests/ root. Local-mode behavior belongs in
qdrant_client/local/tests, and filter corner cases in
tests/congruence_tests/test_complex_filters.py.

- the check_match assertions mirroring the server's unindexed_text_match_test.rs
  move into qdrant_client/local/tests/test_payload_filters.py, next to the other
  filter unit tests
- the client-level cases become congruence tests in test_complex_filters.py, so
  they compare local against a real server instead of asserting local behavior
  alone: text/phrase/text-any matching on an unindexed field, and IsNull over
  arrays holding a null

Both congruence tests fail against the pre-fix payload_filters and pass with it,
against qdrant 1.19.1-dev.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* tests: add non-consecutive case for match filter

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
This commit is contained in:
Andrey Vasnetsov
2026-09-02 15:59:55 +07:00
committed by GitHub
co-authored by Claude Opus 5 George Panchuk
parent a50a16a18c
commit 2cb9c7626f
8 changed files with 222 additions and 15 deletions
+34 -7
View File
@@ -1,3 +1,4 @@
import re
from datetime import date, datetime, timezone
from typing import Any
from uuid import UUID
@@ -162,19 +163,41 @@ def values_match(value: Any, other: Any) -> bool:
return value == other
def unindexed_text_tokens(text: str) -> list[str]:
# Mirrors the server's default word tokenizer, used for text and phrase filters on
# fields without a text index: split on non-alphanumeric characters, lowercase.
return [token.lower() for token in re.findall(r"[^\W_]+", text)]
def check_match(condition: models.Match, value: Any) -> bool:
if isinstance(condition, models.MatchValue):
return values_match(value, condition.value)
if isinstance(condition, models.MatchText):
return isinstance(value, str) and condition.text in value
# On a field without a text index the server tokenizes both sides with the default
# word tokenizer and requires every query token to be a whole document token,
# order-independent (qdrant#10341). An empty query never matches. A text index may
# use a different tokenizer, which local mode cannot reproduce since it builds no
# indexes.
if not isinstance(value, str):
return False
query_tokens = unindexed_text_tokens(condition.text)
document_tokens = set(unindexed_text_tokens(value))
return bool(query_tokens) and all(token in document_tokens for token in query_tokens)
if isinstance(condition, models.MatchTextAny):
# Unlike text/phrase, the server still resolves this with a substring scan on
# unindexed fields.
return isinstance(value, str) and any(word in value for word in condition.text_any.split())
if isinstance(condition, models.MatchPhrase):
# Same approximation as `MatchText` above: on a field without a text index the server
# falls back to a substring scan, which this reproduces exactly. A phrase-enabled text
# index makes the server tokenize and lowercase instead, and local mode builds no
# indexes, so it cannot reproduce that.
return isinstance(value, str) and condition.phrase in value
# Like `MatchText`, but the query tokens must appear consecutively in document
# token order (qdrant#10341).
if not isinstance(value, str):
return False
phrase_tokens = unindexed_text_tokens(condition.phrase)
value_tokens = unindexed_text_tokens(value)
return bool(phrase_tokens) and any(
value_tokens[i : i + len(phrase_tokens)] == phrase_tokens
for i in range(len(value_tokens) - len(phrase_tokens) + 1)
)
if isinstance(condition, models.MatchPrefix):
# byte-wise and case-sensitive, like exact keyword matching. Non-string values never
# match, not even against an empty prefix.
@@ -225,7 +248,11 @@ def check_condition(
values = value_by_key(payload, condition.is_null.key, flat=False)
if values is None:
return False
if any(v is None for v in values):
# A value is null if it is null itself, or is an array containing a null
# element, one level deep (qdrant#10101).
if any(
v is None or (isinstance(v, list) and any(e is None for e in v)) for v in values
):
return True
elif isinstance(condition, models.IsEmptyCondition):
values = value_by_key(payload, condition.is_empty.key, flat=False)
@@ -1,5 +1,5 @@
from qdrant_client.http.models import models
from qdrant_client.local.payload_filters import check_filter
from qdrant_client.local.payload_filters import check_filter, check_match
def test_nested_payload_filters():
@@ -187,3 +187,51 @@ def test_geo_polygon_filter_query():
res = check_filter(query, payload, 0, has_vector={})
assert res is False
def text(query: str) -> models.MatchText:
return models.MatchText(text=query)
def phrase(query: str) -> models.MatchPhrase:
return models.MatchPhrase(phrase=query)
def test_text_match_uses_token_matching_not_substring():
"""On a field without a text index the server matches whole tokens, not substrings
(qdrant#10341). Cases mirror the server's own `unindexed_text_match_test.rs`.
"""
assert not check_match(text("good"), "goodness only")
assert check_match(text("good"), "good cheap stuff")
assert check_match(text("good cheap"), "cheap hardware good")
assert not check_match(text("good cheap"), "cheap hardware")
# tokenization: split on non-alphanumeric, lowercase
assert check_match(text("FLY"), "fly agaric")
assert check_match(text("fly"), "come fly, with me")
assert not check_match(text("fly"), "butterfly dragonfly")
assert not check_match(text(""), "anything")
assert not check_match(text("fly"), 7)
def test_phrase_match_requires_token_order():
assert check_match(phrase("alpha beta"), "foo alpha beta bar")
assert not check_match(phrase("alpha beta"), "beta alpha")
assert not check_match(phrase("alpha beta"), "alphabeta")
# consecutive, not merely in order: an ordered subsequence is not a phrase
assert not check_match(phrase("alpha beta"), "alpha x beta")
assert not check_match(phrase("good"), "goodness only")
assert check_match(phrase("good"), "goodness only good")
assert check_match(phrase("Alpha, Beta!"), "alpha beta")
assert not check_match(phrase(""), "anything")
assert not check_match(phrase("alpha"), None)
def test_text_any_match_keeps_substring_semantics():
"""Unlike text and phrase, the server still resolves `MatchTextAny` on an unindexed
field with a substring scan.
"""
assert check_match(models.MatchTextAny(text_any="good fly"), "goodness only")
assert check_match(models.MatchTextAny(text_any="fly"), "butterfly")
assert not check_match(models.MatchTextAny(text_any="cheap"), "goodness only")
+33 -1
View File
@@ -257,6 +257,38 @@ def compare_scored_record(
compare_vectors(point1.vector, point2.vector, idx)
def compare_group_hits(hits_1: list, hits_2: list, rel_tol: float = 1e-4) -> None:
"""Compare the hits of the same group between the exact (local) client and a
remote one.
Server-side grouping is best-effort within a request budget (see qdrant's
lib/shard/src/grouping/driver.rs): once the budget is spent, a group may be
filled with worse points than its true best, or stay below group_size. The
exact local hits therefore bound the remote ones: at any rank the remote hit
may be worse, but never better, and the remote group may not be larger.
"""
compare_scored_record(hits_1[0], hits_2[0], 0)
assert len(hits_2) <= len(
hits_1
), f"len(hits_1) = {len(hits_1)}, len(hits_2) = {len(hits_2)}"
# infer score ordering from the exact side
larger_is_better = hits_1[0].score >= hits_1[-1].score
for i in range(1, len(hits_2)):
margin = max(abs(hits_1[i].score) * rel_tol, 1e-6)
if larger_is_better:
assert hits_2[i].score <= hits_1[i].score + margin, (
f"hits_2[{i}].score = {hits_2[i].score} is better than exact "
f"hits_1[{i}].score = {hits_1[i].score}"
)
else:
assert hits_2[i].score >= hits_1[i].score - margin, (
f"hits_2[{i}].score = {hits_2[i].score} is better than exact "
f"hits_1[{i}].score = {hits_1[i].score}"
)
def compare_records(res1: list, res2: list, rel_tol: float = 1e-4, abs_tol: float = 1e-6) -> None:
assert len(res1) == len(res2), f"len(res1) = {len(res1)}, len(res2) = {len(res2)}"
for i in range(len(res2)):
@@ -369,7 +401,7 @@ def compare_client_results(
# ), f"groups_1[{i}].id = {group_1.id}, groups_2[{i}].id = {group_2.id}"
if group_1.id == group_2.id:
compare_records(group_1.hits, group_2.hits)
compare_group_hits(group_1.hits, group_2.hits)
else:
# If group ids are different, but scores are the same, we assume that the top hits are the same
compare_scored_record(group_1.hits[0], group_2.hits[0], 0)
@@ -348,3 +348,103 @@ def test_nested_filter_payload_shapes(key: str):
scroll_with_filter,
scroll_filter=nested_filter,
)
@pytest.mark.parametrize(
"match",
[
models.MatchText(text="fly"),
models.MatchText(text="FLY"),
models.MatchText(text="good cheap"),
models.MatchPhrase(phrase="alpha beta"),
models.MatchPhrase(phrase="Alpha, Beta!"),
models.MatchPhrase(phrase="good"),
models.MatchTextAny(text_any="good fly"),
],
ids=[
"text_word",
"text_uppercase",
"text_two_words",
"phrase_two_words",
"phrase_punctuated",
"phrase_one_word",
"text_any",
],
)
def test_text_match_on_unindexed_field(match: models.Match):
"""On a field without a text index the server tokenizes both sides with the default word
tokenizer - split on non-alphanumeric, lowercased - and matches whole tokens rather than
substrings, so "fly" does not match "butterfly". `MatchText` accepts the query tokens in
any order, `MatchPhrase` only consecutively, and `MatchTextAny` is the exception which
still scans for substrings.
"""
values = [
"goodness only", # substring of the query, not a token
"good cheap stuff",
"cheap hardware good", # query tokens present, reversed
"cheap hardware", # only one of two query tokens
"fly agaric",
"come fly, with me", # token followed by punctuation
"butterfly dragonfly", # substrings only
"foo alpha beta bar",
"beta alpha",
"alpha x beta", # in order but not consecutive
"alphabeta", # a single token, not two
"goodness only good",
7, # a non-string value has no tokens
]
fixture_points = generate_fixtures(num=len(values) + 1)
for point, value in zip(fixture_points, values):
point.payload = {"words": value}
fixture_points[-1].payload = {"other": 1} # the key absent altogether
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,
scroll_with_filter,
scroll_filter=models.Filter(must=[models.FieldCondition(key="words", match=match)]),
)
@pytest.mark.parametrize("key", ["a", "nested[].empty"])
def test_is_null_matches_null_inside_arrays(key: str):
"""A value counts as null when it is null itself or is an array holding a null element,
one level deep - so `[null, 1]` matches while `[[null]]` does not.
"""
payloads = [
{"a": [None, 1]},
{"a": [1, None]},
{"a": [1, 2]},
{"a": None},
{"a": [[None]]}, # the null is one level too deep
{"a": []},
{"nested": [{"empty": [None]}, {"empty": [None]}]},
{"nested": [{"empty": 1}]},
{"other": 1}, # the key absent altogether
]
fixture_points = generate_fixtures(num=len(payloads))
for point, payload in zip(fixture_points, payloads):
point.payload = payload
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,
scroll_with_filter,
scroll_filter=models.Filter(
must=[models.IsNullCondition(is_null=models.PayloadField(key=key))]
),
)
+1 -1
View File
@@ -351,7 +351,7 @@ def test_search_with_persistence():
payload_update_filter = one_random_filter_please()
local_client.set_payload(COLLECTION_NAME, {"test": f"test"}, payload_update_filter)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
remote_client = init_remote()
+2 -2
View File
@@ -263,7 +263,7 @@ def test_search_with_persistence():
payload_update_filter = one_random_filter_please()
local_client.set_payload(COLLECTION_NAME, {"test": f"test"}, payload_update_filter)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
remote_client = init_remote()
@@ -302,7 +302,7 @@ def test_search_with_persistence_and_skipped_vectors():
local_client.set_payload(COLLECTION_NAME, {"test": f"test"}, payload_update_filter)
count_before_load = local_client.count(COLLECTION_NAME)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
count_after_load = local_client_2.count(COLLECTION_NAME)
@@ -292,7 +292,7 @@ def test_search_with_persistence():
vectors_config={},
)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
remote_client = init_remote()
+2 -2
View File
@@ -251,7 +251,7 @@ def test_search_with_persistence():
payload_update_filter = one_random_filter_please()
local_client.set_payload(COLLECTION_NAME, {"test": f"test"}, payload_update_filter)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
remote_client = init_remote()
@@ -290,7 +290,7 @@ def test_search_with_persistence_and_skipped_vectors():
local_client.set_payload(COLLECTION_NAME, {"test": f"test"}, payload_update_filter)
count_before_load = local_client.count(COLLECTION_NAME)
del local_client
local_client.close()
local_client_2 = init_local(tmpdir)
count_after_load = local_client_2.count(COLLECTION_NAME)