fix: query the document store once in CacheChecker, not once per item - #12539
fix: query the document store once in CacheChecker, not once per item#12539nata2627 wants to merge 2 commits into
Conversation
`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 is attempting to deploy a commit to the deepset Team on Vercel. A member of the Team first needs to authorize it. |
|
This does not preserve |
`_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.
|
Done — the store's You had the mechanism exactly right. Three tests. The strict sync and async ones fail without the change — reverting only the source file gives the doubled 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 |
Related Issues
Proposed Changes:
CacheChecker.runlooped overitemsand calledfilter_documentswith an==filter once per item, so checking N items cost N round trips to the store.Against
InMemoryDocumentStorethat 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_asynchad the same loop.The store is now asked once, with the
inoperator over the whole list.The part worth reviewing is how
hitsandmissesare recovered from that oneanswer. Deriving them by reading
doc.meta[cache_field]and comparing in Python— the shortest version — would change behaviour in three places:
cache_fieldis not always a metadata key.haystack/utils/filters.pywalks a dotted path (meta.source.url), usesgetattrwhen the name is a realDocumentfield (content), and onlyotherwise falls back to
document.meta.get(field).inisany(_equal(...)), and_equalcarries ISO dateparsing and timezone-aware/naive reconciliation that
==in Python does not.hitsare grouped by the item that matched them, in theorder 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 fromhaystack.utils—the same predicate the filtering machinery applies — so all three behaviours are
unchanged and N network calls become one.
An empty
itemslist now returns early rather than sending an emptyinfilterto the store, which matches what the old loop did: nothing.
How did you test it?
Unit tests, run with
hatch run test:uniton a CPU-only Linux container,Python 3.12.
6341 passed, 10 skipped, 323 deselectedin 85.77s, 0 failed.6349 passed, 10 skipped, 323 deselectedin 81.80s, 0failed — exactly the eight new tests, and nothing else moved. Re-run after
rebasing onto
8887b9d: same,6349 passed, 0 failed.4 failed, 18 passedintest/components/caching. The sharp one isassert 200 == 1onfilter_documents.call_count, sync and async.hatch run fmt-checkon the changed files: All checks passed! 3 filesalready 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 therelease 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
contentcache field, and anested
meta.source.urlone.Notes for the reviewer
Two existing tests are changed, not added to.
test_filters_syntaxandtest_run_async_filters_syntaxasserted the old per-item==filter, which isexactly the thing being replaced, so they now assert the batched
infilterover 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_keyandtest_run_on_a_nested_meta_fieldpin the behaviour this change must notalter. 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 thestore's
inselects candidates anddocument_matches_filterassigns them toitems. That is only equivalent if a store's operators agree with the reference
semantics — and
haystack.testing.document_store.FilterDocumentsTest, whichintegrations run, already requires exactly that:
test_comparison_equalandtest_comparison_inassertfilter_documentsreturns what Python==andinselect 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
itemslist becomes onelarge
infilter, and some backends cap query size or clause count. Splittinginto 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:hitsandmissesare unchanged for every input, sothere is no user-facing behaviour to document. The release note is under
enhancements.Checklist
fix:,feat:,build:,chore:,ci:,docs:,style:,refactor:,perf:,test:and added!in case the PR includes breaking changes.This PR was fully generated with an AI assistant. I have reviewed the changes
and run the relevant tests.