Embeddings Quantization & Dequantization Cookbook

Dequantization

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:

FormatRangeBytes per dimNotes
int8[-127, 127]1Signed 8-bit, scaled by 127
uint8[0, 255]1Unsigned 8-bit, shifted to [-1, 1]
int4[-7, 7]1/2Signed 4-bit, scaled by 7 (15 levels)
uint4[0, 15]1/2Unsigned 4-bit, shifted to [-1, 1]
binary{-1, +1}1/8Sign bits, signed packing
ubinary{0, 1}1/8Sign bits, unsigned packing

Installation#

!pip install torch numpy mistralai
import torch
import numpy as np

Embeddings#

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].embedding
text = "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 embedding

uint8 — 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 embedding

int4 — 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 count

Round-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)))
FormatBytes per dimCosine vs. original (approx.)
int81~0.99 (near-lossless)
uint81~0.99 (near-lossless)
int41/2~0.98 (close-to-lossless)
uint41/2~0.98 (close-to-lossless)
binary1/8~0.78 (sign-only)
ubinary1/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 out

Baseline: 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#

FormatBytes per dimPrecision (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.