Zvec Logo

Product Quantization (PQ)

Configure PQ4, PQ8, OPQ, and IVF FastScan for HNSW and IVF indexes.

Product Quantization (PQ) splits each vector into num_chunk subvectors and represents each subvector with a code from a trained codebook. Unlike scalar quantization, which compresses individual dimensions, PQ compresses groups of dimensions.

Zvec supports PQ with HNSW and IVF, dense FP32 vectors, and L2, IP, and COSINE metrics. This feature is included in the 0.8.1 release. The examples below use the Python API.

Code size and accuracy

  • PQ8 uses 8 bits per subvector (256 codebook entries).
  • PQ4 uses 4 bits per subvector (16 codebook entries).
  • OPQ learns a rotation before PQ training to reduce quantization error.
  • FastScan accelerates scans of packed PQ4 codes in IVF.

The code payload per vector is approximately num_chunk Γ— num_bits / 8 bytes. For example, a 768-dimensional FP32 vector occupies 3,072 bytes before compression; with 96 chunks, its PQ8 code occupies 96 bytes and its PQ4 code occupies 48 bytes. These figures exclude codebooks, index structures, metadata, and retained original vectors; they are not total collection memory or disk sizes.

More chunks or more bits generally preserve more information at a higher storage and computation cost. Measure recall and latency on your own data when choosing a configuration.

Configuration

Set quantize_type=zvec.QuantizeType.PQ and pass a zvec.QuantizerParam to HnswIndexParam or IVFIndexParam.

ParameterDefaultMeaning and constraints
num_chunk8Number of subvectors. Must be positive and no greater than the vector dimension. With PQ4, the dimension must be divisible by this value.
num_bits8Bits per subvector code: 4 or 8.
enable_rotateFalseEnables learned OPQ for PQ. This differs from the random rotation used by INT8/INT4 scalar quantization.
fast_scanFalseEnables FastScan. Requires IVF with PQ4; HNSW and PQ8 do not support it.
opq_iter5OPQ training iterations. Must be positive when OPQ is enabled.
opq_pq_iter4PQ training iterations within each OPQ iteration. Must be positive when OPQ is enabled.

Build an HNSW-PQ index

The following example creates an HNSW-PQ8 collection with OPQ. Use a new collection path.

import random
import zvec

dimension = 128
schema = zvec.CollectionSchema(
    name="hnsw_pq",
    vectors=[
        zvec.VectorSchema(
            name="embedding",
            data_type=zvec.DataType.VECTOR_FP32,
            dimension=dimension,
            index_param=zvec.HnswIndexParam(
                metric_type=zvec.MetricType.L2,
                quantize_type=zvec.QuantizeType.PQ,
                quantizer_param=zvec.QuantizerParam(
                    num_chunk=16,
                    num_bits=8,
                    enable_rotate=True,
                ),
            ),
        )
    ],
)
collection = zvec.create_and_open("./hnsw_pq", schema)

# Synthetic data for this example; use representative embeddings in practice.
rng = random.Random(42)
vectors = [
    [rng.uniform(-1, 1) for _ in range(dimension)]
    for _ in range(1024)
]
for start in range(0, len(vectors), 128):
    statuses = collection.insert([
        zvec.Doc(id=str(i), vectors={"embedding": vectors[i]})
        for i in range(start, min(start + 128, len(vectors)))
    ])
    assert all(status.ok() for status in statuses)

# Train the codebooks and build the PQ index.
collection.optimize()

PQ training runs during Collection.optimize(). Before optimization, untrained data remains searchable through the raw Flat index. Insert representative data before optimizing; creating a PQ schema alone does not build a trained compressed index.

Trained codebooks and quantizer settings persist when the collection is closed and reopened. Writable collections also support subsequent updates; optimization can train and rebuild indexes as data changes.

Use IVF-PQ4 with FastScan

To use IVF instead, replace the index_param in the preceding schema with:

index_param=zvec.IVFIndexParam(
    metric_type=zvec.MetricType.L2,
    n_list=16,
    n_iters=10,
    quantize_type=zvec.QuantizeType.PQ,
    quantizer_param=zvec.QuantizerParam(
        num_chunk=16,
        num_bits=4,
        fast_scan=True,
        enable_rotate=True,
    ),
)

Here, dimension 128 is divisible by 16 chunks. OPQ is optional and can be disabled independently of FastScan. To use ordinary IVF-PQ4, set fast_scan=False; to use IVF-PQ8, also set num_bits=8.

Query and refine results

PQ search uses approximate distances. Enable is_using_refiner to re-score candidates using the reference vectors. scale_factor controls candidate expansion: a value of 4 requests approximately four times topk candidates before refinement.

For the HNSW collection above:

results = collection.query(
    zvec.Query(
        field_name="embedding",
        vector=vectors[0],
        param=zvec.HnswQueryParam(
            ef=100,
            is_using_refiner=True,
            scale_factor=4,
        ),
    ),
    topk=10,
)
collection.close()

For an IVF collection, use the matching query parameter type:

param=zvec.IVFQueryParam(
    nprobe=8,
    is_using_refiner=True,
    scale_factor=4,
)

Refinement adds reference-vector reads and scoring work. It improves ranking within the retrieved candidate set but cannot recover neighbors that were never retrieved. Tune HNSW ef or IVF nprobe together with scale_factor, measuring recall and latency.

See HNSW, IVF, and collection optimization for the surrounding index lifecycle.

On this page