Skip to content

fix: query the document store once in CacheChecker, not once per item - #12539

Open
nata2627 wants to merge 2 commits into
deepset-ai:mainfrom
nata2627:fix/cache-checker-batch-filter
Open

fix: query the document store once in CacheChecker, not once per item#12539
nata2627 wants to merge 2 commits into
deepset-ai:mainfrom
nata2627:fix/cache-checker-batch-filter

Conversation

@nata2627

Copy link
Copy Markdown

Related Issues

Proposed Changes:

CacheChecker.run looped over items and called filter_documents with an
== filter once per item, so checking N items cost N round trips to the store.
Against InMemoryDocumentStore that is a Python loop; against Qdrant, Weaviate,
OpenSearch or pgvector every iteration is a network request, and the component
is normally placed where N is large — the front of an indexing pipeline,
deciding which URLs still need fetching. run_async had the same loop.

The store is now asked once, with the in operator over the whole list.

The part worth reviewing is how hits and misses are recovered from that one
answer. Deriving them by reading doc.meta[cache_field] and comparing in Python
— the shortest version — would change behaviour in three places:

  • field resolution. cache_field is not always a metadata key.
    haystack/utils/filters.py walks a dotted path (meta.source.url), uses
    getattr when the name is a real Document field (content), and only
    otherwise falls back to document.meta.get(field).
  • comparison. in is any(_equal(...)), and _equal carries ISO date
    parsing and timezone-aware/naive reconciliation that == in Python does not.
  • grouping. Today hits are grouped by the item that matched them, in the
    order the items were given, and a repeated item contributes its documents once
    per occurrence. A single query returns store order, once.

So only the I/O moved. The item-to-document mapping is done in process with
document_matches_filter, which is public and exported from haystack.utils
the same predicate the filtering machinery applies — so all three behaviours are
unchanged and N network calls become one.

An empty items list now returns early rather than sending an empty in filter
to the store, which matches what the old loop did: nothing.

How did you test it?

Unit tests, run with hatch run test:unit on a CPU-only Linux container,
Python 3.12.

  • Baseline, this branch with the source change stashed and the tests kept:
    6341 passed, 10 skipped, 323 deselected in 85.77s, 0 failed.
  • With the change: 6349 passed, 10 skipped, 323 deselected in 81.80s, 0
    failed — exactly the eight new tests, and nothing else moved. Re-run after
    rebasing onto 8887b9d: same, 6349 passed, 0 failed.
  • With the fix reverted and the new tests kept: 4 failed, 18 passed in
    test/components/caching. The sharp one is
    assert 200 == 1 on filter_documents.call_count, sync and async.
  • hatch run fmt-check on the changed files: All checks passed! 3 files
    already formatted.
  • hatch run test:types: Success: no issues found in 467 source files.
  • pre-commit run --files … on the four changed files: all hooks pass —
    including release-note-backticks, which caught single backticks in the
    release note on the first run and is why it now uses double ones.

New tests: call count for 200 items (sync and async), the empty-input case,
repeated items, hit grouping and miss ordering, a content cache field, and a
nested meta.source.url one.

Notes for the reviewer

Two existing tests are changed, not added to. test_filters_syntax and
test_run_async_filters_syntax asserted the old per-item == filter, which is
exactly the thing being replaced, so they now assert the batched in filter
over a two-item list.

Four of the eight new tests pass without the fix as well, and that is
deliberate rather than padding: test_run_repeats_hits_for_a_repeated_item,
test_run_keeps_hits_grouped_by_item_and_misses_in_order,
test_run_on_a_document_field_rather_than_a_meta_key and
test_run_on_a_nested_meta_field pin the behaviour this change must not
alter. They are guards, not reproductions.

On re-checking the store's answer locally. Before this change, which
documents counted as hits was decided entirely by the store's own ==; now the
store's in selects candidates and document_matches_filter assigns them to
items. That is only equivalent if a store's operators agree with the reference
semantics — and haystack.testing.document_store.FilterDocumentsTest, which
integrations run, already requires exactly that: test_comparison_equal and
test_comparison_in assert filter_documents returns what Python == and in
select over the same field. Flagging it because it is the one behavioural
question in the change rather than because I think it is open.

On the overlap with @Ayush-yadav11. They asked on #12535 whether they could
take it, describing the same approach, while this branch was already finished
and proved — I hadn't seen the comment until the branch was ready to push, and
I'm not claiming that settles anything. Posting it rather than sitting on it
seemed more useful than the alternative, but if you would rather the issue went
to them, say so and I'll close this; there is no version of this where two of us
should spend an evening on the same ten lines.

Deliberately out of scope: chunking. A very large items list becomes one
large in filter, and some backends cap query size or clause count. Splitting
into batches is a separate decision — it needs a batch size that is right per
store — and this change leaves the number of calls at one rather than trading
one limit for another. Happy to add it if you would rather it went in here.

No docs-website/ change: hits and misses are unchanged for every input, so
there is no user-facing behaviour to document. The release note is under
enhancements.

Checklist

  • I have read the contributors guidelines and the code of conduct.
  • I have updated the related issue with new insights and changes. — nothing to add there beyond this description; the one finding is the three-way behaviour equivalence explained above, and it belongs with the diff rather than on the issue.
  • I have added unit tests and updated the docstrings.
  • I've used one of the conventional commit types for my PR title: fix:, feat:, build:, chore:, ci:, docs:, style:, refactor:, perf:, test: and added ! in case the PR includes breaking changes.
  • I have documented my code.
  • I have added a release note file, following the contributors guidelines.
  • I have run pre-commit hooks and fixed any issue.

