Qdrant TurboQuant vs scalar vs binary quantization, and when to rescore
TurboQuant arrived in Qdrant 1.18, and Qdrant calls 4-bit a good default for many collections. Compression per bit depth, which encodings rescore by default, how oversampling works, and a script that measures recall@10 on your data.
Qdrant TurboQuant is a quantization method added in Qdrant 1.18.0 (May 2026) that rotates each vector before compressing it, so one fixed quantization mapping works on any embedding distribution. Qdrant reports that 4-bit TurboQuant gives recall similar to scalar int8 at twice the compression (8x instead of 4x), with results that vary by dataset. Our recommendation for most new collections on the current release, Qdrant 1.20.0, is turbo with the default bits4, quantized vectors pinned in RAM, and a recall check against exact search before you ship it.
The rest of this post covers each option's byte cost, the rescoring defaults that changed with TurboQuant, why 1-bit binary quantization falls apart on small vectors, and a Python script that measures recall@10 for every method on an open dataset. Every config below was run against qdrant/qdrant:v1.20.0 with qdrant-client 1.20.0.
The options in Qdrant 1.20
Qdrant has four quantization families. The quantization guide lists their compression ratios against float32, and those ratios are all you need to compute the size of the quantized codes. The table below does that for two common embedding sizes. It counts raw code bytes only. Index structures and the original vectors come on top, and the vector sizing calculator adds them for you.
| Method | Config | Since | Compression | Bytes/vector, 768d | Bytes/vector, 1536d |
|---|---|---|---|---|---|
| None (float32) | none | 1x | 3,072 | 6,144 | |
| Scalar | scalar, type: int8 | 1.1.0 | 4x | 768 | 1,536 |
| TurboQuant 8-bit | turbo, bits: bits8 | 1.20.0 | 4x | 768 | 1,536 |
| TurboQuant 4-bit | turbo, bits: bits4 (default) | 1.18.0 | 8x | 384 | 768 |
| TurboQuant 2-bit | turbo, bits: bits2 | 1.18.0 | 16x | 192 | 384 |
| TurboQuant 1.5-bit | turbo, bits: bits1_5 | 1.18.0 | ~21x | 144 | 288 |
| TurboQuant 1-bit | turbo, bits: bits1 | 1.18.0 | 32x | 96 | 192 |
| Binary 2-bit | binary, encoding: two_bits | 1.15.0 | 16x | 192 | 384 |
| Binary 1.5-bit | binary, encoding: one_and_half_bits | 1.15.0 | ~21x | 144 | 288 |
| Binary 1-bit | binary | 1.5.0 | 32x | 96 | 192 |
| Product | product, compression: x16 | 1.2.0 | x4 to x64 | 192 at x16 | 384 at x16 |
The formula is bytes = 4 × d / compression, or equivalently d × bits / 8. At 1536 dimensions and 10 million vectors, that is 61.44 GB of float32, 15.36 GB of int8, 7.68 GB at TurboQuant bits4 and 1.92 GB at 1 bit (decimal GB, codes only).
xychart-beta
title "Quantized code size per 1536d vector, bytes"
x-axis ["float32", "int8 / bits8", "bits4", "2-bit", "1.5-bit", "1-bit"]
y-axis "Bytes" 0 --> 6500
bar [6144, 1536, 768, 384, 288, 192]Computed from the documented compression ratios. Per-vector metadata and the index are excluded.
Scalar int8 maps each float32 component to one byte, using bounds set by quantile (0.99 drops the most extreme 1% of values from the bounds; it changes precision, never size). Binary keeps one sign bit per component, or two with two_bits, which adds an explicit zero bucket. Product quantization splits the vector into chunks and replaces each chunk with one of 256 centroids; Qdrant's docs say it is slower than scalar because the distance math is not SIMD-friendly, and it loses more accuracy.
TurboQuant applies a fast random (Hadamard) rotation first. The rotation spreads the vector's energy evenly across coordinates, so a single precomputed, globally optimized mapping fits every coordinate. Queries are scored at full precision against the compressed stored vectors; the docs call this asymmetric quantization and turn it on automatically. Qdrant's implementation adds length renormalization and a per-coordinate calibration pass on top of Google's published algorithm. Cosine, Dot and Euclidean get SIMD scoring. Manhattan (L1) works but reconstructs the full vector per comparison, so the docs send L1 users to scalar quantization.
What Qdrant reports for TurboQuant
The Qdrant 1.18 release post publishes recall on four datasets with HNSW m=16, ef_construct=128. These are Qdrant's numbers, reproduced as published in May 2026; we did not rerun them.
| Dataset (as named by Qdrant) | F32 | Scalar | TQ 4-bit | Binary 1-bit | TQ 1-bit |
|---|---|---|---|---|---|
| arxiv-titles-instructorxl-embeddings | 0.9419 | 0.9285 | 0.9193 | 0.4683 | 0.6763 |
| gte-multilingual-ads-1M | 0.9298 | 0.9187 | 0.9169 | 0.6760 | 0.7717 |
| dbpedia-entities-openai3-text-embedding-3-large-1536-100K | 0.9348 | 0.9339 | 0.9271 | 0.6921 | 0.7924 |
| wikipedia-2023-11-embed-multilingual-v3 | 0.9446 | 0.9014 | 0.9261 | 0.5409 | 0.6300 |
Read each column against F32, since HNSW alone already costs 5 to 7 points here. Scalar beats TQ 4-bit on three datasets by under 1 point and loses on the fourth by 2.5. That is the "similar recall at double the compression" claim, and it is a fair summary of this table. The 1-bit columns are the more interesting result: TurboQuant beats binary on all four, by 9 to 21 points. Qdrant also says 1-bit TurboQuant is slower than binary at the same storage budget, and the docs repeat that tradeoff at 2, 1.5 and 1 bit: binary is faster, TurboQuant has better recall.
For bits8, added in 1.20.0, the docs say only that it "matched or exceeded the recall of scalar quantization at the same compression, with similar speed" in Qdrant's benchmarks. No table is published.
Rescore and oversampling in Qdrant TurboQuant search
A quantized search runs in two steps. HNSW traverses the quantized codes and collects limit × oversampling candidates. If rescore is on, Qdrant reads the original vectors for those candidates, recomputes exact scores and returns the top limit.
flowchart LR
Q([Query vector]) --> H["HNSW over quantized codes<br/>memory: pinned"]
H --> C["limit x oversampling<br/>candidates"]
C --> R{"rescore?"}
R -- yes --> O["Read original vectors<br/>memory: cached or cold"]
O --> T([Top limit by exact score])
R -- no --> T2([Top limit by quantized score])The oversampling parameter (since 1.3.0) defaults to 1.0, per the client's API schema. With oversampling: 2.4 and limit: 100, Qdrant preselects 240 candidates. Oversampling without rescoring does nothing useful, because the extra candidates are ranked by the same quantized score that missed them.
The trap is the default for rescore. When you leave it unset, Qdrant decides per method:
| Encoding | Rescores by default |
|---|---|
| Scalar int8 | No |
| Product | No |
TurboQuant bits8, bits4 | No |
TurboQuant bits2, bits1_5, bits1 | Yes |
| Binary (all encodings) | Yes |
So the recommended default, bits4, returns quantized scores unless you ask for rescoring. The docs give the reasoning: lower bit depths lose more precision and get rescored, while rescoring reads the originals, which slows search when they sit on disk. If your recall check comes back short, the first fix is rescore: true with oversampling: 2.0, set per query:
POST /collections/docs/points/query
{
"query": [0.2, 0.1, 0.9, 0.7],
"limit": 10,
"params": {
"quantization": { "rescore": true, "oversampling": 2.0 }
}
}
Memory tiers: memory replaces always_ram and on_disk
Qdrant 1.19 replaced always_ram (quantized vectors) and on_disk (original vectors) with one memory parameter that takes pinned, cached or cold. The old flags are deprecated but still accepted, and if you set both, memory wins. The memory tiers page has the full mapping: always_ram: true is pinned, on_disk: true is cold.
pinned loads data onto the heap and never evicts it. cached and cold are both memory-mapped files; cached warms the OS page cache at startup, cold waits for the first read. Original dense vectors default to cached and cannot be pinned. Quantized vectors follow the originals: pinned if the originals are cached, cold if they are cold. That inheritance is why we set memory: pinned on the quantized config explicitly every time. The turbopuffer cold-start post shows the same first-read penalty from a different engine.
The config we reach for on collections that do not fit in RAM keeps the originals cold and the 4-bit codes pinned:
PUT /collections/docs
{
"vectors": { "size": 768, "distance": "Cosine", "memory": "cold" },
"quantization_config": {
"turbo": { "bits": "bits4", "memory": "pinned" }
}
}
In this layout every rescored candidate is a disk read, so oversampling multiplies I/O. The docs' own example of this mode also sets "datatype": "turbo4" on the originals. That stores the originals at 4 bits too, and the docs warn it can reduce recall. Leave it out unless disk size is the constraint, because rescoring against 4-bit originals cannot recover what 4 bits lost.
On older versions, the equivalent scalar config is "scalar": {"type": "int8", "always_ram": true} with "on_disk": true on the vectors.
Why binary quantization needs centered, high-dimensional vectors
One-bit binary keeps only the sign of each component. If a model's components cluster around a non-zero offset, most signs are identical across documents and carry almost no information. That is the "centered distribution" requirement in the docs.
Dimension count is the second limit. With 1-bit codes, the quantized similarity is a Hamming distance, and a d-dimensional code has only d + 1 possible distances. At 384 dimensions that is 385 distinct scores for the whole collection, so many documents tie and the true top 10 gets lost among them. At 3072 dimensions there are 3,073. Qdrant's own 2024 sweep in the quantization guide shows the effect: on 100 queries over 100,000 DBpedia vectors, text-embedding-3-large at 3072d reached 97% to 99% accuracy with rescoring, while text-embedding-3-small at 512d reached 71% to 91%. Qdrant labels those ranges indicative because of the small query sample.
Rescoring cannot fix that by itself. It only reorders the candidates the binary pass found. The 2-bit and 1.5-bit encodings (since 1.15.0) and asymmetric queries (query_encoding: scalar8bits) were added for exactly this case. TurboQuant's rotation addresses the centering problem directly, which is why it holds up better at 1 bit in the table above.
Measure recall@10 against exact search
Vendor tables tell you the shape of the tradeoff. Your recall depends on your model and your data, so measure it. The script below embeds the BEIR SciFact corpus (5,183 documents, 1,109 queries) with the open-source BAAI/bge-base-en-v1.5 model (768 dimensions), builds one collection per method, and compares each method's top 10 against brute-force search on the original vectors.
docker run -p 6333:6333 qdrant/qdrant:v1.20.0
pip install "qdrant-client>=1.20" sentence-transformers datasets numpy
python recall_quant.py
import time
import numpy as np
from qdrant_client import QdrantClient, models
K = 10
PINNED = models.Memory.PINNED
METHODS = {
"float32": None,
"scalar_int8": models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(type=models.ScalarType.INT8, quantile=0.99, memory=PINNED)),
"turbo_bits8": models.TurboQuantization(
turbo=models.TurboQuantQuantizationConfig(bits=models.TurboQuantBitSize.BITS8, memory=PINNED)),
"turbo_bits4": models.TurboQuantization(
turbo=models.TurboQuantQuantizationConfig(bits=models.TurboQuantBitSize.BITS4, memory=PINNED)),
"turbo_bits2": models.TurboQuantization(
turbo=models.TurboQuantQuantizationConfig(bits=models.TurboQuantBitSize.BITS2, memory=PINNED)),
"turbo_bits1": models.TurboQuantization(
turbo=models.TurboQuantQuantizationConfig(bits=models.TurboQuantBitSize.BITS1, memory=PINNED)),
"binary_1bit": models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(memory=PINNED)),
"binary_2bit": models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(encoding=models.BinaryQuantizationEncoding.TWO_BITS, memory=PINNED)),
}
# (label, rescore, oversampling); rescore=None keeps Qdrant's per-method default
SETTINGS = [("default", None, None), ("no_rescore", False, None),
("rescore_x1", True, 1.0), ("rescore_x2", True, 2.0), ("rescore_x3", True, 3.0)]
def embed():
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-base-en-v1.5") # 768 dims
corpus = load_dataset("BeIR/scifact", "corpus", split="corpus")
queries = load_dataset("BeIR/scifact", "queries", split="queries")
docs = [f"{t}\n{x}" for t, x in zip(corpus["title"], corpus["text"])]
d = model.encode(docs, batch_size=64, normalize_embeddings=True, show_progress_bar=True)
q = model.encode(queries["text"], normalize_embeddings=True)
return d, q
def build(client, name, vecs, quant):
if client.collection_exists(name):
client.delete_collection(name)
client.create_collection(
name,
vectors_config=models.VectorParams(size=vecs.shape[1], distance=models.Distance.COSINE),
quantization_config=quant,
# small corpus: force HNSW + quantized segments instead of plain brute force
optimizers_config=models.OptimizersConfigDiff(indexing_threshold=1),
)
client.upload_collection(name, vectors=vecs, ids=range(len(vecs)), wait=True)
while True: # wait until every vector is indexed
info = client.get_collection(name)
if info.status == models.CollectionStatus.GREEN and info.indexed_vectors_count >= len(vecs):
return
time.sleep(1)
def top_ids(client, name, q, params):
res = client.query_points(name, query=q.tolist(), limit=K, search_params=params)
return {p.id for p in res.points}
def run(doc_vecs, query_vecs, url="http://localhost:6333"):
client = QdrantClient(url=url, timeout=120)
truth = None
for method, quant in METHODS.items():
name = f"recall_{method}"
build(client, name, doc_vecs, quant)
if truth is None: # ground truth: brute force on original float32 vectors
exact = models.SearchParams(exact=True, quantization=models.QuantizationSearchParams(ignore=True))
truth = [top_ids(client, name, q, exact) for q in query_vecs]
for label, rescore, oversampling in SETTINGS if quant else SETTINGS[:1]:
params = models.SearchParams(quantization=models.QuantizationSearchParams(
rescore=rescore, oversampling=oversampling))
t0 = time.perf_counter()
hits = [top_ids(client, name, q, params) for q in query_vecs]
ms = (time.perf_counter() - t0) * 1000 / len(query_vecs)
recall = np.mean([len(h & t) / K for h, t in zip(hits, truth)])
print(f"{method:12} {label:11} recall@{K}={recall:.4f} {ms:6.2f} ms/query")
if __name__ == "__main__":
run(*embed())
Two settings matter for a fair test. indexing_threshold=1 (in KB) forces Qdrant to build HNSW and quantized segments on a corpus this small; without it, small segments are searched by plain brute force and every method looks perfect. The ground truth uses exact: true together with ignore: true, so it never touches the quantized codes.
How to read the output
Each line is one method and one search setting. Start with the float32 default line: the gap between it and 1.0 is HNSW's loss, and no quantization setting can beat it. For every other method, compare its rows against that line.
The default row tells you what you get with no search params. In our smoke test on 1.20.0 it matched no_rescore for scalar, bits8 and bits4, and matched rescore_x1 for bits2, bits1 and both binary encodings. That confirms the defaults table above on your own install. If no_rescore sits well below rescore_x1, the quantized ranking is reordering your top 10. If recall keeps rising from rescore_x1 to rescore_x3, the true neighbors are being found just outside the candidate window, and oversampling is the cheap fix. If it stays flat, the codes are too coarse for this model and you need more bits.
The ms/query column is client-side wall time over HTTP, one query at a time, with everything in RAM. Treat it as a rough relative signal. To see what rescoring costs in production, rerun with memory=models.Memory.COLD on the VectorParams and a corpus larger than your page cache. To test your own data, replace embed() with a function that returns your document and query vectors as normalized NumPy arrays.
Which Qdrant quantization to use
flowchart TD
A{"Distance is Manhattan L1?"} -- yes --> S["scalar int8"]
A -- no --> B{"Qdrant 1.18 or later?"}
B -- no --> S
B -- yes --> C{"4-bit codes fit in RAM?"}
C -- yes --> T4["turbo bits4<br/>check recall, rescore if short"]
C -- no --> D{"Model is 1536d+ and centered,<br/>and speed beats recall?"}
D -- yes --> BQ["binary 2-bit or 1-bit<br/>rescore on, oversampling 3"]
D -- no --> T2["turbo bits2 or bits1<br/>rescore on by default"]| Situation | Our pick | Why |
|---|---|---|
| New collection, Qdrant 1.18+ | turbo, bits4, memory: pinned | 8x smaller, recall near scalar in Qdrant's tests |
| Recall must match scalar exactly | turbo, bits8 (1.20+), or scalar int8 | Same 4x size; Qdrant says bits8 matched or beat scalar |
| Manhattan (L1) distance | scalar int8 | TurboQuant L1 reconstructs every vector |
| RAM is the hard limit | turbo, bits2 or bits1 | Better recall than binary at equal bytes, per Qdrant |
| Throughput first, 1536d+ model | binary, two_bits or 1-bit, rescore on | Fastest scoring; Qdrant's binary results are on 1536d+ models |
| Smallest footprint at any cost | product, x32 or x64 | Up to 64x; slower and less accurate than scalar |
Before 1.18, binary quantization was the usual answer to "this does not fit in RAM", and on small models it was a poor one. TurboQuant fixes most of that, but bits4 leaves rescoring off by default, so measure before you trust it. Elasticsearch users face the same choice with BBQ; the bbq_hnsw vs bbq_disk post covers that engine's version of the tradeoff, and the Elasticsearch vs Qdrant comparison puts the two side by side. Retrieval recall is also what caps answer quality downstream, as we argue in an agent is only as good as its retriever.
Where we can help
We pick quantization settings and memory tiers for Qdrant collections, and we build the recall tests that prove the choice on your data. See our Qdrant consulting service.
Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.
