ADR-0026: A vector column declares its index, and similarity search is its own operation
- Status: Working for the column, Exploring for the index. The unindexed
half is built —
schema.Vector(name, dim),vector(n)inDiffwith the extension ahead of it, the introspect mapping,sqlb.Vectorwith pgvector’s binary codec, andNear— and that half is a complete configuration rather than a half-built feature. The index kind, the metric declaration andPOST /resource/searchare unbuilt and not in 1.0 - Confidence: Medium, raised from Low. The three physical claims this record
reasons from have been measured against pgvector 0.8.6
(
pgtest/pgvector_test.go) and all three hold, so the indexed half is no longer argument. What keeps it off High is the regime rule: the collision this record listed as its most likely invalidator turns out to be deployed rather than hypothetical, and the third branch below is written but unbuilt and untested - Decided: 2026-07-28
- Last reviewed: 2026-07-30 (read against the two port reports and ADR-0040)
Context
Section titled “Context”There are no vector references in the tree, and the feature fails in three
places: migrate.sqlType ends in unknown type %q, so no declaration renders —
not even the OfType escape hatch; migrate.createIndex has no operator class
and no WITH, so USING hnsw renders and means nothing; and introspect
refuses a vector column, so adopting an existing pgvector database drops the
embedding.
The query half already “works”: sqlb.OrderBy(sqlb.Raw{SQL: "embedding <-> ?"})
compiles and returns correct rows — by sequentially scanning the table. That is
the problem, not the reassurance. A vector feature built on Raw looks
finished and is a table scan.
The sqlc split does not rescue this. Window functions go to Raw or to sqlc
because they are a query shape; a vector is a column, and the schema is
sqlb’s. sqlc types its queries against schema.sql, which migrate.Diff renders
from a declaration that cannot express the column. Observed, not predicted:
core/rag hand-maintains a mirror schema.sql because its migration uses a
%%EMBEDDING_DIM%% sentinel sqlc’s parser cannot tolerate. The dimension wants
to be a value, and SQL text has nowhere to put one. Either sqlb learns the
type, or the arrangement this project documents stops holding for any project
with an embedding column.
What a real one looks like. core/rag has no vector index — every search
is an exact sort over the rows a mandatory tenant scope already selected, which is
the right shape at that size — and its filters are open-ended JSONB
containment, because a caller attaching whatever it likes is the feature. Set
against Convex’s filterFields, which insists every filterable key be declared,
these look like opposite philosophies. They are the two ends of one axis, and the
axis is whether the index is approximate.
An ANN index is not an index in the sense the rest of the schema means. A
btree is an optimisation the planner may ignore. HNSW changes the answer (recall
is a tuning parameter), serves only ORDER BY <op> LIMIT k, and is built for one
metric — an index created vector_l2_ops does not serve a <=> query, and
Postgres does not complain, it sequentially scans. A metric typo is a correct
answer a thousand times slower, which no test asserting on rows can see.
Filters and approximate search fight, silently. An HNSW scan finds k
candidates and the WHERE runs over them, so a filtered search comes back short
with no error. Convex can enforce its restrictions because it owns its storage
engine; sqlb sits on Postgres, which will cheerfully run the incoherent query.
Anything this project wants enforced, it must refuse itself.
The three claims, measured
Section titled “The three claims, measured”This record’s own What would change our mind put the physical claims first and
said testing them was the work before the DSL. That is done, against pgvector
0.8.6, in pgtest/pgvector_test.go. All three
hold. Four things about them were not known when this was written.
No number in that file is a recall figure, and this record must not quote one as if it were. The first version of those tests measured uniform-random vectors and found a filtered search returning 6 of 10, and 0 of 10 once the filter was selective. Rewritten with a deterministic corpus so the gate would not flake, the same query returned 10 of 10 — and an empty-table IVFFlat index, which had returned nothing, answered in full. Neither run was wrong. Recall depends on the corpus, on the planner’s costing, and on HNSW’s own build randomness, since an index built twice over identical rows is not the same graph. The tests therefore force the mechanism rather than sample it: the rows the filter admits are placed opposite the probe, so the failure is a demonstration and not a statistic. The draft above said “2 of 10”; it should have said “fewer, and you will not be told”.
The planner may decline the ANN index, and did. Under a selective filter it costs the index above a sequential scan and chooses the scan, which returns the exact answer. That makes the silent under-return conditional on a planning decision — the same query complete on one database and quietly partial on another, according to statistics nobody is watching. This is worse than an unconditional failure, not better, and it is a fourth claim this record did not have.
pgvector’s iterative scan is a real mitigation whose benefit is
data-dependent. The draft above said it “converts under-recall into latency”,
which was taken from the documentation. Measured, it recovers the full answer on
one corpus and moves six rows to seven on another, with EXPLAIN still
reporting a single index search. It may be offered. It may not be described as
the thing that makes a filtered search correct, because whether it is cannot be
seen from the schema.
The IVFFlat case announces itself. CREATE INDEX ... USING ivfflat on an
empty table emits a NOTICE — “ivfflat index created with little data … Drop the
index until the table has more data”. A migration runner that surfaced notices
would catch this at the moment it happens, which is cheaper than anything in the
DSL. Every runner this project knows of discards them. That does not change the
decision to default to HNSW; it adds a second, independent answer that costs
almost nothing.
Two traps the harness found, which the feature will hit
Section titled “Two traps the harness found, which the feature will hit”Neither is about vectors. Both are about executing a search that needs session state, which is what the operation below is.
- Session settings must be
SET LOCALinside a transaction. A plainSETrides the pooled connection back into whatever runs next. The first version of the tests measured an exact search, handed the connection back still carryingenable_indexscan = off, and reported a perfect result for the query the file exists to show failing. - pgx caches a prepared statement per connection, keyed on SQL text alone. A
plan built under one set of GUCs is reused under another. The measurement pool
disables statement caching for that reason — and a
searchoperation that setshnsw.*per statement has exactly this problem, since the setting changes and the cached plan does not.
Decision
Section titled “Decision”A vector column declares its dimension; an index is optional and carries the metric when there is one; and similarity search is a distinct operation.
The unindexed declaration is complete on its own — the core/rag shape:
schema.Vector("embedding", ragcfg.Dim).Searchable()Adding an index is a second decision, taken when exact search stops being fast enough:
schema.Vector("embedding", ragcfg.Dim). Searchable(). Index(schema.HNSW, schema.Cosine). Where("deleted_at IS NULL")-
The dimension is part of the type and is a Go expression, which is the whole answer to the
%%EMBEDDING_DIM%%sentinel — no rewrite step, no mirror file, andDiffnotices a dimension change and proposes the rewrite. Over 2,000 is accepted for a column and refused for an index.This takes a capability away, and the record should say so rather than let a port discover it.
core/ragreads its dimension fromRAG_EMBEDDING_DIM, so one binary can deploy against a 768- or a 1,536-dimension embedder. Fixing the dimension atgeneratetime ends that. It is stricter and it is the point — the mirrorschema.sqldisappears and the drift becomes a diff instead of a comment asking a human — but “config becomes a constant” is a real loss for one known consumer. -
A dimension is not a vector space, and
Diffnoticing a change in one gives false comfort. The space is provider and model and dimension. Swaptext-embedding-3-smallfor a different model of the same width and the column diffs clean while every stored vector is meaningless — an outcome with no symptom except worse answers.core/ragcarries anembedding_fingerprintper chunk precisely because re-embedding has to be resumable, which is that problem solved by hand downstream of a schema that could not express it. So a vector column should be able to declare a space identity and not only a width. The first version need not act on it beyond storing it and refusing to search across a mismatch; what it must not do is let the dimension stand in for the space. -
An unindexed column is a supported configuration, not a missing index.
Lintmay observe it; it does not warn, because at rag’s size the index would be the mistake. -
The metric lives with the index, and a query cannot pick a different one. Asking for a metric no declared index serves is refused at build time naming the ones that are (ADR-0011). This is the most important line here: the failure it prevents is invisible in results and shows up as a latency graph.
-
HNSW is the default, because the migration model chooses it. An IVFFlat index built on an empty table clusters nothing, and a
Diff-generatedCREATE INDEXruns at exactly that moment. -
Which filters may accompany a search depends on the index and nothing else, and there are three regimes rather than two. The first draft had two, and the case it could not express is deployed — see the collision, which is not hypothetical below.
- No index. Any filter the model allows. Exact scan; correct and slow, which
is the right answer at
core/rag’s size. - ANN index, filters known at declaration time. A constant predicate becomes
the index’s
WHERE; a variable one must be declared. An undeclared filter is refused at build time, as before. - ANN index, filters variable per request. The caller opts in explicitly, and
the search operation sets
hnsw.iterative_scanandhnsw.max_scan_tuplesfor the statement. This is the branch the measurement constrains: iterative scan helps by an amount that depends on the data, so what the caller is opting into is “try harder, and it may still come back short” — not a fix. Naming it as one would reintroduce the silent failure with a configuration flag in front of it.
All three are explicit and none is silent. Refusing the undeclared fourth case at build time stays as written. The regimes are a property of the search operation, which is what makes this expressible at all: the operation owns execution, so it can own the session GUCs — subject to the two traps above, which is why they are recorded here rather than in a test file nobody reads.
- No index. Any filter the model allows. Exact scan; correct and slow, which
is the right answer at
-
The score is similarity, not distance — larger is closer, whichever metric produced it — and it is a projection as well as an ordering.
1 - (embedding <=> $1)appears in the select list, in the threshold comparison and in theORDER BYof the same statement, soNear(vec)returns a handle yielding all three rather than three call sites that must agree. This is the argumentGroupBySelectionalready makes fordate_trunc. -
A shortfall cannot be detected by counting rows, which is the trap in the branch above. A
min_scorethreshold makes “fewer than k” the normal case, solen(rows) < k— the obvious alarm — is unusable exactly where it is most needed, and a corpus applying both a threshold and a filter has no signal at all. Any shortfall signal sqlb offers must distinguish rows lost to the threshold from rows the index never found, which means counting before the threshold cut. Whether to offer one is open; that it cannot be the naive count is settled. -
Search is its own operation.
POST /documents/search, not?near=: 1,536 float32s is ~20KB and does not fit in a URL;?sort=…&near=…is incoherent; andOFFSET 100into an approximate neighbour set is not page six of anything. -
The embedding does not go over the wire. A vector column is
Hidden, not optionally. Go callers through the query engine still get it.
Two supporting pieces: CREATE EXTENSION IF NOT EXISTS vector; ordered ahead of
every table (the collision argument that kept CREATE TYPE out does not apply —
one global name, owned by nobody, idempotent), and sqlb.Vector.
Superseded: the original sqlb.Vector was a text-form []float32 because
Executor was database/sql. ADR-0040
removed that constraint — it cites this record as its strongest evidence — and
it is built. sqlb.Vector is therefore specified as pgvector’s binary codec
registered on the pool, which is now an ordinary thing to write rather than a
thing the driver had no spelling for. The measurement that made the case: 2.7×
the time and 21× the memory for a 50-row page of 1536-dimension embeddings,
through the text form. The rest of this record is unaffected either way.
What this record is still waiting on is therefore nothing external, and the three places that did not know the type now do. What remains is the index half, which this record deliberately leaves as a second decision — and which the measurement above says should stay unbuilt until a corpus has outgrown an exact scan, because every failure mode it introduces is silent.
One cross-reference in this record was wrong and is worth correcting rather
than quietly deleting: the score-as-projection bullet cites GroupBySelection
as an existing precedent for date_trunc. No such thing exists in the tree. The
argument it was making is sound and is what Near implements — write the
expression once, get the projection, the predicate and the ordering from it —
but it was not standing on a precedent.
Consequences
Section titled “Consequences”Buys. The schema stays the single source of truth for a project that uses
embeddings, which is what the whole sqlc arrangement rests on and what Raw
cannot restore. Adoption round-trips. The mistake with no symptom — a metric that
does not match its index — is refused at build time. And staging the index as a
second decision means the risky half can be deferred, or dropped entirely if
nothing ever outgrows an exact scan.
Costs.
- This is the largest surface added since expansion, and the first that is not table stakes — a type, an index kind, a Go value type, a REST operation, an extension in the diff engine, an introspect mapping, a lint rule. The honest reason it is worth it is the schema argument, not that vector search is popular.
- Approximate results are hard to test the way this project tests, and harder
than this record first thought. The guard cannot be a recall number, because
recall varies with the corpus, the planner and the index build’s own
randomness; it has to be “the plan is the dangerous shape” or “the index is
used”, which means reading a query plan rather than a result set.
pgtest/pgvector_test.gois the worked example and the warning. - The index build is the slowest migration this project will generate. A vector index wants its own migration file, and nothing enforces that yet.
CREATE EXTENSIONusually needs privileges the migration role lacks — failing at the first statement of the first migration, which is at least a good place.- The metadata half was a debt this record did not pay.
filter.operatorshad no containment operator andCoercerefusesjson.RawMessage, so a JSONB column was not filterable through sqlb at all, and the first regime was available to Go callers viaRawPredonly. Paid since:hasdoccompiles to@>through both frontends, andField.ContainsJSONis the Go spelling. The operator is not calledcontainsbecause that name is already the text operator — ADR-0033’s reasoning, applied a second time.Coercestill refusesjson.RawMessage; the document is carried as text rather than coerced, which is why it needs no codec.
What would change our mind
Section titled “What would change our mind”Any of the three physical claims failing against a real database.Done, and none of them failed —pgtest/pgvector_test.goagainst pgvector 0.8.6. What the measurement changed is recorded in the Context: a fourth claim (the planner may decline the index), a weaker statement about iterative scan, and the rule that no recall figure here is transferable. The trigger that replaces it: a claim measured on one corpus being quoted as a property of pgvector. This record has already made that mistake once.- A second metric on one column makes
Near(vec)ambiguous. The fix is additive, but it means the schema-only story was too simple. - Someone needs the raw vector over the wire — client-side re-ranking is the
plausible case, and one instance turns
Hiddeninto an overridable default. - An ANN index whose declared filters cannot express what a caller narrows by.
That is the measurement most likely to be needed first.This has fired, and the arrangement is deployed rather than hypothetical — see below. The record now has a third regime for it, which is written and unbuilt; what would change our mind again is that branch failing to hold in practice, which cannot be known until something runs it. - A second extension wants the same machinery — PostGIS is the obvious one.
- The surface cost arrives as bugs elsewhere — the fallback is not to fix it but to cut back to an opaque passthrough type, a tenth of the code, which unblocks the thing that actually forced the decision.
The collision, which is not hypothetical
Section titled “The collision, which is not hypothetical”The trigger above was written as a thing to watch for. A census of subject-go
reports it already in production: an hnsw (embedding vector_cosine_ops) index,
and a search narrowing by org_id, conversation_id IS NULL, backoffice_only,
project_id, work_package_id and an EXISTS over archived projects — every
one variable per request, none expressible as the index’s WHERE. No
hnsw.ef_search or hnsw.iterative_scan is set anywhere in that repository.
On the measurement above, that arrangement returns fewer rows than it asks for and says nothing about it. Whether it is doing so today depends on the planner, the corpus and the index build — which is to say nobody there knows, and the failure has no symptom that would tell them. It is reported rather than observed: this project has not run that query. But it is the shape, and it is the reason the third regime exists rather than a fourth refusal.
Two details from the same census sharpen the record elsewhere. That codebase
applies a min_score threshold and a filter, which is the case where counting
rows cannot detect a shortfall — the trap now stated in the Decision. And its
migration names vector_cosine_ops in a comment for a future index while its
query uses <=>: those agree because an author was careful once, in prose, with
nothing checking it. That is this record’s metric argument, standing in someone
else’s repository.
Cost of change
Section titled “Cost of change”Declining is free until the first line is written. After that: the DDL
rendering, opclasses, build parameters and HNSW defaults are cheap — and because
an unindexed column is a complete configuration, the index machinery can stay
unbuilt without leaving the feature half-finished. TypeVector in Stable-tier
schema and the POST /resource/search wire format are expensive. A shipped
HNSW index whose metric changes is an hours-long rebuild on every deployment
holding data.
Asymmetric in the useful direction on the decision most likely to be wrong: schema-first metric with a query argument added later is additive; shipping a query argument and later insisting on declaration breaks every caller — the same shape as ADR-0017’s reason for starting from text.
Revisions
Section titled “Revisions”-
2026-07-28 — Written before any implementation, because the metric-in-the-schema decision is the expensive one to reverse and is settled by argument. The physical claims are listed first among what could invalidate it, since ADR-0025 found a green test suite asserting what somebody expected rather than what Postgres accepts.
-
2026-07-28 — Revised against
core/rag, which corrected the record within hours: the first draft made the ANN index mandatory and every filter declared, which refuses the shape that module actually runs. The index is now the second decision, and declared filters are the price of an approximate index. -
2026-07-30 — The “unless a port needs it” qualifier fired, and the answer did not change. What blocked the multi-app port was the driver, not a missing vector DSL — a text-form
sqlb.Vectorcannot host a binary codec however well the schema declares its index. Still not in 1.0, now with the dependency stated. -
2026-07-30 — Condensed.
-
2026-07-30 — The three physical claims measured, which this record said was the first work and had not been done. All three hold, against pgvector 0.8.6, in
pgtest/pgvector_test.go. Four changes followed, and only one of them was expected. The claims are now stated as mechanisms rather than as recall figures, because the first version of those tests produced 6-of-10 on one corpus and 10-of-10 on another and both were correct measurements — recall varies with the corpus, the planner’s costing and HNSW’s own build randomness. The draft’s “returns 2 of 10” was a number this record was never entitled to. A fourth claim was added: the planner may decline the ANN index and return the exact answer, which makes the silent failure conditional on statistics nobody watches. The iterative-scan sentence was weakened from the documentation’s framing to what was observed — a real mitigation whose benefit is data-dependent and invisible from the schema. And the IVFFlat case turns out to announce itself as a build-time NOTICE, which is a second answer costing almost nothing. Confidence: Low → Medium. -
2026-07-30 — The regime rule gains a third branch, because its most likely invalidator is deployed. A census of
subject-goreports an HNSW index with six per-request filters and no scan tuning — the exact collision this record listed as hypothetical and had no answer for. The two-regime rule became three: no index, declared filters, or an explicit opt-in to iterative scan whose honest promise is “try harder, and it may still come back short”. Also from that census, two things the record was missing: amin_scorethreshold makeslen(rows) < kunusable as a shortfall alarm exactly where it is most needed, and a vector space is provider and model and dimension — soDiffnoticing a width change gives false comfort, and a column should be able to declare a space identity. Reported rather than observed; this project has not run that codebase’s queries. -
2026-07-30 — The column is built, which is the whole of what this record calls a complete configuration on its own:
schema.Vector(name, dim)with the dimension as a Go expression,vector(n)rendered byDiffwithCREATE EXTENSIONordered ahead of it, the introspect mapping that closes the round trip,sqlb.Vectorwith pgvector’s binary codec written against pgx rather than taken from pgvector-go, andNearyielding the projection, the threshold and the ordering from one handle. Verified against pgvector 0.8.6: the DDL applies, an imported schema diffs to nothing, values round-trip in binary, and a search returns rows in the order its scores claim.Three things decided while building it that the record did not say.
Nearbinds the vector once however many times it is mentioned, which needed a shared-parameter node in the compiler — an embedding is twenty kilobytes and a search names it three times. The metric is cosine and is not an argument, on this record’s own asymmetry: adding one later is additive, removing one is not. And a vector column is refused as a primary key, as Unique, as an array element, and as Filterable, Sortable or Searchable — each because the REST surface has no spelling for a vector, which is the same reason search is its own operation.The index half stays unbuilt, and the measurement above is now an argument for keeping it that way until a corpus has outgrown an exact scan.
-
2026-07-30 — Two smaller corrections while folding the above in. The score is a projection as well as an ordering, so
Near(vec)yields select, predicate and order together rather than three call sites that must agree. And making the dimension a Go expression removes a capability one known consumer uses — a single binary deploying against a 768- or 1,536-dimension embedder — which is the intended trade but was not written down.