Repository navigation
perf(memory): Use the HNSW index for public memory recall - #2596
Merged
Merged
Conversation
The planner does not cost TOAST reads of stored vectors, so recall over the public scope computed the distance to every stored vector. Take the nearest candidates from the HNSW index in a transaction that turns off the other scans, then apply the model, visibility, and cutoff filters. Private-only searches keep the exact query. Co-Authored-By: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
thomasjiangcy
approved these changes
Nov 3, 2026
This branch was successfully deployed
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.
Recall's vector query brute-forces every stored vector when the scopes include the public scope. On prod (pgvector 0.8.0, ~48.7k active memories, 26k public),
EXPLAIN ANALYZEshows the planner computing the distance for allvoyage/voyage-4-liteembeddings (bitmap scan onjunior_memory_embeddings_model_idxor a parallel seq scan, then a sort), reading ~215k buffers. That takes ~0.36–0.39s on the server and ~0.43s p50 in the recall span. The embeddings are TOASTed, and the planner does not cost TOAST reads, so brute force always looks cheaper than the HNSW index. Fixing row estimates did not change the plan.When the scopes include the public scope and the query vector is 1024-d (the only size with an HNSW index, migration 0013), the vector search now runs an ANN-first query in a transaction:
set_config('enable_seqscan', 'off', true),set_config('enable_bitmapscan', 'off', true), andset_config('hnsw.ef_search', K, true)in one statement.set_configtakes bind params, so this is one round trip, not threeSET LOCALs.WHERE dimensions = 1024 ORDER BY embedding::vector(1024) <=> q LIMIT K, withK = max(200, limit × 5). Its only filter is the index predicate. Adding the model filter here broke the plan on prod.On prod, Thomas forced this query by hand. It used
junior_memory_embeddings_embedding_1024_hnsw_idx, took ~4ms of search work, and returned all 40 rows. For 10 probe vectors, its top 40 matched the exact top 40 (40/40). Those probes were stored vectors, which are easier than real prompts. Without forcing, the planner still picks a parallel seq scan (393ms). Expected effect (an estimate): recall p50 drops from ~1.15s to ~0.75s.Local check against the
compose.yamlpgvector image, with 48.7k clustered 1024-d vectors (26k public): the ANN query took ~5ms against ~170ms for the exact query. Its top 40 overlapped the exact top 40 in 677 of 680 slots, on fresh and stored query vectors.What stays the same:
hnsw.iterative_scan: when fewer rows than the limit pass the cutoff, it scans up tomax_scan_tuples(4ms → 250ms in a local test).Cost: the public vector query now uses its own pooled connection and needs BEGIN, set_config, SELECT, and COMMIT. That is 3 more round trips to Neon. Transactions already work through the Neon Pool driver: drizzle checks out a client for
db.transaction, as the memory write path does.Risk: ANN results are approximate. Other Users' private memories and other 1024-d models use part of the K budget before filtering. So a search can return fewer than
limitrows when most of the nearest vectors are hidden (for example, while two 1024-d models coexist during a reembed).Follow-up:
listVectorAdjudicationCandidates(write path) probably has the same brute-force plan for the public scope. This PR does not change it.via @thomasjiangcy.
--
View Session [Sentry]