Vectors
A vector store you declare, the same vectors as a field of an ordinary table, and the one place in this store where an index is allowed to change an answer.
A vector is an array of numbers — not a second system beside this one, and not a value type of its own. There are two ways to hold them, and the first is the route in.
Declare the store. DEFINE VECTOR gives you a place whose records are
vectors, with the width, the field and the index already agreeing with each
other. Nothing has to exist first.
Or put a vector in a table you already have. A field of an ordinary record holds one just as well, and that shape is not going anywhere: it is the right one when the vector is one aspect of a record among several — a chunk with an embedding is still a chunk.
A vector store you declare
When the vectors are the data, declare the store as one:
DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE atlas;
USE DATABASE atlas;
DEFINE VECTOR embeddings DIMENSION 3 DISTANCE cosine;
CREATE embeddings:'intro' = { vector: [0.1, 0.2, 0.3], label: 'intro' };
INFO FOR VECTOR embeddings;
Three components rather than the several hundred a real embedding carries, so that the write above is one you can type. The width is a value like any other and nothing on this page depends on which one it is. Reading it back takes a bound query vector:
SELECT * FROM embeddings ORDER BY vector::cosine(vector, $query) LIMIT 5;
The records are ordinary records — written with CREATE, read with SELECT,
carrying whatever other fields they need beside the vector. The store is a table
in every way that matters to a reader, which is the point of declaring it as one.
What the word stands for is exactly three statements, run through the same code the long spellings run through:
DEFINE COLLECTION embeddings;
DEFINE FIELD vector ON embeddings TYPE vector<3> REQUIRED;
DEFINE INDEX vector ON embeddings FIELDS vector VECTOR cosine;
What it adds is that the three cannot come apart. A width with no index
declares a shape nothing searches. An index with no width admits a row of the
wrong shape and then reports it as infinitely far from everything — a plausible
ordering rather than a complaint. Neither without REQUIRED admits a record
carrying no vector at all, which is legal in a table and is not a record of a
vector store. Written by hand the three are three chances to be wrong.
Both clauses are required and neither has a default. The width, because declaring it is the whole of what the word is for. The distance, because a default would decide which reads the store can serve without saying so: a graph whose edges were chosen by one distance approximates that distance and no other.
Declaring a store changes nothing about exactness. Reads are exact unless they
say otherwise, and APPROXIMATE is still the only way to ask for the index's own
answer — a store that became approximate by virtue of being declared would turn
two identical-looking reads into two different contracts.
INFO FOR VECTOR reports the width, the distance, and the recall the index was
measured at — or NONE, meaning nobody has measured it. It never computes a
plausible figure from the build parameters, because a number derived that way is
one nobody checked wearing the name of one somebody did. The measurement runs at
REBUILD INDEX, the one statement that already walks every row, so a store
declared, filled and never rebuilt has never been measured and says so.
When there is a figure it never appears alone:
{"recall": 94, "at": 10, "sample": 32, "records": 120000, "neighbours": 16,
"exploration": 64}
records is the one that matters most. Recall decays as records arrive after the
build that measured it — the graph goes on answering, and answers less of the
truth — so a bare percentage would look current forever.
The word that made the store removes it — and takes its collection with it,
the way DROP GRAPH takes a graph's own: the records
a declared store holds are part of the structure rather than something depending
on it. What a drop does not do is reach further — it removes its own
definition and refuses while anything still points at it, which is where
CASCADE would otherwise be:
DROP VECTOR embeddings;
It refuses a table that is not a vector store rather than dropping it, because the two words name different things even where they would remove the same rows.
Everything below applies to a declared store too. The distance functions,
APPROXIMATE, EFFORT and REBUILD INDEX are the same statements over either
shape; they are written out against a table because that is where a width has to
be declared by hand.
A vector in a table of your own
The other shape: a field of an ordinary record, in a table that exists for its own reasons.
DEFINE COLLECTION points;
CREATE points:1 = { at: [0.0, 0.0], name: 'origin' };
CREATE points = { at: [0.1, 0.0], name: 'near' };
CREATE points:3 = { at: [0.9, 0.9], name: 'far' };
CREATE points:4 = { at: [1.0, 1.0], name: 'furthest' };
Saying how wide the field's vectors are
A field that holds vectors can declare how many components they have:
DEFINE FIELD at ON points TYPE vector<2>;
That is the difference between a wrong-shaped row being refused at the write and
being accepted, stored, and then reported as infinitely far from everything on
every read afterwards — a plausible ordering rather than a complaint. The full
rule, including why there is no width-less vector and no vector<0>, is on
Data types.
A declared store has already had this said for it, by the DIMENSION clause.
A distance is an ordinary function
So it sorts like any other expression, and nearest-first needs no new syntax:
SELECT * FROM points ORDER BY vector::euclidean(at, [0.0, 0.0]) LIMIT 2;
Three distances, and which one you want is a property of your data rather than of the store:
| function | measures |
|---|---|
vector::euclidean(a, b) | straight-line distance — smaller is nearer |
vector::cosine(a, b) | angle between the two, ignoring magnitude |
vector::dot(a, b) | the dot product |
SELECT vector::euclidean(at, [0.0, 0.0]) AS d FROM points:1;
SELECT vector::cosine(at, [1.0, 1.0]) AS d FROM points:3;
SELECT vector::dot(at, [1.0, 1.0]) AS d FROM points:4;
The index, and the exception it forces
Every other index in this store changes what a read costs and never what it answers. A vector index cannot promise that: it is a graph, a search returns the neighbours a walk found, and a walk cannot show that it missed none.
The store does not resolve that by choosing for you. It declares the graph:
DEFINE INDEX by_at ON points FIELDS at VECTOR euclidean;
…and then leaves the read exactly as exact as it was:
SELECT * FROM points ORDER BY vector::euclidean(at, [0.0, 0.0]) LIMIT 2;
Still the exact scan. The graph is there and the statement did not ask for it.
Approximation is asked for out loud, in the statement, by the person who gets to decide whether it is acceptable:
SELECT * FROM points ORDER BY vector::euclidean(at, [0.0, 0.0]) LIMIT 2 APPROXIMATE;
That one word is the whole difference. It is in the statement rather than in the index definition on purpose: a property of the answer belongs where the answer is asked for, so a reader of the query knows what they are getting without going to look at the catalog. Two reads over the same data, one exact and one approximate, are both ordinary and both readable.
The index is built for the distance it names. A read ordering by a different distance is served exactly, because the graph it would need does not exist.
A condition is walked too
From 0.22.0-beta an approximate read with a WHERE is served by the graph as
well:
SELECT * FROM points WHERE live = true ORDER BY vector::euclidean(at, [0.0, 0.0]) LIMIT 2 APPROXIMATE;
The walk navigates the whole graph and admits a record into the answer only after reading it at the reader's snapshot and testing the whole condition, so the graph chooses which records are tried and never which records pass. The answer says which of three things happened:
- served —
approximateon the plan and theapproximatenote, every record passing the condition, the page full; - answered exactly — when an index on the condition narrows the read to no more records than the walk would visit, the read takes that index instead: same cost, exact answer, no note;
- given back — a walk that admitted fewer records than the
LIMIT, or hit its ceiling, answers nothing of its own: the read is answered exactly, with afell-backnote.APPROXIMATEagreed to the graph's choice among the nearest, never to a short page.
Measured on twenty thousand clustered 32-dimensional vectors (release, in memory), recall of the exact filtered ten and median time against the exact read:
| condition admits | recall@10 | walk | exact |
|---|---|---|---|
| half | 98.0 % | 6.8 ms | 17.6 ms |
| a tenth | 99.9 % | 8.3 ms | 16.2 ms |
| a hundredth | 100 % (mostly given back) | 22.9 ms | 16.0 ms |
About one record in a hundred is where the walk stops paying; an index on the condition is what lets such a read go straight to its records.
The table was measured before 0.26.0-beta, when every approximate read first
decoded the whole graph. From 0.26.0-beta a walk — and the index's own
maintenance — reads only the nodes it reaches, so the walk column is lower than
shown, with the same answers: on disk, an unfiltered read over twenty thousand
32-dimensional vectors went from 27.5 ms to 1.7 ms at the warm median.
One byte per component: QUANTIZED
DEFINE INDEX by_at_small ON points FIELDS at VECTOR cosine QUANTIZED;
From 0.22.0-beta a vector index may keep each vector as one byte per component
instead of eight, each component coded over that vector's own range — per vector,
so there is no training step and every replica writes the same codes.
DEFINE VECTOR … QUANTIZED says the same for a declared store. The codes choose
who is tried; the full vectors decide the order: a walk over a quantized index
asks for eight times the LIMIT and ranks those candidates by the exact distance
from each record's own vector before the bound cuts. An exact read never consults
it.
INFO FOR VECTOR reports quantized and what the index costs, read off the stored
nodes: on 20 000 clustered 32-dimensional vectors (release, in memory) a vector
took 52 bytes inside a node instead of 260, at a median walk of 6.1 ms against 6.5
and recall@10 after rescoring of 95.6 % against 98.4 % — a fifth of the bytes per
vector for about three points of recall.
Saying what the approximation may spend: EFFORT
An approximate read can be asked to try harder, on that read alone:
SELECT * FROM embeddings
ORDER BY vector::cosine(vector, $query) LIMIT 10 APPROXIMATE EFFORT 200;
EFFORT is how many candidates the walk keeps in hand. Larger explores more,
costs more, and finds more of the true nearest neighbours; leave the clause out
and the engine's own budget applies.
It belongs to this read rather than to the declaration, for the same reason
APPROXIMATE does. A caller who needs a better answer for one query should not
have to redeclare the store for everybody, and a caller who needs a cheaper
answer should not degrade everybody else's.
Three boundaries are worth knowing:
- It stands only after
APPROXIMATE. An exact scan visits every record and has nothing to spend, so there is no position for the clause there. - It is at least one.
- It never reaches the index's construction. The build walks the same graph to choose a new record's neighbours, and letting a read's budget leak into that would make the index a function of whichever reads happened to be running beside the writes — the same index, built twice, would differ.
INFO FOR VECTOR reports the width, the distance, and the recall the index was
measured at — or NONE, meaning nobody has measured it yet. It never derives
a plausible figure from the build parameters, because a number computed that way
is one nobody checked wearing the name of one somebody did.
Rebuilding
REBUILD INDEX by_at ON points;
For an ordinary index this is a no-op you never need. It exists for this one: a proximity graph's recall decays as records churn, because the edges into a removed node go with it, and nothing in the read path can notice. A rebuild is the answer, and it is a decision with a cost rather than something the store does behind you.
What this is not
There is no embedding model here. A vector arrives as an array of numbers you computed somewhere else, and the store's job begins after that. What it gives you in return is that the vector sits in the record, in the same transaction, indexed beside the record's other fields — so a filter and a nearest-neighbour order are one statement:
CREATE points = { at: [0.2, 0.1], name: 'tagged', live: true };
SELECT * FROM points WHERE live = true ORDER BY vector::euclidean(at, [0.0, 0.0]) LIMIT 2;
That is the part a separate vector database cannot do without you writing the join yourself, against two systems that disagree about what time it is.