fix(knowledge): recover vector candidates an HNSW post-filter discards - #7971
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
|
There was a problem hiding this comment.
All reported issues were addressed across 6 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…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.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Why
pgvector's HNSW index post-filters by construction.
hnswgettuplenever reads the scankeys
hnswrescanwrites, the access method declares no scan strategies, and there is noamgetbitmap— so a visibility predicate cannot narrow the graph walk. It can only discardneighbours 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_tuplesexcludes the initial beam, so pairing a1,000-wide
ef_searchwith a 1,000-tuple budget spent the whole budget inside the first beam:ResumeScanItemsnever ran and the iterative scan never iterated. And a beam is uninterruptible— pgvector calls
CHECK_FOR_INTERRUPTSonly in its index-build paths, never inhnswgettuple— 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
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_idxand costs what the permitted set costs rather thanre-deriving permission across the index. It also honours
statement_timeout, which atraversal cannot.
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.
Retuned the scan settings:
ef_search1000 → 200,max_scan_tuples1000 → 20000. Thetuple 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.
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_timeoutwithin1.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.probetiming out no longer fails the leg, so it moved out of the shared partial-retrievalcase 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_searchrelationship. Each wasverified to fail when the corresponding change is reverted.
Type of Change
Testing
lib/knowledge+app/api/knowledgesuites 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 andtype-checkall pass. No schema or migration changes.Checklist