Skip to content

Add a new rescore ANN index for vec0 tables - #276

Merged
asg017 merged 4 commits into
mainfrom
pr/rescore
Mar 31, 2026
Merged

asg017 merged 4 commits into
mainfrom
pr/rescore

Conversation

@asg017

@asg017 asg017 commented Mar 31, 2026

Copy link
Copy Markdown
Owner

This PR adds the first ANN index to sqlite-vec, called "rescore". A rescore
index quantizes input vectors into int8/bit vectors, and performs an oversampled
KNN search on that smaller vector space, then re-ranks or "rescores" those
distances with the full-sized vectors.

create virtual table vec_articles using vec0(
  id integer primary key,
  headline_embedding float[1024] distance_metric=cosine indexed by rescore(
    quantizer=bit,
    oversample=8
  )
);

insert into vec_articles(id, headline_embedding)
  values(:id, :embedding);


-- KNN query
select
  rowid,
  distance
from vec_articles
where headline_embedding match :query_embedding
  and k = 10;

The INDEXED BY rescore() is the new syntax that signifies an index on the
headline_embedding column.

In the KNN search shown above, sqlite-vec will first perform a coarse KNN
search on the binary quantized-vectors of the headline_embedding column. It
"oversamples" on k * oversample, meaning it will find the top 8 * 10 closest
vectors based on hamming distance of the bit quantized vectors in the index.
Then, it will rescore those 80 vectors based on their full-sized float
embedidngs, and return the top 10 values.

Well-versed sqlite-vec users may notice that this is essentially the same
thing as the
old sqlite-vec Binary Quantization re-scoring
method that the doc site recommends:

-- The old, manual way of doing this - not necessary anymore!

create virtual table vec_movies using vec0(
  synopsis_embedding float[768],
  synopsis_embedding_coarse bit[768]
);
insert into vec_movies(rowid, synopsis_embedding, synopsis_embedding_coarse)
 VALUES (:id, :vector, vec_quantize_binary(:vector));

with coarse_matches as (
  select
    rowid,
    synopsis_embedding
  from vec_movies
  where synopsis_embedding_coarse match vec_quantize_binary(:query)
  order by distance
  limit 20 * 8
),
select
  rowid,
  vec_distance_L2(synopsis_embedding, :query)
from coarse_matches
order by 2
limit 20;

Besides being way less verbose, this new rescore index is slightly faster than
doing old manual method. Full-size vectors are stored in a key-value table
instead of vec0's chunked storage, meaning the re-scoring stage are a bunch of
b-tree lookups instead of overflow-page nightmares. Granted it's not a huge
difference, maybe 10-15% faster reads, but it's something!

Benchmarks

First off, benchmarks are a notoriously difficult thing to get right:

  • Every embedding models treats binary/int8 quantizations differently, some are
    better than others.
  • Some datasets are semantically diverse enough to perform well with
    quantizations, others are not.
  • Some SSD's/hardware lend itself well with a page cache that makes the coarse
    lookup faster, others may not.

So take these benchmarks with a grain of salt, try it on your own data.

But for my use-case on my hardware (Macbook Pro M4) on a semantically diverse
datasette of 1 million New York Times headlines embeded with
mixedbread-ai/mxbai-embed-large-v1,
I get:

vec0 Configuration Insert (s) Size (GB) Query (ms) Recall @10
Flat index 27.4 3.85 590ms (baseline) 1.0
rescore int8, oversample=2 27.3 5.28 225ms (2.6x speedup) 1.0
rescore bit, oversample=8 28.2 4.55 101ms (5.8x speedup) 0.988
rescore bit, oversample=4 28.2 4.55 92ms (6.4x speedup) 0.962

The int8 rescore performs extremely well at preserving precision, with perfect
recall at K=10 queries while being more than twice as fast. Again, the
mixedbread-ai/mxbai-embed-large-v1 model was specifically trained on binary at
int8 quantization, so your mileage may vary.

The binary rescore index performs very well too - 0.988 recall at
oversample=8, and a respectable 0.962 recall at the lower oversample=4.
The speed benefits of hamming distance really shows here - nearly 6 times faster
for os=8, and more than 6 times faster for os=4!

