deepset-ai / deepset-ai/haystack
CacheChecker.run issues one filter_documents call per item (N+1 query pattern)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 26.6k
- Forks
- 3.2k
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 194
Description
CacheChecker.run issues one filter_documents call per item (N+1 query pattern)
Note by @anakin87: non-trivial, to be handled internally if impactful for users. Not open for contributions.
Bug
CacheChecker.run (haystack/components/caching/cache_checker.py:75) loops over the input items list and calls self.document_store.filter_documents(...) once per item:
for item in items:
filters = {"field": self.cache_field, "operator": "==", "value": item}
found = self.document_store.filter_documents(filters=filters)
if found:
found_documents.extend(found)
else:
misses.append(item)
For an input of N items, this is N round-trips to the document store. For InMemoryDocumentStore the constant is small; for QdrantDocumentStore, WeaviateDocumentStore, OpenSearchDocumentStore, etc., every call is a network request. With a 10 ms per-call latency, 200 items takes ~2 s; 5 000 items (a realistic batch) takes ~50 s.
The same pattern is duplicated in CacheChecker.run_async (cache_checker.py:99).
The Haystack filter spec already supports the in operator (haystack/document_stores/types/protocol.py:70, haystack/utils/filters.py:262), so the whole check can be done in a single call.
Reproduction
from haystack import Document
from haystack.components.caching.cache_checker import CacheChecker
from haystack.document_stores.in_memory import InMemoryDocumentStore
store = InMemoryDocumentStore()
store.write_documents(
[Document(content=f"d{i}", meta={"url": f"https://example.com/{i}"}) for i in range(200)]
)
calls = 0
orig = store.filter_documents
def counting(*a, **kw):
global calls; calls += 1
return orig(*a, **kw)
store.filter_documents = counting
CacheChecker(store, cache_field="url").run(
items=[f"https://example.com/{i}" for i in range(200)]
)
print("filter_documents calls:", calls) # -> 200
For any non-in-memory store, each call is a separate network round-trip.
Expected behavior
A single filter_documents call should be issued for the whole items list, using the in operator:
filters = {"field": self.cache_field, "operator": "in", "value": items}
found = self.document_store.filter_documents(filters=filters)
misses should be derived from the items whose value did not appear in the result set, preserving the current return contract.
Why this matters
- Cache-checking on URL- or ID-keyed lookups is a common pattern for Web RAG pipelines. Today a 1 000-URL crawl triggers 1 000 round-trips against the document store.
- The proposed change is a pure performance fix: semantics of the return value are preserved (verified locally on
InMemoryDocumentStore: identicalhitsset andmisseslist for inputs with duplicates, missing values, and nestedcache_fieldpaths).
Proposed fix
@component.output_types(hits=list[Document], misses=list)
def run(self, items: list[Any]) -> dict[str, Any]:
if not items:
return {"hits": [], "misses": []}
filters = {"field": self.cache_field, "operator": "in", "value": items}
found = self.document_store.filter_documents(filters=filters) or []
seen = {self._get_field_value(d) for d in found}
misses = [i for i in items if i not in seen]
return {"hits": list(found), "misses": misses}
(Plus a _get_field_value helper that mirrors the field-path handling in haystack/utils/filters.py:304, and the same change in run_async.)
Acceptance criteria
CacheChecker.runissues exactly onefilter_documentscall regardless oflen(items).- Returned
hitsandmissesare identical to the current implementation for: single item, many items, items with duplicates, items all missing, items partially missing, and a nestedcache_fieldlike"meta.foo.bar". run_asyncmirrors the change and still issues exactly onefilter_documents_asynccall.- Existing
test_filters_syntaxis updated to assert the new single-call filter shape. - New unit tests cover the per-call count and the miss-derivation logic.
Backward compatibility
- Public API (
run/run_asyncsignatures, return type, return value semantics) is unchanged. - The new filter shape is observable only through
document_store.filter_documentscalls and thetest_filters_syntaxassertion, both internal.
Risks
- Some document stores may have a practical limit on
in-list size. If a back-end rejects large lists, falling back to the existing per-item loop is a straightforward mitigation. - The miss-derivation assumes
cache_fieldvalues are hashable. The current==filter requires the same, so no new constraint is introduced.
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.
Research direction
Read haystack/components/caching/cache_checker.py, including run and run_async, then inspect haystack/document_stores/types/protocol.py and haystack/utils/filters.py for the in operator and field-path handling. Run the existing test_filters_syntax tests and add the requested call-count, miss-derivation, duplicate, and nested-field coverage; done means both sync and async paths preserve the stated return contract with one store call.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- databases, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 20/100