Skip to content

fix(knowledge): recover vector candidates an HNSW post-filter discards - #7971

Merged
waleedlatif1 merged 3 commits into
stagingfrom
fix/vector-leg-budget
Sep 18, 2026
Merged

waleedlatif1 merged 3 commits into
stagingfrom
fix/vector-leg-budget

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Why

pgvector's HNSW index post-filters by construction. hnswgettuple never reads the scan
keys hnswrescan writes, the access method declares no scan strategies, and there is no
amgetbitmap — so a visibility predicate cannot narrow the graph walk. It can only discard
neighbours the walk has already committed to. When the documents a caller may read are a
small share of the index, the traversal returns a handful of candidates instead of its limit,
and the leg hands back almost nothing.

Two settings made that worse. hnsw.max_scan_tuples excludes the initial beam, so pairing a
1,000-wide ef_search with a 1,000-tuple budget spent the whole budget inside the first beam:
ResumeScanItems never ran and the iterative scan never iterated. And a beam is uninterruptible
— pgvector calls CHECK_FOR_INTERRUPTS only in its index-build paths, never in hnswgettuple
— so a wide beam is also the leg's cancellation floor.

The probe in front of the traversal had its own problem: it scanned documents until it had
accumulated a fixed number of chunks. A search index holds only a few chunks per document,
so that bound let it evaluate the access predicate across many times more documents than its
limit suggested, with no deadline of its own.

