Skip to content

Python: Match float and bool filter values in QdrantCollection - #14777

Open
Harshil Kaneria (Harshil26202) wants to merge 3 commits into
microsoft:mainfrom
Harshil26202:python-qdrant-numeric-and-bool-filter-values
Open

Harshil Kaneria (Harshil26202) wants to merge 3 commits into
microsoft:mainfrom
Harshil26202:python-qdrant-numeric-and-bool-filter-values

Conversation

@Harshil26202

@Harshil26202 Harshil Kaneria (Harshil26202) commented Nov 10, 2026 •

Copy link
Copy Markdown

Motivation and Context

QdrantCollection._lambda_parser builds MatchValue for == and !=, and MatchAny for in and not in. In qdrant-client, MatchValue only accepts str, int and bool, and MatchAny only accepts a list of str or a list of int. So common filters on float and bool fields fail before the query is sent:

await collection.search(vector=[1, 1], filter="lambda x: x.price == 4.5")
# VectorSearchExecutionException: ... validation errors for MatchValue

await collection.search(vector=[1, 1], filter="lambda x: x.in_stock in [True]")
# VectorSearchExecutionException: ... validation errors for MatchAny

There is a quieter case as well. An int value never matches a float payload in Qdrant, so lambda x: x.price == 5 returns nothing for a record stored with price=5.0. InMemoryCollection returns that record, as Python would.

Description

  • == and != with a number (not a bool) now use a closed range, Range(gte=value, lte=value). Qdrant compares ranges numerically, so this works for float values and matches int and float payloads alike. Ints with abs(value) >= 2**53 keep MatchValue, because the server compares ranges as float64, where 2**53 and 2**53 + 1 are the same value. Floats of any size use the range.
  • in and not in keep MatchAny for lists of strings. Other lists become a should of the single-value conditions above, so float and bool lists work. Mixed int/float lists also match numerically.
  • Strings and bools in ==/!= still use MatchValue.

Tests: test_local_filter_with_float_and_bool_values in tests/unit/connectors/memory/test_qdrant_local.py runs nine filters against a local :memory: Qdrant collection, including a large float (1e20). All of them fail on main and pass with this change. test_match_value_uses_range_only_where_float64_is_exact pins the int cutoff, since local mode compares exact Python ints and can't show the server's float64 rounding. I also compared about 600 random nested filters over int, float, bool and string fields between QdrantCollection(location=":memory:") and InMemoryCollection, and the results now match. The only remaining errors were chained comparisons with the constant on the left (0 <= x.num < 5), which is a separate issue and not part of this PR.

Locally, tests/unit/data plus the in-memory, Qdrant, Qdrant local, Chroma and FAISS connector tests pass (263 passed). ruff check and ruff format --check are clean. mypy reports one error in qdrant.py at the delete call (line 281), which is also there on main.

AI disclosure: I found this with Claude Code while comparing filter results across the Python vector store connectors. It also drafted the fix and the tests.

Contribution Checklist

The Qdrant filter parser built MatchValue for == and != and MatchAny for
in and not in. MatchValue only accepts str, int and bool, MatchAny only
a list of str or int, so filters such as 'x.price == 4.5' or
'x.in_stock in [True]' raised a pydantic ValidationError. An int value
also never matched a float payload, so 'x.price == 5' missed 5.0.

Match numbers with a closed Range, which Qdrant compares numerically,
and keep MatchValue for ints above 2**53 because ranges are compared as
float64. Keep MatchAny for lists of strings and expand other lists into
a should of single-value matches.
Copilot AI balanced review requested due to automatic review settings November 10, 2026 14:54
@semantic-kernel-automation semantic-kernel-automation Bot added the python Pull requests for the Python Semantic Kernel label Nov 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The numeric filtering helper mishandles the 2**53 integer boundary, causing incorrect matches, and routes large floats to MatchValue, which rejects them.

2 open issues
What changed in this PR

This PR extends Python’s QdrantCollection filter translation to support float comparisons and boolean membership filters, bringing numeric matching closer to InMemoryCollection.

Changes:

  • Uses closed numeric ranges for equality comparisons, with an exact-match fallback for large integers.
  • Translates non-string membership lists into individual value conditions.
  • Adds seven local Qdrant regression cases for float and boolean filters.
