A vector index is built by a job - #566
Merged
Merged
Conversation
This was referenced Sep 9, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
WaylandYang
force-pushed
the
feat/a-vector-index-is-built-by-a-job
branch
from
September 9, 2026 14:04
8a45845 to
72cbcb5
Compare
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.
Decision record 0035, implementing #512 and #514 in one cut because
entities.profile_embeddingtakes the same mechanism aschunks.embedding.The index is built by a job, not a migration. The column is dimensionless and the dimension follows the workspace's embedding model, so nothing is known at migration time; and
CREATE INDEX CONCURRENTLYcannot run inside the transaction sqlx wraps migrations in.vector_index::requestis called by the first write of a dimension (set_embeddings,update_profile) and queues onebuild_vector_indexjob unless one is queued; the job builds a partial expression index,USING hnsw ((col::vector(N)) vector_cosine_ops) WHERE vector_dims(col) = N,CONCURRENTLY IF NOT EXISTS, on a plain connection, serially, withmaintenance_work_memraised for the session. An invalid leftover from a failed build is dropped first. Dimensions above 2000 (HNSW's limit onvector) are never requested and stay on the exact path.The two reads write the dimension as a literal, cast both sides, and run in a transaction with
hnsw.iterative_scan = relaxed_order,hnsw.scan_mem_multiplier = 4andhnsw.max_scan_tuples = 100000. A bound parameter for the dimension works on the custom plan and falls back to a seq scan on the generic one, which sqlx switches to after five executions.nearest_typed_entitiesreads the subject's vector first so the dimension is known, and gains a dimension guard it never had.Type resolution gathers before it reasons (#514).
nearest_typed_for_eachruns a batch's neighbour queries eight at a time withbuffered, so they come back in input order and the per-subject reasoning stays in that order;DescendantsMemoanswersdescendants_ofonce per coarse class per batch, and a subject with no class stays out of the memo.Measured
On a copy of a bench base (531 real chunks, 3,269 real profiles, 1024 dims) with a synthetic base of 50,000 chunks beside them:
vector_searchon the 50k base, beforemaintenance_work_mem64 MB (default)iterative_scanoffscan_mem_multiplier4max_scan_tuples100,000chunks_kb_idxand a sort, 1.5 ms; the 50k base takes the HNSW path, 3.8 msSo the memory multiplier is what stops an iterative scan short, the tuple ceiling is not, and the planner keeps small bases on the exact path on its own. The full EXPLAIN (ANALYZE, BUFFERS) before and after are in the record's measurements.
Recall bar proposed: recall@10 of 0.99 against the exact top ten on real embeddings, measured with the index forced, which this passes on both tables once the scan memory is raised. Random vectors (16/24 in the earlier synthetic run) are the wrong corpus for the question.
Tests
Database-gated, run with
UTOPIA_TEST_REQUIRE_DB=1. Every question is asked twice, without the index and aftervector_index::build, and the answers must agree:the_nearest_chunk_is_found_however_it_is_reached: the nearest chunk, a base of one beside a base of 2,000 filling its limit, tenancy, a 5-dim chunk among 7-dim ones, a superseded chunk, a moment on the record axis; and the index's name,USING hnsw,vector_cosine_opsand the partial predicate, with a second build a no-op.a_batch_gathers_its_neighbours_in_order: the batch comes back in input order at concurrency 1 and 8, the same with the entity index built; a subject is not its own neighbour, an untyped entity is never evidence, a same-document neighbour is flagged, a 3-dim subject finds only 3-dim neighbours and does not error; a two-connection pool completes sixty subjects; the memo returns what the query returns, leaves a classless subject out, and does not notice a class added mid-batch.a_vector_index_is_built_by_a_job: the first write queues one build, the second does not, a built index is remembered, a dimension above the limit is neither queued nor buildable and fails asValidation, which the dispatcher marks terminal.a_search_reads_the_base_as_it_waspasses unchanged.Not written:
a_signature_is_guidance_and_not_a_gatefrom #514, which is the candidate-ranking logic in the server loop and unchanged here.Closes #512. Closes #514.
🤖 Generated with Claude Code