Skip to content

Python: normalize comparison filters with constant on left and chained comparisons (#14779) - #14781

Open
MOHAMMED WASIM KHAN (wasim-builds) wants to merge 1 commit into
microsoft:mainfrom
wasim-builds:fix/vector-filter-constant-on-left
Open

MOHAMMED WASIM KHAN (wasim-builds) wants to merge 1 commit into
microsoft:mainfrom
wasim-builds:fix/vector-filter-constant-on-left

Conversation

@wasim-builds

Copy link
Copy Markdown
Contributor

Motivation and Context

Fixes #14779.

In Python vector search, filters passed as lambda expressions fail across vector store connectors when a comparison has the constant/literal on the left side (e.g. lambda x: 2 < x.num or lambda x: 3 == x.num), or uses Python's chained comparison syntax (e.g. lambda x: 1 <= x.num < 4).

Connector _lambda_parser implementations (Qdrant, Chroma, Weaviate, Pinecone, MongoDB, Azure Cosmos DB, etc.) assume the field identifier is on the left (Attribute or Name) and the value is on the right (Constant). When reversed:

  • Qdrant raises a Pydantic ValidationError (attempting to parse a string field name into a numeric Range bound, or using an int as key).
  • Chroma generates {2: {"$lt": "num"}}, which Chroma rejects with Expected where key to be a str, got 2.
  • Chained comparisons (len(ops) > 1) fail because the first sub-comparison in a range chain like 1 <= x.num < 4 always has the lower constant bound on the left.

In-memory collection and FAISS evaluate the lambda directly in Python, so they succeed while database connectors fail.

Description

  1. Introduced _NormalizeComparisonFilters(NodeTransformer) in python/semantic_kernel/data/vector.py executed within VectorSearch._build_filter directly following _FoldUnaryNumericConstants (Python: Vector store filters raise NotImplementedError for negative number literals such as x.price > -5 #14571):
    • Chained comparison decomposition: Recursively splits Compare(left, ops, comparators) where len(ops) > 1 into pairwise comparisons combined with BoolOp(op=And(), values=[...]).
    • Left-hand constant normalization: Rewrites Constant op Field into Field op' Constant, flipping inequality operators (< ↔ >, <= ↔ >=) and preserving equality/identity operators (==, !=, is, is not).
    • Membership tests (in, not in) and comparisons between two fields or two constants are preserved as-is.
  2. Verified integration across ChromaCollection, QdrantCollection, and InMemoryCollection.
  3. Added comprehensive AST unit tests and vector collection regression tests in python/tests/unit/data/test_vector_filter_comparison_normalization.py.

Contribution Checklist

Copilot AI balanced review requested due to automatic review settings November 11, 2026 08:00

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@semantic-kernel-automation semantic-kernel-automation Bot added the python Pull requests for the Python Semantic Kernel label Nov 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm posting the same review on #14780 and #14781, since both fix #14779 the same way and they conflict with each other in data/vector.py (git merge-tree of the two heads stops on the import block and the new class). Each one merges cleanly against main on its own, so the maintainers will want to pick one.

What both get right. Both add a NodeTransformer that runs in VectorSearch._build_filter right after _FoldUnaryNumericConstants. So -5 < x.num is folded to a Constant before the flip, and both include a test for that. Both:

  • split a chain into BoolOp(And) pair by pair, taking left from comparators[i - 1]
  • flip </> and <=/>=, and keep ==/!=
  • leave in/not in alone, as the issue asked

I traced the three filters from the issue through both, and they give the same tree: x.num > 2, x.num == 3, and x.num >= 1 and x.num < 4. A middle constant also works in both. x.a < 5 < x.b becomes x.a < 5 and x.b > 5. Every connector parser I checked (Chroma, Qdrant, Weaviate, Pinecone, MongoDB, Cosmos DB) already handles a nested BoolOp(And). So a chain combined with and still parses.

Where they differ.

#14780 _NormalizeComparisonConstants #14781 _NormalizeComparisonFilters
When it flips left is a Constant and right is Attribute/Name left is not Attribute/Name and right is
['a'] == x.tags, (1, 2) == x.pair left as is (List/Tuple aren't Constant) flipped
None is x.f left as is (Is/IsNot aren't in the map) flipped to x.f is None
Connector tests Chroma, Qdrant, Weaviate _build_filter Chroma and Qdrant _build_filter, plus an InMemoryCollection.search run

#14781's wider rule is safe for the cases above. It only swaps operands of symmetric or mirrored operators, and the tests pin two fields (x.num < x.status) and two constants (1 < 2) as unchanged. #14780's narrower rule is easier to reason about. List/tuple literals and is on the left of a field are uncommon in filters. sql_server.py:600 already swaps a None on the left, so the extra cases in #14781 add little. Either is fine to merge on correctness. I'd decide on test coverage: #14780 covers Weaviate, which the issue names as raising NotImplementedError, and #14781 covers an end-to-end in-memory search.

Small things:

  • #14781 vector.py (the chain loop): self.visit_Compare(sub_compare) re-runs generic_visit on children that were already visited, and its result is always an expr, so transformed if isinstance(transformed, expr) else sub_compare can just be self._flip(sub_compare). A helper like #14780's _normalize_single does that.
  • #14780: copy_location(inv_op_cls(), node.ops[0]) does nothing, because cmpop nodes have no location attributes. inv_op_cls() is enough.
  • In both, OracleCollection._build_filter (oracle.py:1016) overrides the base method and parses filters without the folder or the new normalizer. Oracle builds SQL, so :b1 < num already works there. I'm only noting it so it doesn't read as covered.

I read both diffs and the connector _lambda_parsers at the PR heads. I didn't run the tests.

Reviewed with help from Claude Code.

This branch was successfully deployed

1 active deployment
github-app-auth — c54f8f7a Deployed Nov 11, 2026 by wasim-builds via add_label #29667
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.

Python: Bug: vector store filters fail when the constant is on the left, including chained comparisons

3 participants