improvement(knowledge): match keyword chunks before authorizing them - #7973
Merged
Merged
Conversation
The org-scoped keyword leg built its candidate set in the order visibility -> match -> rank. The visibility predicate carries a correlated subquery per connector plus a search-integration check, so evaluating it across the base before the query terms were consulted priced every search by how many documents the base holds rather than by how many the query matched. Reorder to match -> authorize -> rank. The match stage carries chunk identifiers only, the identical visibility predicate then runs over just the documents that matched, and ts_rank_cd is computed for the matches that survive it. Same predicate, same ordering, same page contract. Two details keep the reorder from paying the saving back. Restricting the predicate with `document.id = ANY (...)` rather than a subquery keeps the narrowed lookup on a bitmap scan, which prefetches, where a plain `IN (SELECT ...)` plans as an index walk that does not. And ranking in the match stage rather than after authorization would detoast one text-search vector per match, which on a mid-frequency term costs more than the pass it replaces.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Collaborator
Author
Collaborator
Author
|
@cubic-dev-ai review this PR |
Contributor
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Contributor
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The org-scoped keyword retrieval leg built its candidate set in the order visibility → match → rank:
visible_keyword_documents— every document in the knowledge base that passes the visibility predicate. No limit, and not restricted to documents the query matched.scored_keyword_candidates— the full-text match plusts_rank_cd, filtered to those document ids.ranked_keyword_candidates— order by rank, take the page.That predicate carries a correlated subquery per connector plus a search-integration check, so step 1 is a full pass over the base evaluated before the query terms are consulted. The cost of a search is therefore set by how many documents the base holds, not by how many the query matched — the same search costs about the same whether it matches a few thousand chunks or a few hundred thousand.
Change
Reorder to match → authorize → rank:
matched_keyword_chunks— the full-text match, chunk identifiers only.visible_keyword_documents— the identical visibility predicate, restricted to the documents that matched.ranked_keyword_candidates—ts_rank_cdfor the matches that survive authorization, then the existing order/limit/offset.Same predicate, same ordering, same page contract, no schema or write-path change.
Two details keep the reorder from paying the saving back, both measured:
document.id = ANY (ARRAY(...))rather thanIN (SELECT ...)keeps the narrowed lookup on a bitmap scan, which prefetches. The subquery form plans as a nested-loop index walk that does not, and on a cold cache it gives back most of what the smaller candidate set saves.The
EXISTS (SELECT 1 FROM visible_keyword_documents)conjunct is dropped: it sat besidedocument_id IN (SELECT id FROM visible_keyword_documents), and with a non-nulldocument_idtheINalready yields no rows when that set is empty. Verified by running both shapes under an access scope that grants nothing — 0 rows from each.Measurements
Synthetic corpus shaped like a large search index (a few million chunks over a base of documents behind a mix of workspace, admin-verified and member-observed connectors), Postgres 17,
shared_bufferssized to about 8% of the working set. Buffer counts are the hardware-independent figure; wall-clock is warm, single machine, and is direction only.Paired warm runs, base vs this change:
Cold (shared buffers and page cache dropped, single shot):
Reading of that: at the selectivity a real query has, the leg stops reading altogether — the candidate set is small enough to sit in cache, and total buffer touches fall by more than an order of magnitude. As the term matches more of the corpus the two shapes converge, because the full-text scan itself becomes the cost and neither shape can avoid it. The worst cell measured is 1.02x slower, inside this machine's run-to-run spread (the same base query measured between 3.3 s and 17.8 s across runs on a term matching a third of the corpus).
An earlier variant that ranked inside the match stage and capped it was measured as slower than today between roughly 150k and 350k matches — that is the shape this PR deliberately does not use, and why
matched_keyword_chunkscarries identifiers only.SELECT DISTINCTinside theARRAY(...)was measured and made no difference at any selectivity (identical buffer counts); Postgres already aggregates the array for the bitmap, so it is not included.Correctness
EXCEPTin both directions against the previous statement, 0 rows each way, across six configurations: 39,650 / 168,188 / 335,893 / 1,191,775 matching chunks, page sizes 50 and 200, and a page at offset 1,000. No cap and no truncation anywhere in the new shape, so the result set is identical by construction rather than by sampling.Tests
New unit test asserts the ordering contract — the match stage precedes the visibility stage, carries no rank, and the visibility stage is restricted to matched identifiers. Mutation-verified three ways: reverting the source fails it, moving the rank back into the match stage fails it, and swapping
= ANY (ARRAY(...))for a subquery fails it. Three existing statement markers moved to the new CTE name, including the staging-only latency harness.apps/sim/lib/knowledge(2,933 tests) andapps/sim/app/api/knowledge(187 tests) pass.