deepset-ai / deepset-ai/haystack

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

Open
#12,535 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

P2
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: identical hits set and misses list for inputs with duplicates, missing values, and nested cache_field paths).
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.run issues exactly one filter_documents call regardless of len(items).
  • Returned hits and misses are identical to the current implementation for: single item, many items, items with duplicates, items all missing, items partially missing, and a nested cache_field like "meta.foo.bar".
  • run_async mirrors the change and still issues exactly one filter_documents_async call.
  • Existing test_filters_syntax is 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_async signatures, return type, return value semantics) is unchanged.
  • The new filter shape is observable only through document_store.filter_documents calls and the test_filters_syntax assertion, 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_field values are hashable. The current == filter requires the same, so no new constraint is introduced.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.