Skip to content

Vector/hybrid filter intersection drops candidates beyond VECTOR_FILTER_SCAN_LIMIT #1431

Closed

Description

@phernandez

Summary

When a vector or hybrid query carries a structured filter, the filter-only FTS call is capped at VECTOR_FILTER_SCAN_LIMIT (50,000) rows, and every vector candidate outside that page is treated as disallowed. A highly similar, genuinely matching result can therefore vanish purely because of where it fell in that scan.

Raised during review of #1426 (SPEC-82 temporal search) and declined there on scope — this is pre-existing and not temporal-specific. Filing so it isn't lost.

Why it is not merely "arbitrary"

With search_text=None there is no relevance signal in the ordering, so the retained 50,000 are not a random sample:

  • Postgres tiebreaks search_index.id ASC — the survivors are deterministically the earliest-indexed rows, i.e. systematically biased against newer content
  • SQLite has no id tiebreaker at all

Why it is not covered by the eventual-consistency carve-out

CLAUDE.md asks reviewers not to raise derived-state races that converge on a later write or index pass. This is not one of those. It is a result-completeness bug: the query returns a wrong answer now and returns the same wrong answer on every rerun. Nothing repairs it.

Scope — this is not about temporal

The filter_requested block, the capped self.search(...) call, and the allowed_keys intersection are byte-identical on posix-meta-predicates, the base branch. VECTOR_FILTER_SCAN_LIMIT dates to #550, with #1141 on top. SPEC-82 adds temporal to a list that already held nine filters with the identical exposure.

temporal is in fact the least reachable of the ten — it only matches rows carrying authored valid-time markup, whereas search_item_types=[observation] or a shallow file_path_prefix crosses 50k on any large vault. Fixing it for the newest and narrowest filter alone would leave the eight more reachable ones broken while making the path look safe, which is worse than leaving it visibly unfixed.

Why the obvious fixes are not small

  • Intersect with the candidate keys (the right design): search() has no id-restriction parameter, so this means a new argument on the abstract signature plus both backend implementations plus batched IN lists — the file already batches hydration at 250 because both engines cap bind parameters.
  • Exhaust the pages: removes the safety valve that stops a filter-only scan materializing an entire project's search index into memory.
  • Raise the constant: moves the cliff, does not remove it.

Suggested direction

Scope a fix to the whole filter-intersection path rather than one filter, with candidate-key restriction as the design: push the candidate ids into the filter query instead of paging the filter and intersecting client-side.

Reachability

Requires a project whose filter-matching rows exceed 50,000 — large vaults and cloud tenants, not typical local use. That is what makes it a tracked issue rather than a release blocker.

Activity

  1. claude commented on Sep 1, 2026

    @claude
    Contributor

    Claude finished @phernandez's task in 50s —— View job


    Triage complete

    Type: Bug — confirmed against code, not just as reported. src/basic_memory/repository/search_repository_base.py:2317-2355 builds filter_requested for a vector/hybrid query, runs a filter-only FTS search() capped at VECTOR_FILTER_SCAN_LIMIT (50,000, defined at line 85), then intersects vector candidates against allowed_keys derived from that capped page. Any vector match whose (type, id) key isn't in the first 50k FTS rows is dropped from search_index_rows regardless of true relevance.

    Component: Database/search repository (search_repository_base.py) — the intersection logic is retrieval-mode agnostic (vector and hybrid paths both hit it) and is not specific to MCP, CLI, or Cloud; Cloud/large vaults are just where the row count is most likely to exceed 50k.

    Priority: Medium. It's a genuine result-completeness bug (deterministic wrong answers, not a self-healing eventual-consistency case per CLAUDE.md's carve-out), but only reachable once filter-matching rows exceed 50,000 — large vaults / cloud tenants, not typical local use. That reachability bar is why this is being tracked rather than treated as a blocker.

    Complexity: Complex. As the issue notes, the correct fix (push candidate ids into the filter query instead of paging + client-side intersecting) requires a new id-restriction parameter on the abstract search() signature plus both backend implementations, plus batching to respect each engine's bind-parameter cap (existing hydration already batches at 250). This is a design change to the whole filter-intersection path, not a one-line constant bump — and per the issue, the fix should cover all ten filters uniformly (not just temporal) to avoid leaving the more-reachable filters (e.g., search_item_types, file_path_prefix) silently broken.

    No duplicate issues found (gh search issues for VECTOR_FILTER_SCAN_LIMIT and "vector filter intersection" returned only this issue). Root cause, scope, and suggested direction in the issue body are accurate based on reading the current code — recommend keeping this open for a dedicated design/implementation pass on the filter-intersection path rather than a narrow patch.

  2. added a commit that references this issue on Sep 3, 2026
    34ac0a3
  3. added a commit that references this issue on Sep 5, 2026
    5e7d95d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions