perf(migrations): add HNSW ANN indexes for embedding tables - #213
xiaocui-big wants to merge 6 commits into
Conversation
Add pgvector HNSW indexes on embeddings_text (768-d), embeddings_visual (512-d), and embeddings_face (512-d) using vector_cosine_ops to match the <=> cosine distance operator used by search and relator queries. Previously all vector queries performed exact sequential scans of the full embedding table. The dimension blocker cited in the original DDL comments has been resolved: all three tables now use fixed-dimension columns declared in the schema. Resolves: bytefolk#173
The dimension blocker cited in this comment has been resolved: embeddings_text uses a fixed vector(768) column. The HNSW index is now created by migration 0024. Refs: bytefolk#173
Scope the dimension-specific ANN guidance to index_generation_vectors explicitly, and note that the legacy embedding tables now have HNSW indexes (migration 0024). The versioned table index is deferred until the generation executor exists. Refs: bytefolk#173
CREATE INDEX CONCURRENTLY cannot execute inside a transaction block. Add goose NO TRANSACTION annotation so the migration runs without transaction wrapping, allowing concurrent index builds that do not block writes on populated embedding tables. Refs: bytefolk#173
Migration 0024 adds HNSW ANN indexes for the three embedding tables. Refs: bytefolk#173
|
Closing as duplicate of the existing #173 track. This change independently takes migration
Merging this PR would collide with that sequence. Please continue on #197 rather than a parallel 0024 HNSW migration. Thank you for the implementation — the HNSW/ |
waterbro-8
left a comment
There was a problem hiding this comment.
Review (source + #173 AC audit)
Verdict: do not merge / do not treat as satisfying #173. Operator class and table set are pointed the right way (hnsw + vector_cosine_ops on the three fixed-dimension embedding tables, index_generation_vectors left alone). The shipping query shapes, the validation ledger, and the migration number do not meet the issue.
This PR is already closed as a duplicate of the #173 track. That close is still correct: #194 merged 0024_files_lexical_search.sql, and main now has EXPECTED_MIGRATION_HEAD=24. The live successor is #197 (0025_ann_hnsw_indexes.sql), which is itself HOLD on the text planner AC. Continue there, do not revive this 0024.
I did not run EXPLAIN against a populated mem database in this review. Planner claims below are from the shipping SQL in search.go / relator.go / face.go on main, plus the PostgreSQL 16.14 / pgvector 0.8.2 measurements already recorded on #197.
Blocking
-
Migration number collision. This change takes
0024. That number is already shipped by #194. Freshmaincannot apply this file. -
Text planner AC cannot pass with the current SQL, and the EXPLAIN snippet in the PR body is wrong. Shipping text search is:
SELECT DISTINCT ON (f.id) ... FROM embeddings_text e JOIN files f ON f.id = e.file_id WHERE f.user_id = $2 AND … ORDER BY f.id, e.embedding <=> $1::vector ASC
HNSW is usable for
ORDER BY embedding <=> $1 LIMIT n. It is not usable whenDISTINCT ON (f.id)forcesORDER BY f.id, distance. Relator text (recomputeText) is the same shape. The PR’s “expect Index Scan using idx_embeddings_text_embedding_hnsw” for that query will not hold. #197 already reproduced seq scan on this exact shape; a naive rewrite also drops recall when one file owns many near chunks (ef_search=40can collapse to 1 file vs 10). Query rewrite is out of scope here, so this PR cannot honestly tick the planner AC. -
#173 acceptance is mostly unchecked.
- ANN DDL for the three tables: would be OK if numbered correctly.
- EXPLAIN on a populated corpus recorded in the ledger: not done (ledger is Pending / Not affected). No CI on this head.
- Ingest on a populated table + inconsistent-dimension failure mode:
CONCURRENTLYis an attempt at the lock problem; the failure mode is not stated. - Recall before/after, or a link to the harness issue: V10 is marked “Not affected”, which sidesteps the AC. Point at #175 / #184 / #196 instead of claiming N/A.
SPEC.md:537is untouched (comment-only edits on 0001/0019). Fine once real DDL exists; not sufficient alone.
-
CHANGELOG overclaim. “Vector search queries now use approximate nearest-neighbor … instead of exact sequential scans” is false for the text route, which is the primary search path. Visual
ORDER BY e.embedding <=> $1 LIMIT ncan use the index (subject touser_id/path/MIME filters and corpus size). Face has no SQL<=>search: clustering is in-process overAVG(embedding)centroids (face.go), so an HNSW index onembeddings_facedoes not speed the current path.
Important (if this work is retargeted onto #197)
-
CREATE INDEX CONCURRENTLY+ gooseNO TRANSACTIONis the riskier of the two options. A failed concurrent build leaves anINVALIDindex;IF NOT EXISTSthen no-ops and the migration looks green. Down drops withoutCONCURRENTLY(write lock). Many test/goose runners wrap migrations in a transaction, which rejectsCONCURRENTLY. #197’s transactionalCREATE INDEX IF NOT EXISTSplus an explicit “maintenance window / write lock” note is the sounder default until there is a production rollout playbook that checkspg_index.indisvalid. -
No index-validity / planner regression in-tree. #173 asked for a recorded EXPLAIN. This PR only pastes unverified SQL. Need a check that (a) the three indexes exist and are
VALID, (b) visual cosine-order uses HNSW on a populated corpus, (c) text DISTINCT ON is reported as still seq-scan until a rewrite with a continuation/fallback policy exists. Do not weaken that last item to pass.
What is fine
vector_cosine_opsmatching<=>.- Leaving
index_generation_vectorsunindexed (undimensioned, empty, no executor). - Not changing distance, model, chunking, ranking, or generation lifecycle.
- Default
m=16,ef_construction=64until there is a measured corpus. - Comment cleanup on 0001 / 0019 (goose does not checksum like Flyway).
Suggested next step
Do not reopen this PR. Fold any remaining useful bits into #197, keep 0025, and treat the text DISTINCT ON planner failure as an explicit remaining AC rather than a migration-only merge.
## Summary Completes the work [#213](#213) left undone against [#173](#173). - Migration **0025** (0024 is already lexical from #194) adds cosine HNSW indexes on `embeddings_text` (768), `embeddings_visual` (512), and `embeddings_face` (512) with `vector_cosine_ops`. - Shipping **text** search no longer uses `DISTINCT ON (f.id) ORDER BY f.id, distance` as the primary plan. That shape cannot use HNSW. It now walks `ORDER BY embedding <=> $1 LIMIT n`, keeps the first sighting of each file, excludes selected files, and **falls back** to the original exact `DISTINCT ON` query if a bounded scan underfills (one file owning many near chunks). - Relator text neighbors use the same continuation/fallback. - Visual already matched HNSW. Face clustering stays in-process; the face index is DDL only. - `index_generation_vectors` is not indexed (scope boundary). - Transactional `CREATE INDEX` (not `CONCURRENTLY`): a failed concurrent build leaves an `INVALID` index that `IF NOT EXISTS` will skip. - Wrong dimensions fail at the `vector(N)` column. Recall is **not** claimed; harness is [#175](#175). Does not reopen #213. #197 remains the earlier HOLD attempt; this branch is based on current `main` (schema 24) and takes 0025. ## Changes - `server/internal/db/migrations/0025_ann_hnsw_indexes.sql` - `server/internal/search/search.go` — text continuation + exact fallback - `server/internal/relator/relator.go` — same policy - `TestHNSWMigrationPostgres` — populated 24→25→24→25, ingest, dimension rejection, EXPLAIN - `TestTextANNFileSemanticsPostgres` — 101-chunk file still returns 10 eligible files - `scripts/verify_hnsw_indexes.sh` + `scripts/verify.sh` head 25 - `docs/VALIDATION_HNSW.md`, SPEC, CHANGELOG ## Validation ledger | ID | Criterion | Command | Status | | --- | --- | --- | --- | | V1 | Server builds, unit contracts | `make test-server` | Pending CI (sandbox has Go 1.22; module requires 1.25) | | V2 | Worker | `make test-worker` | Not affected | | V3 | Web | `make test-web` | Not affected | | V4 | Race | `make test-race` | Pending CI | | V5 | Fresh schema, rollback, PostgreSQL | `make test-integration` | Pending CI — `EXPECTED_MIGRATION_HEAD=25`; `TestHNSWMigrationPostgres` + `verify_hnsw_indexes.sh` | | V6 | DB race | `make test-integration-race` | Pending CI | | V7–V9 | Acceptance / MCP / visual quality | — | Not affected | | V10 | Recall | `make test-recall` | Not measured. Recorded harness: #175 | | Text semantics | 101-chunk file still yields k files | `TestTextANNFileSemanticsPostgres` | Pending CI | | Planner | EXPLAIN uses HNSW for text cosine-order and visual | `TestHNSWMigrationPostgres` + `scripts/verify_hnsw_indexes.sh` | Pending CI. No `enable_seqscan=off`. | Local sandbox could not run Docker or download Go 1.25, so EXPLAIN was not executed here. CI `memory-validation.yml` / `verify.sh integration` is the evidence path. ## Design notes - Defaults `m=16`, `ef_construction=64`. No iterative-scan GUC. - Empty `uuid[]` exclude lists are never sent as SQL NULL (`ANY(NULL)` would drop all rows). - Editing 0001/0019 is comment-only. Goose does not checksum like Flyway. Fixes #173 --------- Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com>
Summary
Add pgvector HNSW indexes on
embeddings_text(768-d),embeddings_visual(512-d), andembeddings_face(512-d) usingvector_cosine_opsto match the<=>cosine distance operator used by search and relator queries.Previously all vector queries performed exact sequential scans of the full embedding table. The dimension blocker cited in the original DDL comments has been resolved: all three tables now use fixed-dimension columns declared in the schema.
Resolves #173
Changes
0024_add_hnsw_ann_indexes.sql: Creates HNSW indexes on all three embedding tables usingCREATE INDEX CONCURRENTLY(with gooseNO TRANSACTIONannotation) so the index build does not block writes on populated tables.0001_init.sql: Removed the stale deferral comment ("HNSW index will be added by worker once we settle on a model dimension").0019_versioned_index_generations.sql: Scoped the ANN guidance toindex_generation_vectorsexplicitly and noted that the legacy tables now have HNSW indexes via migration 0024. The versioned table index remains deferred until the generation executor exists (per the issue's scope boundary).scripts/verify.sh: BumpedEXPECTED_MIGRATION_HEADfrom 23 to 24.CHANGELOG.md: Added[Unreleased]entry.Design notes
vector_cosine_opsmatches the<=>operator used insearch.goandrelator.goqueries. pgvector will use these indexes forORDER BY embedding <=> $1 LIMIT npatterns.m=16,ef_construction=64). These are appropriate for the personal-corpus scale (tens of thousands of vectors) that this server targets.index_generation_vectors: Deliberately not indexed in this change. The table is empty today (no generation executor yet), uses undimensionedvectortype, and building an index on it would prove nothing about the planner. This matches the scope boundary stated in perf(index): add an ANN index so vector search stops scanning every embedding #173.CONCURRENTLY: Requires running outside a transaction block, hence the-- +goose NO TRANSACTIONannotation. This ensures the migration can run on populated tables without blocking ingest.Validation ledger
make test-servermake test-workermake test-webmake test-racemake test-integrationEXPECTED_MIGRATION_HEADbumped to 24make test-integration-racemake test-acceptancemake test-agent-certificationmake test-recallgo testEXPLAIN verification (requires populated database)