Fixed grids on vector search - comparing 3 approaches to quantization: FAISS, Turbovec, and Infino
We benchmarked three approaches to 4-bit quantized vector search over the same 100,000 OpenAI embeddings (1536 dimensions): FAISS's classic product quantization, turbovec — an open-source implementation of TurboQuant — and Infino's SQ4, which we build, each measured through its own public API against exact brute-force ground truth. At 4 bits per dimension every index stores 768 bytes of codes per vector, and all three sit between 0.94 and 0.97 recall — but the latencies run from 1.5 ms to 45 ms.
The sections below work through where that difference comes from, build and write cost, and the same scan at four corpus sizes.
Quantization, briefly
There are two ways to make approximate search cheaper, and they're independent of each other.
a. You can look at fewer vectors. That's routing: IVF (inverted file) groups the corpus into clusters and reads only the clusters nearest your query, HNSW (hierarchical navigable small world) walks a graph toward the answer, tree methods carve up the space.
b. Or you can make each vector cheaper to look at. That's quantization, and it's the only thing that varies here — every index in this comparison scans all 100,000 vectors on every query. The FAISS index string carries no coarse quantizer, the turbovec index is a flat scan by design, and the Infino mode we measured is an exhaustive scan over its in-memory codes.
Scalar quantization treats each coordinate on its own: take the float, round it to one of 16 levels, store 4 bits. 1536 coordinates, 768 bytes. Usually there's a rotation first, and those 16 levels can be evenly spaced or fitted to the data — Lloyd-Max, the classic minimum-error placement, is one such fit. Product quantization chops the vector into M pieces and runs k-means separately on each piece, replacing that chunk of the vector with the index of its nearest centroid. The list of centroids a code indexes into is called a codebook, and each piece's codebook is a subquantizer. Because the centroids live in the chunk's own space, they can capture correlation between coordinates in a chunk, which per-coordinate rounding cannot. The price is a training pass over the corpus.
One thing none of these indexes does: rerank. In many systems you take the top few hundred results then re-score those against the uncompressed vectors.
The three approaches
FAISS is Meta's vector library, and its indexes are specified by factory string. The one here is IDMap,PQ768x8np — classic product quantization with a lookup table: 768 subquantizers of two dimensions each, so one 8-bit code covers two coordinates and the budget is the same 4 bits per dimension and 768 bytes per vector as everything else in the article. It is also the configuration turbovec's own README benchmarks its recall against.
turbovec implements TurboQuant, and we ran it at its 1.0.0 release. No routing structure at all, a flat scan over compressed vectors. Per its docs, each vector is rotated, quantized to 2 or 4 bits per coordinate against a Lloyd-Max codebook, and renormalized by length, and the search kernels use nibble-split lookup tables over an interleaved layout, chosen at runtime.
The TurboQuant authors' own released code ships only a conceptual Python path for inner-product estimation — no optimized scan, which is why the RaBitQ team's comparison report excluded query-time efficiency entirely. As far as we know turbovec is the only way to measure this method's scan at full speed.
Infino is an embedded retrieval library whose tables are Parquet files with the vector index embedded in them. It doesn't implement one fixed vector algorithm. The index serves through one of several modes — a routed cluster scan by default, an in-memory graph, or a flat scan — picked by a config setting, and optimize() sizes whichever is selected against a recall target measured on the table's own data. The mode tested here is the flat scan, flat_ivf: a 4-bit plane scanned exhaustively. At small corpora it's the effective shape and it's the configuration comparable to a flat library scan.
The setup
Everything runs in one process over 100,000 vectors from the dbpedia OpenAI corpus — 1536 dimensions, cosine — each engine through its own public API. The machine is an EPYC 9V74 slice: 4 cores, 8 hardware threads, 63 GiB of RAM.
At fp32, this corpus is 586 MiB of vectors. Every index below serves it from about 75 MiB — 4 bits per dimension in place of 32. Quantization writeups often make this the headline — turbovec's README included, where the compression figures ("31 GB of RAM as float32… fits it in 4 GB", "16x compression") are quoted against full 32-bit floating point vectors. The reduction in memory is real, but it is also what any 2- or 4-bit quantization delivers.
To measure recall, ground truth is exact brute-force nearest neighbors, computed once and reused. Recall is graded at k = 1, 10 and 100 over 1,000 held-out queries, and every engine answers the same query set.
Latency, memory and recall
Queries run one at a time, so the latency measurements are per-query scan costs and say nothing about throughput. Nothing pins threads: each engine parallelizes a single query however it chooses.
| recall@10 | p50 | bytes/vector | resident | |
|---|---|---|---|---|
| FAISS flat PQ | 0.950 | 45.5 ms | 791 | 75.5 MiB |
| turbovec 4-bit | 0.948 | 1.59 ms | 788 | 75.2 MiB |
| Infino 4-bit | 0.963 | 1.50 ms | 788 | 75.2 MiB |
Infino is slightly faster than turbovec, and both are significantly faster than FAISS.
As expected, memory differences are a wash. 1536 dimensions at 4 bits is 768 bytes of codes, and all three indexes land within 3% of that, at 788 to 791 bytes per vector.
The rest is bookkeeping. turbovec and Infino each store 20 bytes: an id plus a per-vector scalar that corrects the quantized dot's bias. Flat PQ stores an 8-byte id plus its trained codebook, which is 1.5 MiB shared across 100,000 vectors, or 15.7 bytes each — 768 + 8 + 15.7 = 791.7, against 791 measured.
Recall at k = 1, 10, 100:
| k=1 | k=10 | k=100 | |
|---|---|---|---|
| FAISS flat PQ | 0.949 | 0.950 | 0.963 |
| turbovec 4-bit | 0.946 | 0.948 | 0.957 |
| Infino 4-bit | 0.970 | 0.963 | 0.970 |
Every number here is graded over 1,000 held-out queries, which puts the standard error on a mean recall near ±0.004. FAISS PQ and turbovec are within a couple of points of each other at every k. Infino leads the group by at least 1.3 to 1.8 points at every size we ran.
Searching the vector space
A 4-bit code can take sixteen values, so every quantizer here has sixteen levels to place. The three approaches place them differently.
FAISS trains centroids. It runs k-means in 768 two-dimensional subspaces, 256 centroids each — that's the 312 CPU-seconds of build time, and it buys a codebook that can represent correlation between paired coordinates, which neither one-dimensional fit in this comparison can. The scan never compares the query against the compressed vectors directly: once per query it precomputes the distance to every centroid — a 768 KiB table, 768 × 256 floats — then scores a candidate by summing one lookup per subquantizer, 768 of them, each at an address not known until the code byte has been read.
Turbovec precomputes its levels. The insight behind it is not TurboQuant's alone — rotate-then-quantize is a family: RaBitQ (SIGMOD 2024) established the construction and its error bounds although there is an ongoing discussion on precedence. The property both rely on is the same: after a random rotation, every coordinate follows the same known distribution — a Beta, near-Gaussian in high dimension — so the optimal 16-level Lloyd-Max quantizer for that distribution can be computed once, ahead of time, and reused for every corpus forever. (Infino's ruler, next, is the same construction with uniform levels.) Nothing is trained and nothing is fitted; the 1.2 CPU-seconds is rotating and encoding, which is the point — the paper calls it data-oblivious: no pass over the data is ever needed, so a vector can be quantized the moment it arrives. turbovec also stores one scalar per vector that corrects the inner product's quantization bias. At scan time the fitted levels are read through lookup tables of 16 entries per coordinate — small enough to stay in SIMD registers — at 1.59 ms.
Infino computes a ruler based on the data. It rotates (a seeded structured rotation — only the 8-byte seed is persisted), then fits offset and step per rotated coordinate over mean ± 2.7σ, uniform steps between. No codebook at all. The 2.7 is the optimal 16-level loading for a Gaussian, and the rotation is what makes the Gaussian assumption hold: it spreads each coordinate toward the same near-Gaussian marginal, which is what lets one global ruler — a fixed grid, the same sixteen levels for every row in the table — work at all; on the raw embedding axes, with their wildly uneven energy, most coordinates would collapse onto a couple of codes. A comment in the encoder records the measured stakes: loading over sigma instead of min/max moved recall@10 up 0.032 on this corpus, because a min/max ruler is set by each coordinate's single most extreme value and spends most of its 16 levels on range almost no row occupies. The scan carries no distance table of any size — because the levels are evenly spaced, scoring reduces to a direct integer dot on the packed nibbles — and runs at 1.50 ms.
Building the vectors
| wall (8 threads) | CPU-seconds | |
|---|---|---|
| turbovec 4-bit | 374 ms | 1.2 |
| Infino¹ | 1.7 s | — |
| FAISS flat PQ | 39.7 s | 312 |
¹ Infino's build is a table write: the vectors are appended and committed, so the 1.7 s ends with the data durable on disk as Parquet, where the library builds end in RAM. With a single writer it's 3.57 s. The harness doesn't record CPU-seconds for it.
FAISS trains: 768 k-means problems, 256 centroids each over a two-dimensional subspace, iterated over a sample of the corpus — that's the 312 CPU-seconds. TurboQuant's codebook is precomputed for the rotated coordinate distribution and never reads the corpus; turbovec's 1.2 CPU-seconds is rotation and encoding. Infino's build trains too — the 1.7 s includes its own k-means — but we didn't profile it by stage.
On this corpus the trained codebook and the precomputed one reach the same recall at the same bytes. That's corpus-dependent, and we ran only the one. We didn't test anything with strong subspace structure, which is exactly where trained codebooks would have more to fit than a per-coordinate scheme does.
A new vector has two jobs: get into the search structure, and get onto disk. FAISS and turbovec split them. Their add writes straight into the layout the scan kernels read, so after turbovec's 18 µs the vector is searchable — in RAM, where a crash takes it back, because nothing touches disk until save rewrites the whole index. Infino's append does both jobs in one call: when it returns, the rows are searchable and committed.
| add | remove | persist | full cycle | |
|---|---|---|---|---|
| FAISS flat PQ | 3.14 ms | 401 µs | 15.2 ms | 68.5 ms |
| turbovec 4-bit | 18.0 µs | 3.4 µs | 33.4 ms | 21.4 ms |
| Infino 4-bit | 27 µs¹ | 2.7 µs¹ | — | 17.0 ms |
¹ per row, 100,000 rows sharing one commit, for the append and the delete alike. Infino separates add and save operations internally but renders them combined in its public API: append and delete each do the in-memory work and the save in one call, so the per-row cost is set by how many rows share it. A row alone in the commit costs the full 17 ms to add, 7.9 ms to delete.
Deletes have the same latency shape — turbovec's 3 µs remove is the in-RAM half, durable on its next save, and Infino's is again a commit — with one twist: a tombstone — the committed delete marker — carries no vector payload, so a wide delete amortizes below the in-RAM number, 2.7 µs a row durable against 3.4 µs in RAM. The two write models come down to what gets batched: the libraries batch time, with mutations waiting in RAM until the next save and a crash losing the interval, while Infino batches rows, and every call is a durability boundary.
Committing every write costs the scan nothing: 788 bytes per vector and 1.50 ms, inside the range of the two libraries.
Scaling the quantization
Everything above shares one constraint: to answer a query, each of these indexes reads every row. A SIMD scan over contiguous bytes has the DRAM and L1/L2/L3 cache behavior that random pointers through data structures can't match, whether it is a graph, a tree, or other types of routing. At small corpora, flat scan is the natural shape.
We ran the same benchmark at four corpus sizes:
| warm p50 @ k=10 | 100K | 250K | 500K | 933K |
|---|---|---|---|---|
| Infino SQ4 | 1.50 ms | 4.64 ms | 9.90 ms | 18.88 ms |
| turbovec 4-bit | 1.59 ms | 4.84 ms | 10.19 ms | 19.05 ms |
| FAISS flat PQ | 45.5 ms | 111.8 ms | 226.3 ms | 414.7 ms |
Recall over the same sweep:
| recall@10 | 100K | 250K | 500K | 933K |
|---|---|---|---|---|
| Infino SQ4 | 0.963 | 0.962 | 0.960 | 0.960 |
| turbovec 4-bit | 0.948 | 0.945 | 0.941 | 0.937 |
| FAISS flat PQ | 0.950 | 0.945 | 0.942 | 0.942 |
Infino recall holds within 0.3 points across the sweep. turbovec and flat PQ drift down about a point. However, given the linear nature of scans all 3 libraries incur latency penalties as the corpus scales. In the next post we'll show how we scale past that point, with different ANN (approximate nearest neighbor) structures built on the same fixed-grid philosophy.
Limits
- FAISS also ships a SIMD variant of this index:
PQ1536x4fs, "FastScan," which trades the float lookup tables for 8-bit-quantized ones resolved by shuffle instructions. We measured it, and its speed is real — 4.4 ms on this corpus, 10× faster than the classic scanner. Its recall is not publishable: at this 1536-subquantizer geometry it swung between 0.59 and 0.91 across corpus sizes in our runs, and a control that scored the identical trained codebook through the classic float tables read a steady 0.934 — so the loss is inside the fast-scan kernel, not the quantization. We found no published comparison that reports FastScan recall at all; turbovec's own README uses classic PQ as its recall baseline and cites FastScan only as a speed reference, and FAISS normally deploys it with a reranking stage. - FAISS PQ is normally deployed at 32 to 96 bytes per vector, where its lookup table would be 32 to 96 KiB; the 768 bytes per vector measured here is far outside its usual range.
- Queries run one at a time throughout, so nothing in the test measures concurrency.
- Every recall number is a raw quantized operating point; we didn't measure any of these indexes with a rerank stage.
Reproducing it
git clone https://github.com/infino-ai/retrievalbench && cd retrievalbench printf 'vector:\n search_mode: flat_ivf\n' > infino.yaml INFINO_BENCH_SUPERTABLE_DOCS=100000 \ cargo bench -- vector-codec \ corpus=hf:KShivendu/dbpedia-entities-openai-1M corpus-dir=./corpora
The scaling table is the same command at INFINO_BENCH_SUPERTABLE_DOCS of 250000, 500000, and 1000000; the recall and latency tables all come from that one four-size sweep, and the build and write tables from the same harness at 100K. The corpus downloads once from HuggingFace. The FAISS row needs --features faiss after scripts/build_faiss.sh, which builds the bundled FAISS source with -march=native — without it, FAISS's SIMD kernels silently fall back to scalar code. The engine dependencies are pinned to a commit, and every committed run records host, engine commit and command in a run.json beside the numbers under results/inprocess/.