File Description
python/​tests/​unit/​connectors/​memory/​test_qdrant_local.py Adds a product fixture and parameterized float and boolean filter tests.
python/​semantic_kernel/​connectors/​qdrant.py Adds shared matching helpers and integrates them into equality and membership parsing.

Review effort: Balanced

}
IN_MEMORY_STRING: Final[str] = ":memory:"
# The server compares ranges as float64, so larger ints are matched exactly with MatchValue instead.
MAX_EXACT_FLOAT_INT: Final[int] = 2**53

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in b570677. The cutoff is now abs(value) < 2**53, so 253 itself stays on MatchValue. Local mode compares ranges with exact Python ints, so a local test can't show the server-side rounding. test_match_value_uses_range_only_where_float64_is_exact pins which condition gets built at 253 - 1, 253 and 260 instead.

def _match_value(key: str, value: Any) -> FieldCondition:
"""Match a single value, numerically for numbers so 5 also matches 5.0 and floats are accepted."""
# MatchValue only accepts str, int and bool, and an int never matches a float payload
if isinstance(value, (int, float)) and not isinstance(value, bool) and abs(value) <= MAX_EXACT_FLOAT_INT:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in b570677. The cutoff now applies to ints only, so any float uses Range. I added x.price == 1e20 and != 1e20 to the local filter tests. Both failed with a MatchValue validation error before this commit.

…filters

The float64 cutoff also applied to floats, so a value such as 1e20 fell
through to MatchValue, which rejects floats. And it included 2**53,
which float64 can't tell apart from 2**53 + 1. Apply the cutoff to ints
only, and keep ints from 2**53 up on MatchValue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks correct for the cases it targets. Here's what I checked.

  • _match_value (qdrant.py:87). Floats, and ints with abs(value) < 2**53 that aren't bools, become Range(gte=v, lte=v). Bools are excluded by the not isinstance(value, bool) check, so x.in_stock == True still uses MatchValue. So do strings. Bools never reach the range branch, and a bool payload isn't numeric.
  • != and not in. Both still wrap the condition in must_not. So a record that lacks the field is returned, as on main. not in over a mixed list becomes must_not=[Filter(should=[...])], which is "none of these". That is the right meaning.
  • _match_any (qdrant.py:96). An empty list still takes the all-str branch (all() of nothing is True) and gives MatchAny(any=[]), as main did.
  • Chained comparisons. The chain branch (qdrant.py:395) calls _lambda_parser on each pair, so x.price == 4.5 inside a chain also goes through _match_value.
  • git merge-tree against main is clean.

I didn't run the tests. I also didn't check the server-side float64 behaviour behind the 2**53 cutoff myself. It's plausible, and the description notes that local mode can't show it.

One cost to consider: a list of ints used to be a single MatchAny, and it now becomes one Range condition per value under should. For x.category_id in ids with a few hundred ids, the request and the filter grow linearly. The definition already knows each field's declared type, so _lambda_parser could look up the field by left and keep MatchValue/MatchAny when its type_ is "int". That would use Range only for float fields, or when the type is unknown. It would keep exact int matching (and the 2**53 question) out of int fields entirely, and x.price == 5 on a float field would still match 5.0.

Reviewed with help from Claude Code.

…Qdrant filters

A field declared as int holds int payloads, so an int value there can be
matched exactly. This keeps a list of ints as a single MatchAny instead of
one Range per value, and int fields no longer depend on the 2**53 cutoff.
Floats, and ints on fields of any other or unknown type, still use Range.
@Harshil26202

Copy link
Copy Markdown
Author

Muhammad Abdullah Butt (@1AbdullahButt) thanks for the careful read. Good point on the int lists, done in ff3b3df.

_lambda_parser now checks the field's declared type_. On a field declared int, an int value stays on MatchValue and a list of ints stays a single MatchAny, so x.category_id in ids is one condition again and int fields no longer depend on the 2**53 cutoff. Floats, and ints on float or untyped fields, still use Range, so x.price == 5 keeps matching 5.0 and x.quantity == 7.0 still matches an int payload.

test_int_field_keeps_match_value_and_match_any pins which condition gets built, and the local filter tests now cover an int field (==, !=, in, not in, and a float value on it). The Qdrant unit tests pass (48).

This branch was successfully deployed

1 active deployment
github-app-auth — ff3b3df6 Deployed Nov 12, 2026 by Harshil26202 via add_label #29675
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants