Repository navigation
v0.60.127.0 perf(postgres): index-walk vector search, cheap private-page rule, keyed held-page join, exact-first get_page - #6364
Merged
Conversation
…yed held-page join Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
|
😎 This pull request was merged. |
…private-page rule and the vector index walk Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
…SIGTERM lockfile race in bounded-child-exec Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
…wave Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
…s delete mid-walk Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
… ahead of GBRA-68's wave Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
… and restamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
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.
Query-plan wave from the Efficiency crew (GBRA-67), based on GBRA-66's profile. I reproduced every claim on synthetic brains before fixing it. Brain data stays off this PR: the fixtures are synthetic, generated with the shape of a 5k-page brain. The bench harness (#6357) is not included.
Setup: 4 vCPU / 16 GiB, Postgres 16.15 + pgvector 0.8.7 (Docker), Bun 1.4.2. Brains are 5,001 pages / 25,331 chunks and 50,010 pages / 248,802 chunks, with 1024-dim vectors clustered by page type. All runs are warm, N=25, times in ms as p50/p95. Recall is measured against
exactSql(exact scan).1. Vector search walks the HNSW index first
What reproduced. The joined candidate statement's plan sits on a cost knife edge, for three reasons:
hnsw.ef_search(the window).On the 5k brain it flips to a parallel seq scan plus an on-disk sort at 50 results. For remote readers it filters every page up front and nested-loops the chunk scan against that list.
Fix.
indexWalkSql(ann) orderscontent_chunksalone with only the chunk-level filters, over twice the window. It then joins pages and sources by key and applies every page filter, visibility rule and the private-page rule, keeping the nearestinnerLimit. It runs withenable_sort = off, so the HNSW scan is the only ordered path, and withef_searchsized for the window. A full window that fills the limit is the answer. Anything else falls back to the unchanged joined statement and its escalation path. Type and date filters skip the walk.EXPLAIN ANALYZE, 5k brain, remote reader, limit 50:
At 50k the planner already uses the index, so the joined statement runs in 45 ms and the walk in 56 ms.
Engine
searchVector(warm, p50/p95, recall in parentheses):At 50k the trade is honest: the walk costs 6 to 15 ms more at p50 because it reads twice the window, and recall rises 14 to 16 points. A selective source filter pays about 25 ms for the short walk before falling back, with identical results. The type-filter row's 50k win comes from fix 2.
PGLite 5k shares the statement builder, so I measured it too (p50/p95):
2. Private-page rule no longer trips JIT
What reproduced. The declared-lineage arm used
slug IN (SELECT ... jsonb_array_elements_text(...)), which the planner prices as a 100-row semi-join per page. Every read carrying the rule crossedjit_above_cost, so Postgres compiled it on every call.list_pagesspent 37 to 46 ms on a statement that runs in under 1 ms. The backlink count in search and query was worse: 265 ms at 5k and 571 ms at 50k, nearly all of it JIT (pg_stat_statementsjit_functions20k to 23k per 84 calls).Fix. The arm is now
slug = ANY(ARRAY(SELECT ...)). It returns the same rows, the estimated cost per page drops, and no statement needs a JIT-off wrapper. After the change, no statement in the hot-path suite showsjit_functions> 0.list_pagesover MCP (warm stdio)searchover MCP (with Voyage query embedding)recallover MCP3. Held-page read joins pages by key
readHeldPages(everyget_pageand every search result set) joined onp.id::text = page_id, sopagescould only be scanned. Whether that cost anything depended on small-table statistics. Right after import, it seq-scanned pages once per source: 17 ms at 5k in EXPLAIN ANALYZE, and a 12.8 to 13.9 ms mean in pg_stat_statements during the warm MCP run. Oncesourcesandop_checkpointswere analyzed, master's planner skipped the probe and ran in about 1 ms, so I could not reproduce the slow case at 50k. The join is nowp.id = (page_id)::int, a primary-key probe in every statistics state (0.5 to 1.2 ms). MCPget_pageat 50k measured 25.3/40.3 before and 12.8/18.3 after.I also grepped for the same
id::textjoin pattern. The other hits are tiny tables (sources,persistence_worktrees), a doctor-only join and a one-time migration, so I left them alone.4. get_page looks up the exact slug first (GBRA-66 #4)
An alias-resolving
readPageSnapshot(get_page,fetch) used one statement withslug = $1 OR EXISTS (alias), which scans the source's pages. It now runs the exact-slug statement first and the alias statement only on a miss. The alias statement'sORDER BY (p.slug = $1) DESCalready ranks any exact match above every alias match, so a hit returns the same row, andresolved_slugis unchanged.requireUnambiguous(the put_page identity path) still runs the alias statement alone, because its count includes alias matches.get_pageover MCP, warm (includes fix 3)At 50k I measured 1.8 ms, not GBRA-66's 23–80 ms; the plan depends on statistics, as in fix 3.
Not shipped: expansion overlap (GBRA-66 #10)
I implemented it: the expansion call and the original query's embed and vector search start before the lexical arms, and a test proved identical results and meta. I measured no gain, though, so I took it out of this PR. In-process A/B on Postgres with real Voyage embeddings and a fixed 800 ms expansion, N=25: 1378/1565 with the overlap against 1365/1462 without at 5k, and 1396/1635 against 1424/1777 at 50k. Over MCP with the real LLM (N=25, two rounds each), p50 was 2209/2268 with the overlap against 2154/2203 without. The traces show the overlap does start the original embed alongside the expansion, but the post-expansion stages took longer by about the same amount. The patch is parked for a follow-up.
Flake fix
test/bounded-child-exec.test.tsfailed once in the Ubicloud gate. The SIGTERM test slept 200 ms and expected the child's lockfile to exist; on a loaded runner the child had not started. The child now installs its trap before writing the lockfile, and the test waits for the lockfile. Forced probe: delaying the child by 0.5 s fails the old test and passes the new one.Tests (results unchanged)
test/search/vector-index-walk.test.ts(PGLite, new) runs both statements with index scans disabled, so each computes its window exactly. It asserts a full walk window returns the joined statement's rows row-for-row, order and pool counts included. The filter matrix covers private-page rules, archived, quarantined and deleted pages, stale hashes, granted sources, slug exclusion anddetail: low. It also checks that a selective source leaves the walk short andsearchVectorreturns the joined result. A mutation that drops visibility from the walk fails 8 of 9 cases.test/search/vector-statement.test.tspins the walk's shape:anntouches onlycontent_chunks, page filters sit after the key joins, parameters are shared, and the walk is absent for the legacy guard, wide columns and type or date filters. The existing master statements are byte-identical across both dialects and every option combination.test/e2e/vector-plan-real-column-postgres.test.tsnow explains the walk and asserts it usesidx_chunks_embeddingfor remote readers and 50- and 200-result windows.test/e2e/private-visibility-plan-postgres.test.ts(new) asserts both spellings hide the same pages, in the same order, across every rule. That covers explicit private, atoms with a private origin, synthesized concepts,derived_fromas string, array and.md, missing and cross-source lineage, and life-chronicle events. It also checks the planner's cost per page is now underjit_above_cost / 500(147 against 296), with a precondition that fails if the old spelling would pass.test/page-snapshot-exact-first.test.ts(new) covers exact hits (one statement, no alias lookup), alias fallback, an exact page in another source outranking an alias, deleted and private exact pages, the ambiguity check and a miss. The existing alias-redirect suite (test/get-page-federated-scope.test.ts) passes unchanged.searchVectornow runs one more scoped read (the walk).test/held-read-signals.test.tsasserts the held-page read returns the hold and its revision, and that its plan probespages_pkey. Forced probe: this fails on the text-cast join and passes after.Not fixed here (bigger; in the GBRA-67 report)
sync-movementwriter-status query (persistence_requestscount withintent->>'kind' LIKE): 132 ms at 5k and 836 ms at 50k per call duringserve. It is not on the per-call read path.UPDATE pages SET last_retrieved_aton reads:last_retrieved_atis indexed, so these updates are never HOT.content_chunks.embeddingstorage: TOASTed vectors are why the seq-scan cost is underestimated.SET STORAGE MAINplus a rewrite would make plans honest, but it changes the schema and needs a migration.