deepset-ai / deepset-ai/haystack
FilterPolicy.MERGE unions OR/NOT filters instead of intersecting them, returning documents the init filter excludes
@sjrl is already working on this.
Since Sep 19, 2026.
- Dominant language
- Python
- Stars
- 26.6k
- Forks
- 3.2k
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 194
Description
Describe the bug
Under FilterPolicy.MERGE, apply_filter_policy() concatenates the conditions of two
logical filters that share an operator. That is correct for AND, but for OR and NOT
it produces a filter that is logically weaker than either input — the result is a
union where it should be an intersection, so the retriever returns documents that the
init filter was supposed to exclude. No error, no warning.
combine_two_logical_filters() never looks at default_logical_operator; it branches
only on whether the two filters happen to share an operator:
# haystack/document_stores/types/filter_policy.py
if init_logical_filter["operator"] == runtime_logical_filter["operator"]:
return {
"operator": str(init_logical_filter["operator"]),
"conditions": init_logical_filter["conditions"] + runtime_logical_filter["conditions"],
}
With haystack/utils/filters.py semantics (AND = all, OR = any,
NOT = not all), concatenation means:
| operator | merge produces | merge should produce | correct? |
|---|---|---|---|
AND |
A ∧ B ∧ C ∧ D |
(A ∧ B) ∧ (C ∧ D) |
yes |
OR |
A ∨ B ∨ C ∨ D |
(A ∨ B) ∧ (C ∨ D) |
no — can be wider |
NOT |
¬(A ∧ B ∧ C ∧ D) |
¬(A ∧ B) ∧ ¬(C ∧ D) |
no — can be wider |
This happens with default arguments. default_logical_operator defaults to "AND"
and is not plumbed through the retrievers at all, yet the OR/NOT path is still taken,
because the branch keys off the filters' own operators rather than the requested one.
Error message
None. The merged filter is well-formed and the retriever returns successfully.
Expected behavior
FilterPolicy.MERGE is documented as "Runtime filters are merged with the initial
filters". Merging two restrictions should mean both still apply, so the result should be
the intersection of the two filters for every operator — as it already is for AND.
Additional context
To reproduce (no external services, in-memory only):
from haystack import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import FilterPolicy
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
store = InMemoryDocumentStore()
store.write_documents([
Document(id="1", content="quarterly budget report",
meta={"tenant": "acme", "public": False, "category": "finance"}),
Document(id="2", content="quarterly budget report",
meta={"tenant": "other", "public": False, "category": "finance"}),
])
# init filter, fixed at construction: this tenant's documents, or public ones
retriever = InMemoryBM25Retriever(
document_store=store,
filters={"operator": "OR", "conditions": [
{"field": "meta.tenant", "operator": "==", "value": "acme"},
{"field": "meta.public", "operator": "==", "value": True},
]},
filter_policy=FilterPolicy.MERGE,
)
# runtime filter from the query: finance or legal
res = retriever.run(query="budget", filters={"operator": "OR", "conditions": [
{"field": "meta.category", "operator": "==", "value": "finance"},
{"field": "meta.category", "operator": "==", "value": "legal"},
]})
print(sorted(d.id for d in res["documents"]))
['1', '2'] # expected ['1']
Document 2 has tenant="other" and public=False, so the init filter excludes it. The
merged filter is OR(tenant==acme, public==True, category==finance, category==legal), and
2 matches the last-but-one condition, so it comes back.
Same thing at the helper level, with default_logical_operator left at its default:
from haystack.document_stores.types.filter_policy import FilterPolicy, apply_filter_policy
from haystack.utils.filters import document_matches_filter
from haystack import Document
docs = [Document(id=str(i), meta=m) for i, m in enumerate(
[{"a": 1, "b": 1}, {"a": 1, "b": 9}, {"a": 9, "b": 1}, {"a": 9, "b": 9}])]
def keep(f): return [d.id for d in docs if document_matches_filter(f, d)]
def L(op, *c): return {"operator": op, "conditions": list(c)}
def C(f, v): return {"field": f"meta.{f}", "operator": "==", "value": v}
for init, run in [(L("AND", C("a", 1)), L("AND", C("b", 1))),
(L("OR", C("a", 1), C("b", 1)), L("OR", C("a", 9), C("b", 9))),
(L("NOT", C("a", 9)), L("NOT", C("b", 9)))]:
merged = apply_filter_policy(FilterPolicy.MERGE, init, run)
expected = [d.id for d in docs
if document_matches_filter(init, d) and document_matches_filter(run, d)]
print(f"{init['operator']:4} intersection={expected} actual={keep(merged)}")
AND intersection=['0'] actual=['0']
OR intersection=['1', '2'] actual=['0', '1', '2', '3']
NOT intersection=['0'] actual=['0', '1', '2']
What is not part of this report. When a caller passes default_logical_operator="OR"
or "NOT" explicitly, combine_two_comparison_filters() wraps both filters in that
operator. That is what the caller asked for, and
test_merge_with_custom_logical_operator pins it, so I have left it alone. This report is
only about combine_two_logical_filters(), which ignores default_logical_operator
entirely.
Scope. apply_filter_policy is public API (exported from
haystack.document_stores.types), so this affects the in-memory retrievers in this repo
and any integration retriever that uses it for FilterPolicy.MERGE. Existing coverage in
test/document_stores/test_filter_policy.py (10 test functions / 12 collected cases, all passing on main) exercises
two-logical-filter merging only with AND.
A fix can keep the current flattening for AND and nest the two filters under an AND
for every other operator: correct in general, and the AND output shape is unchanged so
existing behaviour and tests are untouched. I've opened a PR with that fix and tests.
Worth flagging explicitly: this changes the merged filter for anyone currently relying on
the OR/NOT output. I think that is the point — the current output can match
documents excluded by either input filter — but if you'd rather take a different direction
(e.g. keep the shape and only warn), say so on the PR and I'll rework it.
To Reproduce
See the two snippets above.
FAQ Check
- Have you had a look at our new FAQ page?
System:
- OS: macOS (Darwin 25.6.0, arm64)
- GPU/CPU: Apple M4
- Haystack version (commit or version number):
main@ b717d00 - Python version: 3.13
Developed with AI-assisted tooling. I reproduced the bug, reviewed the final report,
and confirmed the behaviour before filing.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.