You can choose a different oversample value at query time, so you can tune that
for faster queries, at the expense of recall.

Do note that both int8 and bit quantized rescores take up more disk space,
since it hold both full-size vectors and quantized vectors. The int8 vectors
take D bytes of space per vector, while bit vectors take D / 8 bytes of
space.

Drawbacks

This rescore index is technically still a brute-force search, but instead of
doing full-scans on full-size floating point vectors, it brute-forces much small
binary or int8 vectors.

So eventually while your vector database grows, query time with the rescore
index will also grow, roughly linearly.

Also, recall will nearly entirely depend on your embedding model and dataset. If
your embedding model was specifically trained on binary or integer quanitzation,
I suspect you'll have a great time using rescore. If not, YMMV but I've seen
recall drop down to like 70-80. Additionally, if your data isn't very
semantically diverse, than quantization may muddle the results.

But in general, if data/embedding model handles quantization well and you can
take a ~10% accuracy hit, I've found that the rescore index to be fantastic!

asg017 added 3 commits March 29, 2026 19:44
Add approximate nearest neighbor infrastructure to vec0: shared distance
dispatch (vec0_distance_full), flat index type with parser, NEON-optimized
cosine/Hamming for float32/int8, amalgamation script, and benchmark suite
(benchmarks-ann/) with ground-truth generation and profiling tools. Remove
unused vec_npy_each/vec_static_blobs code, fix missing stdint.h include.
Add rescore index type: stores full-precision float vectors in a rowid-keyed
shadow table, quantizes to int8 for fast initial scan, then rescores top
candidates with original vectors. Includes config parser, shadow table
management, insert/delete support, KNN integration, compile flag
(SQLITE_VEC_ENABLE_RESCORE), fuzz targets, and tests.
@asg017
asg017 changed the base branch from pr/ann to main March 31, 2026 08:08
@asg017
asg017 merged commit 43982c1 into main Mar 31, 2026
realgetOff pushed a commit to realgetOff/sqlite-vec that referenced this pull request Aug 4, 2026
vec0BestIndex() sets aConstraintUsage[].omit = 1 on constraints against the
`distance` column, which tells SQLite the vtab will enforce them itself and
lets SQLite drop the term from the query plan. Only the FLAT chunk scan ever
read those constraints back out of idxStr, so on rescore, DiskANN and IVF
columns the predicate was silently discarded and rows violating it were
returned with no error.