What changed

  1. An exact ranking rescues an underfilled traversal. A traversal that fills its candidate
    limit is already the nearest permitted chunks and is returned untouched — nothing else runs,
    so a scope the graph serves well pays nothing. Only when it comes back short does the leg
    probe how many documents the caller may read, and rank exactly when that set is small enough
    to afford. The exact scan is keyed by the probed document identities, so it reads through
    embedding_search_document_lookup_idx and costs what the permitted set costs rather than
    re-deriving permission across the index. It also honours statement_timeout, which a
    traversal cannot.

  2. The probe is bounded by documents examined and has its own sub-budget. Reaching either
    bound means an exact ranking is not worth attempting — a decision, not a failure — so the
    traversal's own candidates stand and the leg's deadline is untouched. The bound is derived
    from the narrowest live vector budget and the measured per-document cost, not picked.

  3. Retuned the scan settings: ef_search 1000 → 200, max_scan_tuples 1000 → 20000. The
    tuple budget now sits an order of magnitude above the beam, which is what lets the iterative
    scan iterate at all (pgvector#912), and the narrower beam lowers the uninterruptible floor.

Measured

On a local reproduction built to match the search index's shape — chunk and document counts,
knowledge-base size skew, chunks per document, index parameters and server settings — and run
under matching cache pressure (working set ~14x shared_buffers). Candidate-stage wall clock and
candidates returned; the retrieval budget for the narrowest surface is 3,000 ms.

scope before after
org index, caller sees 67% of docs, query off-beam 123 ms, 1600 29 ms, 1600
org index, sees 27%, off-beam 70 ms, 1600 40 ms, 1600
org index, sees 6.7%, off-beam 55 ms, 2 637 ms, 1600
org index, sees 1.6%, off-beam 39 ms, 1 363 ms, 1600
org index, sees nothing 43 ms, 0 273 ms, 0
org index, sees 6.7%, on-beam 57 ms, 1600 46 ms, 1600
workspace KB, 0.5% of index 61 ms, 1600 20 ms, 1600
workspace KB, 4.7% of index 57 ms, 7 424 ms, 1600
workspace KB, 8.4% of index 56 ms, 0 559 ms, 1600

Every case where the traversal fills is faster after, with identical recall — workspace-scoped
search, which was healthy, is strictly better. Every case where it came back short now returns a
full candidate set, for a fraction of the budget. Two workspace-scoped cases turn out to have
been quietly broken too: a knowledge base that is a small share of the shared index collapses
for the same reason an organization index does.

The one axis that is worse: a caller who may read nothing costs ~230 ms more, because the
retuned scan works harder before concluding there is nothing to find and the probe then confirms
it. Both paths return nothing either way, it is bounded, and it buys the recall above.

Cancellation, same reproduction: the exact scan and the probe honour statement_timeout within
1.0–2.4x. The retuned traversal is cancelled at every timeout tested; the previous settings ran
a single beam straight through a 50 ms timeout without yielding.

Plan shape of the rescue is a bounded parallel top-N heapsort at every permitted-set size tested,
with no correlated subquery in it — it does not depend on the planner's estimate for one.

Tests

vector.probe timing out no longer fails the leg, so it moved out of the shared partial-retrieval
case and into its own test. New tests cover the rescue firing, not firing on a filled traversal,
declining past the probe bound, and the max_scan_tuples / ef_search relationship. Each was
verified to fail when the corresponding change is reverted.

Type of Change

  • Bug fix

Testing

lib/knowledge + app/api/knowledge suites pass (3,117 tests). Each new test was verified to fail when its change is reverted. bun run lint, check:audits (46 audits), docs-manifest:check, block-registry check and type-check all pass. No schema or migration changes.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

pgvector's HNSW index post-filters by construction, so a visibility predicate
can only discard neighbours the graph walk already committed to. When the
documents a caller may read are a small share of the index, the traversal
returns a handful of candidates instead of its limit.

Rank the permitted set exactly when the traversal comes back underfilled and a
bounded probe says that set is small enough to afford. A traversal that fills
its limit is returned untouched, so a scope the graph serves well pays nothing.

Bound the probe by documents examined rather than chunks accumulated, and give
it its own sub-budget so deciding against a rescue can never cost the leg its
results.

Retune the scan settings so the tuple budget sits an order of magnitude above
the beam, which is what lets the iterative scan iterate at all, and so the
narrower beam lowers the leg's uninterruptible floor.
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Sep 18, 2026 9:02am UTC

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previously reported disabled-chunk probe inflation is fixed and no new actionable failure remains.

Summary

The PR improves vector-search recall when pgvector’s HNSW post-filter underfills the candidate set.

  • Retunes HNSW traversal so iterative scanning can progress with a smaller cancellation floor.
  • Adds a document-bounded, separately budgeted visibility probe.
  • Exactly ranks tractable permitted document sets before the existing rerank, live-authorization, and hydration stages.
  • Requires enabled chunks when structured tags determine whether a document consumes the probe bound.
  • Expands diagnostics and tests for rescue, timeout, authorization-refill, and scan-setting behavior.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Start vector retrieval] --> B[Bounded HNSW traversal]
    B --> C{Candidate limit filled?}
    C -- Yes --> G[Rerank bounded candidates]
    C -- No --> D[Probe visible documents<br/>under capped deadline]
    D --> E{Probe tractable and nonempty?}
    E -- Too large or timed out --> G
    E -- Empty --> H[Return no candidates]
    E -- Yes --> F[Exact rank permitted documents]
    F --> G
    G --> I[Verify live source authorization]
    I --> J[Hydrate under full visibility predicate]
    J --> K[Return results]
Loading

Reviews (3) · Last reviewed commit: "fix(knowledge): require an enabled chunk..."

Comment thread apps/sim/lib/knowledge/search/queries.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/knowledge/search/queries.ts
Comment thread apps/sim/lib/knowledge/search/queries.ts
…document

A document whose only tagged chunk is disabled could be admitted by the probe
and then discarded by ranking, spending the probe's document bound on a
document that can contribute no candidate.

Update the KB block fan-out integration test to the strategy it now exercises:
a scope too small to fill the traversal probes once and rescues once.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@waleedlatif1
waleedlatif1 merged commit 75156bc into staging Sep 18, 2026
34 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/vector-leg-budget branch September 18, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant