Internal documentation for how sqlite-vec works under-the-hood. Not meant for
users of the sqlite-vec project, consult
the official sqlite-vec documentation for
how-to-guides. Rather, this is for people interested in how sqlite-vec works
and some guidelines to any future contributors.
Very much a WIP.
chunk_id INTEGERsize INTEGERvalidity BLOBrowids BLOB
rowid INTEGERidchunk_id INTEGERchunk_offset INTEGER
rowid INTEGERvector BLOB
rowid INTEGERvalueNN [type]
rowid INTEGERdata BLOB
rowid INTEGERdata TEXT
The supported way to tell which tables belong to a vec0 table is by name. A
vec0 table xyz owns these tables and no others (NN is two digits):
xyz_info,xyz_chunks,xyz_rowids: alwaysxyz_vector_chunksNN: one per vector columnxyz_auxiliary: only if there are auxiliary columnsxyz_metadatachunksNN: one per metadata columnxyz_metadatatextNN: one per text metadata column
This query lists them for every vec0 table in a database. It reads only
sqlite_master, so it works on every SQLite version and on a connection that
has not loaded sqlite-vec:
select v.name as vec0_table, s.name as backing_table
from sqlite_master as v
join sqlite_master as s
on s.type = 'table'
and substr(s.name, 1, length(v.name) + 1) = v.name || '_'
and (substr(s.name, length(v.name) + 2) in ('rowids', 'chunks', 'info', 'auxiliary')
or substr(s.name, length(v.name) + 2) glob 'vector_chunks[0-9][0-9]'
or substr(s.name, length(v.name) + 2) glob 'metadatachunks[0-9][0-9]'
or substr(s.name, length(v.name) + 2) glob 'metadatatext[0-9][0-9]')
where v.type = 'table' and v.rootpage = 0
and v.sql like 'create virtual table%using vec0%'
order by 1, 2;type = 'shadow' in PRAGMA table_list is best-effort and should not be
relied on:
- A connection that has not loaded
sqlite-vecreports every backing table astable. - Up to SQLite 3.53, so does a connection that parsed the schema before loading the extension.
- Before SQLite 3.37 there is no
PRAGMA table_listand no prefix marking (see below), so the vector chunk tables are never shadow tables. - SQLite finds the virtual table that owns a newly created or newly parsed
table by splitting its name at the last underscore
(documented). For
xyz_vector_chunks00it looks for a virtual table calledxyz_vector, soxShadowNameis never asked about it on that path. xShadowNamedoes reportvector_chunksNN, and SQLite also marks shadow tables by name prefix when it parses the virtual table's own schema entry. That only sees tables parsed earlier, which in practice means after aVACUUM(it rewritessqlite_masterwith virtual tables last). Avec0table created after thatVACUUMis not covered until the next one.- SQLite 3.54 additionally marks by prefix after every
xConnect. There the vector chunk tables becomeshadowonce thevec0table has been used in the connection, except in the connection that created it.PRAGMA table_listconnects every virtual table first, so it always reports them asshadow, but underSQLITE_DBCONFIG_DEFENSIVEa direct write to a chunk table is still accepted until thevec0table is first touched. This prefix matching is not part of the documented contract.
Rules that follow from this:
- A new backing table that has no upstream counterpart should use a suffix
without underscores (
metadatachunksNN, notmetadata_chunksNN), so that it follows the documented rule. A table ported from upstream keeps upstream's name, to stay file-compatible, and is added tovec0ShadowName. xyz_vector_chunksNNis not renamed, because that would make databases unreadable by upstreamsqlite-vecand by earlier releases of this fork.- Do not rename a backing table from inside
xConnect. In vlasky/sqlite-vec's test of a minimal module under AddressSanitizer on SQLite 3.45.3, anALTER TABLE ... RENAMEissued there was a heap-use-after-free in the statement that triggered the connect.
The vec0 idxStr is a string composed of a single "header" character and 0 or
more "blocks" of 4 characters each.
The "header" character denotes the type of query plan, as determined by the
enum vec0_query_plan values. The current possible values are:
| Name | Value | Description |
|---|---|---|
VEC0_QUERY_PLAN_FULLSCAN |
'1' |
Perform a full-scan on all rows |
VEC0_QUERY_PLAN_POINT |
'2' |
Perform a single-lookup point query for the provided rowid |
VEC0_QUERY_PLAN_KNN |
'3' |
Perform a KNN-style query on the provided query vector and parameters. |
Each 4-character "block" is associated with a corresponding value in argv[].
For example, the 1st block at byte offset 1-4 (inclusive) is the 1st block and
is associated with argv[0]. The 2nd block at byte offset 5-8 (inclusive) is
associated with argv[1] and so on. Each block describes what kind of value or
filter the given argv[i] value is.
argv[i] is the query vector of the KNN query.
The remaining 3 characters of the block are _ fillers.
argv[i] is the limit/k value of the KNN query.
The remaining 3 characters of the block are _ fillers.
argv[i] is the optional rowid in (...) value, and must be handled with
sqlite3_vtab_in_first() / sqlite3_vtab_in_next().
The remaining 3 characters of the block are _ fillers.
argv[i] is the optional rowid = ? value, or id = ? for a table with a
primary key column. SQLite also passes a one-value rowid in (?) this way.
vec0 restricts the KNN search to the row it names, as it does for each value
of a '[' list, so a query has at most one of the two blocks: with both
constraints, vec0 takes the = value and leaves the list to SQLite. It
leaves a text primary key constraint whose collation is not BINARY to SQLite.
For a text primary key, a value that is not text names no row, in this block
or in a '[' list.
The remaining 3 characters of the block are _ fillers.
argv[i] is a "constraint" on a specific partition key.
The second character of the block denotes which partition key to filter on,
using A to denote the first partition key column, B for the second, etc. It
is encoded with 'A' + partition_idx and can be decoded with c - 'A'.
The third character of the block denotes which operator is used in the
constraint. It will be one of the values of enum vec0_partition_operator.
vec0_chunks_iter() turns each block into a condition on the _chunks
table's partitionNN column:
| Operator | Value | Condition |
|---|---|---|
EQ |
'a' |
partitionNN = ? |
GT |
'b' |
partitionNN > ? |
LE |
'c' |
partitionNN <= ? |
LT |
'd' |
partitionNN < ? |
GE |
'e' |
partitionNN >= ? |
NE |
'f' |
partitionNN != ? |
IN |
'g' |
partitionNN IN (?, ...) |
LIKE |
'h' |
partitionNN LIKE ? |
GLOB |
'i' |
partitionNN GLOB ? |
IS |
'j' |
partitionNN IS ? |
ISNOT |
'k' |
partitionNN IS NOT ? |
ISNULL |
'l' |
partitionNN IS NULL |
ISNOTNULL |
'm' |
partitionNN IS NOT NULL |
argv[i] of an IN block is read with sqlite3_vtab_in_first() /
sqlite3_vtab_in_next(), and each of its values is bound to its own parameter
of the _chunks query, so the query fails to prepare when the distinct values
of its IN blocks and its other partition key values together number more
than SQLITE_LIMIT_VARIABLE_NUMBER. ISNULL and ISNOTNULL blocks bind nothing.
These conditions compare text with BINARY (LIKE and GLOB keep their own
case rules, and LIKE follows PRAGMA case_sensitive_like), and they see only
argv[i]'s value, not the affinity of the expression that produced it, so
p IS CAST(5 AS INTEGER) does not match a text '5'. EQ, NE, and the
range operators are encoded whatever collation the query names. An IN or
IS constraint is encoded as such only when sqlite3_vtab_collation()
reports BINARY: an IN with another collation is encoded as EQ, which
SQLite runs once per value that is distinct under that collation, as before
vec0 encoded IN, and an IS with
another collation is left to SQLite. SQLite reports BINARY for IS NOT
whatever collation the query names, so xBestIndex leaves omit unset for it
and SQLite re-checks each row.
The fourth character of the block is a _ filler.
argv[i] is the value of the rowid or id to match against for the point query.
For a text primary key, xBestIndex leaves omit unset and SQLite re-checks
the row: vec0 looks the id up in _rowids.id, which is declared TEXT, so
the integer 5 finds '5', and SQLite drops that row unless the other side of
the = has numeric affinity.
The remaining 3 characters of the block are _ fillers.
argv[i] is the value of the WHERE constraint for a metadata column in a KNN
query.
The second character of the block denotes which metadata column the constraint
belongs to, using A to denote the first metadata column, B for the
second, etc. It is encoded with 'A' + metadata_idx and can be decoded with
c - 'A'.
The third character of the block is the constraint operator. It will be one of
enum vec0_metadata_operator, as only a subset of operators are supported on
metadata column KNN filters.
The fourth character of the block is the collation a text constraint compares
with, one of enum vec0_metadata_collation:
| Collation | Value |
|---|---|
BINARY |
'_' |
NOCASE |
'n' |
RTRIM |
'r' |
Only =, IS, <, <=, >, >=, and IN constraints on a text column
carry the collation that sqlite3_vtab_collation() reports for them; xBestIndex
rejects any other collation on them. SQLite also calls xBestIndex with each
branch of an OR offered as a constraint, which vec0 cannot tell from a
top-level one, so the error fires for such a constraint inside an OR too,
although SQLite may evaluate that OR itself.
Every other constraint carries '_': SQLite compares an INTEGER or REAL without
a collation, LIKE and GLOB have their own case rules, and SQLite reports
BINARY for a text != or IS NOT whatever collation the query names. vec0
compares those two bytewise and leaves omit unset, so SQLite re-checks each
row with the query's collation.
argv[i] is a constraint on the distance column in a KNN query.
This enables filtering KNN results by distance thresholds, useful for:
- Cursor-based pagination:
WHERE embedding MATCH ? AND k = 10 AND distance > 0.21 - Range queries:
WHERE embedding MATCH ? AND k = 100 AND distance BETWEEN 0.5 AND 1.0
The second character of the block denotes the constraint operator. It will be one of
the values of enum vec0_distance_constraint_operator:
| Operator | Value | Description | SQL Example |
|---|---|---|---|
GT |
'a' |
Greater than | distance > 0.5 |
GE |
'b' |
Greater than or equal to | distance >= 0.5 |
LT |
'c' |
Less than | distance < 1.0 |
LE |
'd' |
Less than or equal to | distance <= 1.0 |
The third and fourth characters of the block are _ fillers.
Note on precision: Distance values are cast from f64 to f32 for comparison, which may result in precision loss for very small distance differences.
Note on pagination: When multiple vectors have identical distances, pagination using
distance > X may skip some results. For stable pagination, combine distance with rowid:
WHERE (distance > 0.5) OR (distance = 0.5 AND rowid > 123)
argv[i] is the value of the optional mmr_lambda = ? constraint of a KNN
query. Unless k is 0, vec0Filter_knn applies numeric affinity to it with
sqlite3_value_numeric_type(), as SQLite does to a LIMIT value, and fails
the query unless it is then an INTEGER or REAL: '0.5' reads as 0.5, and NULL,
a blob, or text such as 'abc' fails. It converts the number to f32 and
fails the query if it is below 0.0 or above 1.0. For a value below 1.0, it
fetches the k * 5 nearest rows (at most 4096) and, if it finds more than
k, selects k of them by MMR.
The remaining 3 characters of the block are _ fillers.