fix(vector_stores/chroma): stop dropping filter conditions in where-clause translation (#6452)
This commit is contained in:
@@ -254,110 +254,111 @@ class ChromaDB(VectorStoreBase):
|
|||||||
def _generate_where_clause(where: dict[str, any]) -> dict[str, any]:
|
def _generate_where_clause(where: dict[str, any]) -> dict[str, any]:
|
||||||
"""
|
"""
|
||||||
Generate a properly formatted where clause for ChromaDB.
|
Generate a properly formatted where clause for ChromaDB.
|
||||||
|
|
||||||
|
ChromaDB's where grammar allows exactly one field or one logical
|
||||||
|
operator per dict level, so multiple operators on the same field and
|
||||||
|
multi-field conditions must be combined with an explicit ``$and``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
where (dict[str, any]): The filter conditions.
|
where (dict[str, any]): The filter conditions.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, any]: Properly formatted where clause for ChromaDB.
|
dict[str, any]: Properly formatted where clause for ChromaDB.
|
||||||
"""
|
"""
|
||||||
if where is None:
|
if where is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def convert_condition(key: str, value: any) -> dict:
|
op_map = {
|
||||||
"""Convert universal filter format to ChromaDB format."""
|
"eq": "$eq",
|
||||||
|
"ne": "$ne",
|
||||||
|
"gt": "$gt",
|
||||||
|
"gte": "$gte",
|
||||||
|
"lt": "$lt",
|
||||||
|
"lte": "$lte",
|
||||||
|
"in": "$in",
|
||||||
|
"nin": "$nin",
|
||||||
|
}
|
||||||
|
# Negation of each operator. contains/icontains fall back to equality
|
||||||
|
# on the positive path (ChromaDB has no substring match), so their
|
||||||
|
# negation falls back to inequality for consistency.
|
||||||
|
negate_map = {
|
||||||
|
"eq": "$ne",
|
||||||
|
"ne": "$eq",
|
||||||
|
"gt": "$lte",
|
||||||
|
"gte": "$lt",
|
||||||
|
"lt": "$gte",
|
||||||
|
"lte": "$gt",
|
||||||
|
"in": "$nin",
|
||||||
|
"nin": "$in",
|
||||||
|
}
|
||||||
|
|
||||||
|
def convert_condition(key: str, value: any) -> list:
|
||||||
|
"""Convert one field condition to a list of single-field ChromaDB clauses."""
|
||||||
if value == "*":
|
if value == "*":
|
||||||
# Wildcard - match any value (ChromaDB doesn't have direct wildcard, so we skip this filter)
|
# Wildcard - match any value (ChromaDB doesn't have direct wildcard, so we skip this filter)
|
||||||
|
return []
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# One clause per operator: ChromaDB rejects field expressions
|
||||||
|
# with more than one operator, so a range like
|
||||||
|
# {"gte": 18, "lte": 65} must become two clauses combined
|
||||||
|
# with $and by the caller (previously each operator
|
||||||
|
# overwrote the last, silently dropping bounds).
|
||||||
|
# contains/icontains and unknown operators fall back to equality.
|
||||||
|
return [{key: {op_map.get(op, "$eq"): val}} for op, val in value.items()]
|
||||||
|
# Simple equality
|
||||||
|
return [{key: {"$eq": value}}]
|
||||||
|
|
||||||
|
def combine(clauses: list, operator: str):
|
||||||
|
"""Combine clauses under a logical operator, unwrapping singletons."""
|
||||||
|
if not clauses:
|
||||||
return None
|
return None
|
||||||
elif isinstance(value, dict):
|
if len(clauses) == 1:
|
||||||
# Handle comparison operators
|
return clauses[0]
|
||||||
chroma_condition = {}
|
return {operator: clauses}
|
||||||
for op, val in value.items():
|
|
||||||
if op == "eq":
|
|
||||||
chroma_condition[key] = {"$eq": val}
|
|
||||||
elif op == "ne":
|
|
||||||
chroma_condition[key] = {"$ne": val}
|
|
||||||
elif op == "gt":
|
|
||||||
chroma_condition[key] = {"$gt": val}
|
|
||||||
elif op == "gte":
|
|
||||||
chroma_condition[key] = {"$gte": val}
|
|
||||||
elif op == "lt":
|
|
||||||
chroma_condition[key] = {"$lt": val}
|
|
||||||
elif op == "lte":
|
|
||||||
chroma_condition[key] = {"$lte": val}
|
|
||||||
elif op == "in":
|
|
||||||
chroma_condition[key] = {"$in": val}
|
|
||||||
elif op == "nin":
|
|
||||||
chroma_condition[key] = {"$nin": val}
|
|
||||||
elif op in ["contains", "icontains"]:
|
|
||||||
# ChromaDB doesn't support contains, fallback to equality
|
|
||||||
chroma_condition[key] = {"$eq": val}
|
|
||||||
else:
|
|
||||||
# Unknown operator, treat as equality
|
|
||||||
chroma_condition[key] = {"$eq": val}
|
|
||||||
return chroma_condition
|
|
||||||
else:
|
|
||||||
# Simple equality
|
|
||||||
return {key: {"$eq": value}}
|
|
||||||
|
|
||||||
processed_filters = []
|
processed_filters = []
|
||||||
|
|
||||||
for key, value in where.items():
|
for key, value in where.items():
|
||||||
if key == "$or":
|
if key == "$or":
|
||||||
# Handle OR conditions
|
|
||||||
or_conditions = []
|
or_conditions = []
|
||||||
for condition in value:
|
for condition in value:
|
||||||
or_condition = {}
|
sub_clauses = []
|
||||||
for sub_key, sub_value in condition.items():
|
for sub_key, sub_value in condition.items():
|
||||||
converted = convert_condition(sub_key, sub_value)
|
sub_clauses.extend(convert_condition(sub_key, sub_value))
|
||||||
if converted:
|
combined = combine(sub_clauses, "$and")
|
||||||
or_condition.update(converted)
|
if combined:
|
||||||
if or_condition:
|
or_conditions.append(combined)
|
||||||
or_conditions.append(or_condition)
|
combined_or = combine(or_conditions, "$or")
|
||||||
|
if combined_or:
|
||||||
if len(or_conditions) > 1:
|
processed_filters.append(combined_or)
|
||||||
processed_filters.append({"$or": or_conditions})
|
|
||||||
elif len(or_conditions) == 1:
|
|
||||||
processed_filters.append(or_conditions[0])
|
|
||||||
|
|
||||||
elif key == "$not":
|
elif key == "$not":
|
||||||
negate_op = {
|
|
||||||
"eq": "$ne", "ne": "$eq",
|
|
||||||
"gt": "$lte", "gte": "$lt",
|
|
||||||
"lt": "$gte", "lte": "$gt",
|
|
||||||
"in": "$nin", "nin": "$in",
|
|
||||||
}
|
|
||||||
negated_per_group = []
|
negated_per_group = []
|
||||||
for condition in value:
|
for condition in value:
|
||||||
negated_fields = []
|
negated_fields = []
|
||||||
for sub_key, sub_value in condition.items():
|
for sub_key, sub_value in condition.items():
|
||||||
if isinstance(sub_value, dict):
|
if isinstance(sub_value, dict):
|
||||||
for op, val in sub_value.items():
|
for op, val in sub_value.items():
|
||||||
neg = negate_op.get(op)
|
# Unknown operators mirror the positive-path
|
||||||
if neg:
|
# equality fallback as inequality (previously
|
||||||
negated_fields.append({sub_key: {neg: val}})
|
# they were silently dropped, which could
|
||||||
|
# erase the entire NOT clause).
|
||||||
|
negated_fields.append({sub_key: {negate_map.get(op, "$ne"): val}})
|
||||||
else:
|
else:
|
||||||
negated_fields.append({sub_key: {"$ne": sub_value}})
|
negated_fields.append({sub_key: {"$ne": sub_value}})
|
||||||
if len(negated_fields) > 1:
|
# NOT(a AND b) == (NOT a) OR (NOT b)
|
||||||
negated_per_group.append({"$or": negated_fields})
|
combined = combine(negated_fields, "$or")
|
||||||
elif len(negated_fields) == 1:
|
if combined:
|
||||||
negated_per_group.append(negated_fields[0])
|
negated_per_group.append(combined)
|
||||||
|
combined_not = combine(negated_per_group, "$and")
|
||||||
|
if combined_not:
|
||||||
|
processed_filters.append(combined_not)
|
||||||
|
|
||||||
if len(negated_per_group) > 1:
|
|
||||||
processed_filters.append({"$and": negated_per_group})
|
|
||||||
elif len(negated_per_group) == 1:
|
|
||||||
processed_filters.append(negated_per_group[0])
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Regular condition
|
# Regular condition
|
||||||
converted = convert_condition(key, value)
|
combined = combine(convert_condition(key, value), "$and")
|
||||||
if converted:
|
if combined:
|
||||||
processed_filters.append(converted)
|
processed_filters.append(combined)
|
||||||
|
|
||||||
# Return appropriate format based on number of conditions
|
# Return appropriate format based on number of conditions
|
||||||
if len(processed_filters) == 0:
|
return combine(processed_filters, "$and")
|
||||||
return None
|
|
||||||
elif len(processed_filters) == 1:
|
|
||||||
return processed_filters[0]
|
|
||||||
else:
|
|
||||||
return {"$and": processed_filters}
|
|
||||||
|
|||||||
@@ -344,3 +344,53 @@ def test_chroma_config_rejects_no_config():
|
|||||||
"""Test that ChromaDbConfig rejects when no connection config is provided."""
|
"""Test that ChromaDbConfig rejects when no connection config is provided."""
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
ChromaDbConfig()
|
ChromaDbConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_where_clause_same_field_range_keeps_both_bounds():
|
||||||
|
"""A same-field range must produce both bounds, each as its own single-operator
|
||||||
|
clause combined with $and (ChromaDB rejects multi-operator field expressions).
|
||||||
|
|
||||||
|
Regression test: each operator previously overwrote the previous one, so
|
||||||
|
{"gte": 18, "lte": 65} silently degraded to {"$lte": 65} and returned rows
|
||||||
|
the caller explicitly excluded.
|
||||||
|
"""
|
||||||
|
result = ChromaDB._generate_where_clause({"age": {"gte": 18, "lte": 65}})
|
||||||
|
assert result == {"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_where_clause_or_with_same_field_range():
|
||||||
|
"""Same-field ranges inside $or branches must also keep both bounds."""
|
||||||
|
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18, "lte": 65}}, {"vip": True}]})
|
||||||
|
assert result == {
|
||||||
|
"$or": [
|
||||||
|
{"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]},
|
||||||
|
{"vip": {"$eq": True}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_where_clause_or_with_multi_field_condition():
|
||||||
|
"""Multi-field conditions inside $or must be wrapped in $and — ChromaDB
|
||||||
|
rejects flat dicts with more than one field per level."""
|
||||||
|
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18}, "vip": True}, {"city": "sh"}]})
|
||||||
|
assert result == {
|
||||||
|
"$or": [
|
||||||
|
{"$and": [{"age": {"$gte": 18}}, {"vip": {"$eq": True}}]},
|
||||||
|
{"city": {"$eq": "sh"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_where_clause_not_contains_negates_instead_of_vanishing():
|
||||||
|
"""$not with contains/icontains must produce a negated clause.
|
||||||
|
|
||||||
|
Regression test: operators missing from the negation map were silently
|
||||||
|
dropped, which could erase the whole where clause and return unfiltered
|
||||||
|
results. contains falls back to equality on the positive path, so its
|
||||||
|
negation falls back to inequality.
|
||||||
|
"""
|
||||||
|
result = ChromaDB._generate_where_clause({"$not": [{"title": {"contains": "draft"}}]})
|
||||||
|
assert result == {"title": {"$ne": "draft"}}
|
||||||
|
|
||||||
|
result = ChromaDB._generate_where_clause({"$not": [{"title": {"icontains": "draft"}}]})
|
||||||
|
assert result == {"title": {"$ne": "draft"}}
|
||||||
|
|||||||
Reference in New Issue
Block a user