This PR was fully generated with an AI assistant. I have reviewed the changes
and run the relevant tests.

`CacheChecker.run` looped over `items` and called `filter_documents` with an
`==` filter for each one, so checking N items cost N round trips to the store.
Against `InMemoryDocumentStore` that is a Python loop; against Qdrant,
Weaviate, OpenSearch or pgvector every iteration is a network request, and the
component is normally placed where N is large -- the front of an indexing
pipeline, deciding which URLs still need fetching. `run_async` had the same
loop.

The store is now asked once, with the `in` operator over the whole list.

Splitting the result back into `hits` and `misses` is done in process with
`document_matches_filter`, rather than by comparing `doc.meta[cache_field]` to
each item. That keeps three behaviours identical to the per-item version:

- field resolution. `cache_field` is not always a metadata key --
  `haystack/utils/filters.py` walks a dotted path, uses `getattr` for a real
  `Document` field, and only otherwise falls back to `document.meta.get`;
- comparison. The `in` operator is `any(_equal(...))`, and `_equal` carries ISO
  date parsing and timezone-aware/naive reconciliation that `==` in Python does
  not;
- grouping. `hits` stay grouped by the item that matched them, in the order the
  items were given, and a repeated item still contributes its documents once
  per occurrence.

An empty `items` list now returns early instead of sending an empty `in` filter
to the store, matching what the old loop did, which was nothing.

Two existing tests pinned the old `==` filter syntax and are updated to the
batched one. Six new tests cover the call count for 200 items, the empty-input
case, repeated items, hit grouping and miss ordering, a `content` cache field
and a nested `meta.source.url` one; two more cover the async path.
@nata2627
nata2627 requested a review from a team as a code owner August 31, 2026 17:21
@nata2627
nata2627 requested review from anakin87 and removed request for a team August 31, 2026 17:21
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@nata2627 is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

@Ayush-yadav11

Copy link
Copy Markdown

This does not preserve strict_datetime_comparison. The store query uses the store's configured strictness, but this rematch calls document_matches_filter with its default False. With InMemoryDocumentStore(strict_datetime_comparison=True), a document with an aware timestamp and items containing both the equivalent naive and aware timestamps is returned as a hit for both items here, while the old per-item == queries only hit the aware item. The same bug affects run_async through this helper. Please pass the store's strictness into the predicate, or add an equivalent store-level contract, and cover both sync and async cases.

`_split_hits_and_misses` regroups the documents from the single `in` query onto
the items that matched them, and called `document_matches_filter` with no
`strict_datetime_comparison`. The flag is keyword-only and defaults to `False`,
while `InMemoryDocumentStore` passes its own `strict_datetime_comparison` into
every call it makes. So the store answered the batched query with its setting
and the regrouping used the default, and the two disagree whenever they differ.

With `InMemoryDocumentStore(strict_datetime_comparison=True)`, a document whose
cache field holds a timezone-aware timestamp, and `items` containing both the
naive and the aware spelling of it: the `in` query matches the document on the
aware item alone, and the regrouping then matches it against both, because
non-strict date equality copies the timezone from the aware value onto the
naive one. `hits` gets the document twice and the naive item is dropped from
`misses`. The per-item `==` queries this replaced did not do that.

The store's setting is now read once and passed into the predicate. `getattr`
rather than an attribute access, because `strict_datetime_comparison` belongs
to `InMemoryDocumentStore` and is not part of the `DocumentStore` protocol;
this component already duck-types optional store capabilities the same way for
`filter_documents_async` and `close`. `run` and `run_async` share the helper,
so both paths are covered.

Two new tests fail without the change, one for each path. A third pins the
default non-strict behaviour on the same data, so it cannot be tightened by
accident later.
@nata2627

nata2627 commented Sep 1, 2026

Copy link
Copy Markdown
Author

Done — the store's strict_datetime_comparison is read and passed into the predicate now (6998819).

You had the mechanism exactly right. document_matches_filter takes the flag keyword-only with a False default, while InMemoryDocumentStore passes self.strict_datetime_comparison into all nine calls it makes itself, so the batched in query was answered with the store's setting and the in-process regrouping was not. getattr rather than an attribute access, since the setting is on InMemoryDocumentStore and not in the DocumentStore protocol — the same duck-typing this component already does for filter_documents_async and close. run_async shares _split_hits_and_misses, so both paths were affected and both are covered.

Three tests. The strict sync and async ones fail without the change — reverting only the source file gives the doubled hits and empty misses you described. The third pins the default non-strict behaviour on the same data so it cannot be tightened by accident later. Full unit suite: 6352 passed, 10 skipped, 0 failed.

One thing your comment made me realise the PR body should have said and did not: with the grouping in process, it is Haystack's own filter semantics that decide which item a returned document belongs to, whatever store answered the in. For InMemoryDocumentStore that is now exact. For a backend whose == differs from document_matches_filter it is the reference implementation's answer rather than the backend's — inherent to replacing N queries with one, and worth stating plainly. If you or the maintainers would rather have a store-level contract for this than reading the attribute, say so and I will switch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CacheChecker.run issues one filter_documents call per item (N+1 query pattern)

2 participants