This was a regression: asg017#166 added the distance constraints, and the ANN
backends merged six weeks later (asg017#276, asg017#277, asg017#278) never wired them up.

Introduce vec0_distance_constraints_satisfied() as the single point of truth
for the predicate and route every backend through it:

- FLAT keeps its pre-filter semantics; its four nested switch cases collapse
  into one loop over the chunk bitmap (net -44 lines, snapshots unchanged).
- rescore filters the rescored float distances before the top-k truncation,
  so a lower-bound constraint still yields k rows. Coarse quantized distances
  from phase 1 are not comparable to a user-supplied threshold, so the filter
  cannot be pushed down into the quantized scan.
- DiskANN and IVF compact their final result sets.

Also fixes a latent issue on the rescore path: when the constraint filters out
every candidate, result_k becomes 0 and sqlite3_malloc(0) returns NULL, which
the existing check would have misreported as SQLITE_NOMEM.

Adds parametrized coverage over flat / rescore-bit / rescore-int8 / diskann.
17 of the new assertions fail without this change and pass with it.

Fixes asg017#308
realgetOff pushed a commit to realgetOff/sqlite-vec that referenced this pull request Aug 4, 2026
vec0BestIndex() sets aConstraintUsage[].omit = 1 on constraints against the
`distance` column, which tells SQLite the vtab will enforce them itself and
lets SQLite drop the term from the query plan. Only the FLAT chunk scan ever
read those constraints back out of idxStr, so on rescore, DiskANN and IVF
columns the predicate was silently discarded and rows violating it were
returned with no error.

This was a regression: asg017#166 added the distance constraints, and the ANN
backends merged six weeks later (asg017#276, asg017#277, asg017#278) never wired them up.

Introduce vec0_distance_constraints_satisfied() as the single point of truth
for the predicate and route every backend through it:

- FLAT keeps its pre-filter semantics; its four nested switch cases collapse
  into one loop over the chunk bitmap (net -44 lines, snapshots unchanged).
- rescore filters the rescored float distances before the top-k truncation,
  so a lower-bound constraint still yields k rows. Coarse quantized distances
  from phase 1 are not comparable to a user-supplied threshold, so the filter
  cannot be pushed down into the quantized scan.
- DiskANN and IVF compact their final result sets.

Also fixes a latent issue on the rescore path: when the constraint filters out
every candidate, result_k becomes 0 and sqlite3_malloc(0) returns NULL, which
the existing check would have misreported as SQLITE_NOMEM.

Adds parametrized coverage over flat / rescore-bit / rescore-int8 / diskann,
plus ivf behind a build-flag skipif mirroring tests/conftest.py. 17 of the new
assertions fail without this change on a default build (22 with IVF enabled)
and all pass with it.

Fixes asg017#308
mceachen pushed a commit to photostructure/sqlite-vec that referenced this pull request Nov 2, 2026
A float vector column declared `indexed by rescore(quantizer=bit)` or
`indexed by rescore(quantizer=int8)`, with an optional `oversample=N` from 1
to 128 (8 by default), also keeps its vectors quantized in _rescore_chunksNN,
in the layout of _vector_chunksNN, and its float32 vectors by rowid in
_rescore_vectorsNN. A KNN query scans the quantized vectors for the
k * oversample nearest rows, then ranks those candidates by the distances of
their float32 vectors. The `oversample=N` command sets the oversample of a
connection's queries. A full scan reads a rescore column's vectors row by row
through vec0_get_vector_data(), and a flat column's by chunk as before.

Ported from asg017#276 (ba0db0b) as asg017's main has it at
04d28bd: with the int8 quantizer that 575371d fixed, the fixes 5e4c557,
82f4eb0 and 5522e86, auxiliary columns (b7fc459), the oversample=N command
and its unknown-command message (6e2c4c6), and the distance constraints of
the unmerged asg017#309. SQLITE_VEC_ENABLE_RESCORE=0 builds without it.

Where this port differs from upstream:

- rescore_knn() takes vec0Filter_knn_chunks_iter()'s arguments and results,
  so vec0Filter_knn() picks between the two and the MMR rerank runs after
  either. It reports errors as the chunk scan does, so an interrupt stays an
  interrupt. It picks each chunk's candidates with this fork's min_idx(),
  whose heap needs no bitmap of taken slots.
- Distance constraints with a NULL, TEXT or BLOB value act as they do for a
  flat column.
- optimize, which upstream lacks, writes a moved row's quantized vectors to
  its new slot and leaves its row of _rescore_vectorsNN, whose rowid does not
  change, and deletes the old _rescore_chunksNN rows with the old chunks. A
  DELETE that empties a chunk deletes its _rescore_chunksNN row too.
- A point query for a rowid the table does not have returns no row, since
  vec0_get_vector_data() finds the row's position first, as for a flat
  column; upstream fails with "Could not fetch vector data".
- _rescore_chunksNN declares "rowid INTEGER PRIMARY KEY", as this fork's new
  tables do, and an oversample=N value must be all digits.

The tests are upstream's test-rescore.py and test-rescore-mutations.py, the
rescore cases of its test-auxiliary.py and test-rename.py, its rescore fuzz
targets, and test-unit.c's bit quantizer, byte size and parser cases.
Upstream's int8 quantizer unit tests, and the uniform-vector case of
rescore-quantize-edge.c, test the min/max quantizer that 575371d replaced, so
they are written here for the current one. Modes 1 and 2 of
rescore-quantize-edge.c copy the fuzz bytes out to float alignment before
reading them as floats, as asg017#343 has them; upstream's read floats one
byte past the mode byte, a misaligned load UBSan reports. New tests check
that each row's quantized slot holds the quantized form of its vector
through inserts, updates, deletes that empty chunks, and optimize, and
rescore-optimize.c fuzzes optimize. test-fullscan.py reads a rescore column
beside a flat one, and runs its delete and optimize cases on a rescore
column too; read by chunk, a rescore column fails all three with "Could not
fetch vector data".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant