[8' read]
Embeddings Quantization & Dequantization Cookbook
When leveraging multiple kinds of embeddings, you may need to quantize or dequantize embeddings to different formats. The reasons vary, but among them:
- Faster retrieval: smaller vectors mean faster similarity search.
- Memory saving: 8× to 32× smaller storage for the same number of vectors.
This cookbook shows the full round trip: get embeddings from Mistral AI, quantize them into compact integer / bit-packed representations, then dequantize them back to floats.
It covers six formats, in decreasing order of fidelity and size:
| Format | Range | Bytes per dim | Notes |
|---|---|---|---|
int8 | [-127, 127] | 1 | Signed 8-bit, scaled by 127 |
uint8 | [0, 255] | 1 | Unsigned 8-bit, shifted to [-1, 1] |
int4 | [-7, 7] | 1/2 | Signed 4-bit, scaled by 7 (15 levels) |
uint4 | [0, 15] | 1/2 | Unsigned 4-bit, shifted to [-1, 1] |
binary | {-1, +1} | 1/8 | Sign bits, signed packing |
ubinary | {0, 1} | 1/8 | Sign bits, unsigned packing |
Installation#
!pip install torch numpy mistralaiimport torch
import numpy as npEmbeddings#
First, get a float embedding for a piece of text. The Mistral AI embeddings API returns a list of floats (one per dimension), typically in the range [-1, 1].
from mistralai.client import Mistral
import getpass
import os
if not os.environ.get("MISTRAL_API_KEY"):
os.environ["MISTRAL_API_KEY"] = getpass.getpass("Mistral API key: ")
client = Mistral(os.environ["MISTRAL_API_KEY"])
model = "mistral-embed"
def get_text_embedding(inputs):
embeddings_batch_response = client.embeddings.create(
model=model,
inputs=inputs
)
return embeddings_batch_response.data[0].embeddingtext = "Quantization reduces the precision of stored vector values."
embedding = get_text_embedding([text])
# Inspect what kind of floats the API actually returned.
# A Python `float` is a 64-bit (double) value;
print("type of one value:", type(embedding[0]).__name__) # 'float'
print("value kind:", float(embedding[0]).__class__.__name__) # 'float' (C double)
# We downcast to a float32 tensor for the rest of the cookbook
x = torch.tensor(embedding, dtype=torch.float32)
# Keep the dimension count — the bit-packed formats need it to trim padding bits
orig_dim = x.shape[0]
print("dims:", orig_dim, "| range:", x.min().item(), x.max().item())The rest of this cookbook quantizes this same x into four formats, then dequantizes each one back to floats.
Quantization#
Quantizing means reducing the precision of your stored values. Below are the forward (float → quantized) functions for each format.
int8 — scaled signed bytes#
Multiply by 127 and round to the nearest integer in [-127, 127]. Each float becomes a single signed byte.
def quantize_int8(x: torch.Tensor) -> torch.Tensor:
return torch.clamp(torch.round(x * 127.0), -127, 127).to(torch.int8)q_int8 = quantize_int8(x)
print(q_int8) # signed bytes in [-127, 127], same length as the embeddinguint8 — shifted unsigned bytes#
Map [-1, 1] to [0, 255] via x * 127.5 + 127.5, then round. Unsigned bytes are convenient for storage formats that dislike signed integers.
def quantize_uint8(x: torch.Tensor) -> torch.Tensor:
return torch.clamp(torch.round(x * 127.5 + 127.5), 0, 255).to(torch.uint8)q_uint8 = quantize_uint8(x)
print(q_uint8) # unsigned bytes in [0, 255], same length as the embeddingint4 — packed signed nibbles#
Halfway between int8 and binary: multiply by 7 and round to the nearest
integer in [-8, 7], offset to unsigned [0, 15], then pack two nibbles per
byte for 2× compression over int8 (8× over float32).
Because the embedding is normalized by its max absolute value before
quantization, the per-vector scale must be stored so it can be restored
on dequantization. quantize_int4 therefore returns (packed, scale).
def quantize_int4(x: torch.Tensor) -> tuple[torch.Tensor, float]:
scale = x.abs().max().clamp(min=1e-8) # per-vector scale to store
x_norm = x / scale # normalize to [-1, 1]
nibbles = (torch.clamp(torch.round(x_norm * 7.0), -8, 7) + 8).to(torch.uint8) # offset to [0, 15]
arr = nibbles.cpu().numpy()
if arr.size % 2 != 0: # pad to even length
arr = np.append(arr, np.uint8(0))
packed = (arr[0::2] << 4) | arr[1::2] # two nibbles per byte
return torch.from_numpy(packed.astype(np.uint8).copy()), scale.item()q_int4, scale_int4 = quantize_int4(x)
print(q_int4) # uint8 bytes, ceil(orig_dim/2) elements (packed nibbles, padded)uint4 — packed unsigned nibbles#
Same packing idea, shifted to unsigned [0, 15] via (x + 1) * 7.5.
Returns (packed, scale) for the same scale-restoration reason.
def quantize_uint4(x: torch.Tensor) -> tuple[torch.Tensor, float]:
scale = x.abs().max().clamp(min=1e-8)
x_norm = x / scale
nibbles = torch.clamp(torch.round((x_norm + 1.0) * 7.5), 0, 15).to(torch.uint8)
arr = nibbles.cpu().numpy()
if arr.size % 2 != 0:
arr = np.append(arr, np.uint8(0))
packed = (arr[0::2] << 4) | arr[1::2]
return torch.from_numpy(packed.astype(np.uint8).copy()), scale.item()q_uint4, scale_uint4 = quantize_uint4(x)
print(q_uint4) # uint8 bytes, ceil(orig_dim/2) elements (packed nibbles, padded)binary / ubinary — packed sign bits#
Only the sign of each value is kept (+1 or -1), then 8 signs are packed into one byte for an 8× compression beyond int8. binary adds a 128 offset so that a 0 sign bit maps to -1; ubinary stores the raw sign bit (1 → +1, 0 → -1).
def _pack_sign_bits(x: torch.Tensor, signed: bool) -> torch.Tensor:
bits = (x > 0).to(torch.uint8) # 1 where positive, 0 elsewhere
arr = np.packbits(bits.cpu().numpy())
if signed:
arr = (arr.astype(np.int16) - 128).astype(np.int8) # shift into signed range
return torch.from_numpy(arr.copy())
def quantize_binary(x: torch.Tensor) -> torch.Tensor:
return _pack_sign_bits(x, signed=True)
def quantize_ubinary(x: torch.Tensor) -> torch.Tensor:
return _pack_sign_bits(x, signed=False)q_bin = quantize_binary(x)
q_ubin = quantize_ubinary(x)
print(q_bin) # signed bytes, ~orig_dim/8 elements (sign bits, padded)
print(q_ubin) # unsigned bytes, ~orig_dim/8 elements (sign bits, padded)The orig_dim values collapse to roughly ceil(orig_dim / 8) packed bytes; the last byte may hold padding bits that are trimmed on dequantization.
Dequantization#
Dequantization reverses each format back into float32 values in [-1, 1]. The bit-packed binary/ubinary formats need the original dimension count orig_dim so the padding bits can be trimmed.
def _unpack_nibbles(x_packed: torch.Tensor, orig_dim: int) -> torch.Tensor:
"""Unpack a nibble-packed tensor into a [0, 15] uint8 tensor, trimmed to orig_dim."""
arr = x_packed.cpu().numpy().astype(np.uint8)
high = (arr >> 4) & 0x0F
low = arr & 0x0F
nibbles = np.empty(arr.size * 2, dtype=np.uint8)
nibbles[0::2] = high
nibbles[1::2] = low
return torch.from_numpy(nibbles[:orig_dim].copy())
def _unpack_sign_bits(x_packed: torch.Tensor, signed: bool, C: int) -> torch.Tensor:
"""
Unpacks a bit-packed tensor into a ±1 float32 tensor.
C is the number of original dimensions (used to trim padding bits).
"""
arr = (x_packed.cpu().numpy().astype(np.int16) + (128 if signed else 0)).astype(np.uint8)
bits = np.unpackbits(arr, axis=-1)[..., :C] # remove pad bits
return torch.from_numpy(bits.astype(np.int16) * 2 - 1).float().to(x_packed.device)
def dequantize(
q: torch.Tensor | list, quant: str, orig_dim: int | None = None,
scale: float = 1.0,
) -> torch.Tensor:
"""
Dequantizes a quantized tensor or list back to float32 values.
quant supports: 'fp8' (already float), 'int8', 'uint8', 'int4', 'uint4',
'binary' and 'ubinary'. The packed formats 'int4', 'uint4', 'binary' and
'ubinary' require orig_dim to trim padding bits. 'int4' and 'uint4'
additionally require the per-vector scale returned by quantize_int4 /
quantize_uint4 so the original magnitude is restored.
"""
if isinstance(q, list):
q = torch.tensor(q)
if quant == "fp8":
return q.float()
if quant == "int8":
return q.float() / 127.0
if quant == "uint8":
return q.float() / 127.5 - 1.0
if quant == "int4":
if orig_dim is None:
raise ValueError("orig_dim needed for int4 nibble unpack")
nibbles = _unpack_nibbles(q, orig_dim)
return (nibbles.float() - 8.0) / 7.0 * scale # undo offset, scale, restore magnitude
if quant == "uint4":
if orig_dim is None:
raise ValueError("orig_dim needed for uint4 nibble unpack")
nibbles = _unpack_nibbles(q, orig_dim)
return (nibbles.float() / 7.5 - 1.0) * scale # undo shift, restore magnitude
if quant in {"binary", "ubinary"}:
if orig_dim is None:
raise ValueError("orig_dim needed for (u)binary unpack")
return _unpack_sign_bits(q, quant == "binary", orig_dim)
raise ValueError(f"Invalid quantization {quant}")Examples#
These dequantize the q_int8, q_uint8, q_bin, and q_ubin produced in the Quantization section above.
# Signed 8-bit -> float
x_int8 = dequantize(q_int8, quant="int8")
# Unsigned 8-bit -> float
x_uint8 = dequantize(q_uint8, quant="uint8")
# Signed 4-bit -> float
x_int4 = dequantize(q_int4, quant="int4", orig_dim=orig_dim, scale=scale_int4)
# Unsigned 4-bit -> float
x_uint4 = dequantize(q_uint4, quant="uint4", orig_dim=orig_dim, scale=scale_uint4)
# Packed sign bits -> float (orig_dim trims the padding bits)
x_bin = dequantize(q_bin, quant="binary", orig_dim=orig_dim)
x_ubin = dequantize(q_ubin, quant="ubinary", orig_dim=orig_dim)
print(x_int8.shape, x_int4.shape, x_bin.shape) # all restore the original dimension countRound-trip fidelity#
import torch.nn.functional as F
def cosine(a, b):
return F.cosine_similarity(a.unsqueeze(0).float(), b.unsqueeze(0).float()).item()
print("int8 :", cosine(x, dequantize(q_int8, "int8")))
print("uint8 :", cosine(x, dequantize(q_uint8, "uint8")))
print("int4 :", cosine(x, dequantize(q_int4, "int4", orig_dim, scale_int4)))
print("uint4 :", cosine(x, dequantize(q_uint4, "uint4", orig_dim, scale_uint4)))
print("binary :", cosine(x, dequantize(q_bin, "binary", orig_dim)))
print("ubinary:", cosine(x, dequantize(q_ubin, "ubinary", orig_dim)))| Format | Bytes per dim | Cosine vs. original (approx.) |
|---|---|---|
int8 | 1 | ~0.99 (near-lossless) |
uint8 | 1 | ~0.99 (near-lossless) |
int4 | 1/2 | ~0.98 (close-to-lossless) |
uint4 | 1/2 | ~0.98 (close-to-lossless) |
binary | 1/8 | ~0.78 (sign-only) |
ubinary | 1/8 | ~0.78 (sign-only) |
Takeaway: int8/uint8 preserve almost all information at 4× compression vs. float32; binary/ubinary drop magnitude but keep direction, giving 32× compression — ideal for large-scale retrieval where approximate search is acceptable.
Quantized vs. non-quantized classifier training#
The round-trip fidelity section above only measured cosine similarity between an original embedding and its quantized-then-dequantized version. A more practical question is: how much does quantization hurt a downstream task?
We train the same logistic-regression classifier on the Symptom2Disease
dataset once on the raw float embeddings, and once on each quantized-then-
dequantized version (int8, uint8, int4, uint4, binary, ubinary), then compare test
accuracy. Everything else - train/test split, StandardScaler, the classifier
hyper-parameters - is kept identical, so any accuracy gap is attributable to the
quantization format alone.
Dataset & Embeddings#
import pandas as pd
# Download the dataset
URL = "https://raw.githubusercontent.com/mistralai/cookbook/main/data/Symptom2Disease.csv"
df = pd.read_csv(URL, index_col=0)
# Remove full-row duplicates, keeping the first occurrence
before = len(df)
df = df.drop_duplicates()
after = len(df)
print(f"Rows before: {before}")
print(f"Rows after: {after}")
print(f"Removed: {before - after} duplicate rows")
def get_embeddings_by_chunks(data, chunk_size):
chunks = [data[x : x + chunk_size] for x in range(0, len(data), chunk_size)]
embeddings_response = [
client.embeddings.create(model=model, inputs=c) for c in chunks
]
return [d.embedding for e in embeddings_response for d in e.data]
df["embeddings"] = get_embeddings_by_chunks(df["text"].tolist(), 50)
# Original embedding dimension — the bit-packed formats need it to trim padding
orig_dim = len(df["embeddings"].iloc[0])
print("dims:", orig_dim, "| examples:", len(df))Quantize → Dequantize Helper#
This applies the cookbook's quantize_* / dequantize functions to every
embedding and hands back float32 arrays that sklearn can consume. "float"
is the no-op baseline (the raw API embeddings, untouched).
def quantize_dequantize(embeddings, quant, orig_dim):
"""
Quantize then dequantize a list of embeddings.
quant in {"float", "int8", "uint8", "int4", "uint4", "binary", "ubinary"}.
"float" returns the raw embedding unchanged (the non-quantized baseline).
Returns a list of float32 numpy arrays, one per input embedding.
"""
out = []
for emb in embeddings:
x = torch.tensor(emb, dtype=torch.float32)
if quant == "float":
out.append(x.numpy())
continue
if quant == "int8":
q = quantize_int8(x)
out.append(dequantize(q, quant, orig_dim=orig_dim).numpy())
elif quant == "uint8":
q = quantize_uint8(x)
out.append(dequantize(q, quant, orig_dim=orig_dim).numpy())
elif quant == "int4":
q, sc = quantize_int4(x)
out.append(dequantize(q, quant, orig_dim=orig_dim, scale=sc).numpy())
elif quant == "uint4":
q, sc = quantize_uint4(x)
out.append(dequantize(q, quant, orig_dim=orig_dim, scale=sc).numpy())
elif quant == "binary":
q = quantize_binary(x)
out.append(dequantize(q, quant, orig_dim=orig_dim).numpy())
elif quant == "ubinary":
q = quantize_ubinary(x)
out.append(dequantize(q, quant, orig_dim=orig_dim).numpy())
else:
raise ValueError(f"Invalid quantization {quant}")
return outBaseline: non-quantized float embeddings#
This is the reference classifier — the exact snippet from the task, trained on the untouched float embeddings.
# Create a train / test split
from sklearn.model_selection import train_test_split
train_x, test_x, train_y, test_y = train_test_split(
df["embeddings"], df["label"], test_size=0.5, random_state=42
)
# Normalize features
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
train_x = scaler.fit_transform(train_x.to_list())
test_x = scaler.transform(test_x.to_list())
# Train a classifier and compute the test accuracy
from sklearn.linear_model import LogisticRegression
# For a real problem, C should be properly cross validated and the confusion matrix analyzed
clf = LogisticRegression(random_state=0, C=1.0, max_iter=500).fit(
train_x, train_y.to_list()
)
# you can also try the sag algorithm:
# clf = LogisticRegression(random_state=0, C=1.0, max_iter=1000, solver='sag').fit(train_x, train_y)
mistral_classifier_precision = 100 * np.mean(clf.predict(test_x) == test_y.to_list())
print(f"Precision: {mistral_classifier_precision:.2f}%")
Quantized formats#
Now repeat the exact same pipeline on each quantized-then-dequantized version.
The train/test split uses the same random_state=42 on a list of the same
length, so every format is evaluated on the same examples — the only thing
that changes is the precision of the features fed to the classifier.
# @title Train LogisticRegression models for each quantization format
formats = ["float", "int8", "uint8", "int4", "uint4", "binary", "ubinary"]
results = {}
for quant in formats:
# Quantize -> dequantize every embedding back to floats to simulate loss of information
X = quantize_dequantize(df["embeddings"].tolist(), quant, orig_dim)
y = df["label"].tolist()
# Same split as the baseline (same length + same random_state => same indices)
train_x, test_x, train_y, test_y = train_test_split(
X, y, test_size=0.5, random_state=42
)
# Same normalization
scaler = StandardScaler()
train_x = scaler.fit_transform(train_x)
test_x = scaler.transform(test_x)
# Same classifier & hyper-parameters
clf = LogisticRegression(random_state=0, C=1.0, max_iter=500).fit(
train_x, train_y
)
precision = 100 * np.mean(clf.predict(test_x) == test_y)
results[quant] = precision
print(f"{quant:8s}: {precision:.2f}%")Results#
| Format | Bytes per dim | Precision (test accuracy) |
|---|
# @title Printing all Results
# size lookup
size_of = {}
for quant in formats:
if quant == "float":
size_of[quant] = "4 (float32)"
elif quant in ("int8", "uint8"):
size_of[quant] = "1"
elif quant in ("int4", "uint4"):
size_of[quant] = "0.5"
else: # binary, ubinary
size_of[quant] = "1/8"
# Build rows
headers = ["quant", "bytes/value", "precision"]
rows = [[quant, size_of[quant], f"{results[quant]:.2f}%"] for quant in formats]
# Compute column widths
widths = [len(h) for h in headers]
for r in rows:
for i, c in enumerate(r):
widths[i] = max(widths[i], len(c))
# Print aligned table
print(" ".join(headers[i].ljust(widths[i]) for i in range(len(headers))))
print(" ".join("-" * widths[i] for i in range(len(headers))))
for r in rows:
print(" ".join(r[i].ljust(widths[i]) for i in range(len(r))))Takeaway: int8 and uint8 (1 byte/dim) typically match the float baseline within a fraction of a percent — the ~4× storage saving costs almost nothing in classification accuracy. int4 and uint4 (0.5 bytes/dim, 8× compression) occupy a sweet spot between the two: they preserve both sign and coarse magnitude (15-16 levels instead of 256), usually staying within 1-2% of the float baseline while doubling the compression of int8. binary and ubinary (1 bit/dim, 32× compression) keep only the sign of each dimension; they lose magnitude and usually drop a few accuracy points, but can still be competitive when the embedding dimensionality is large enough for the sign pattern alone to remain discriminative - an attractive trade-off for large-scale retrieval pipelines where storage and latency dominate.