Skip to content

Commit 2887837

Browse files
authored
fix(python): preserve PQ num_bits in model training (#7583)
## Background While building vector indexes through Ray-based distributed indexing, we found that the PQ `num_bits` option could be ignored by the global PQ training path. The codebook is parameterized by this value: for example, 4-bit PQ should train 16 centroids per sub-vector, while 8-bit PQ trains 256. Before this change, Python/PyO3 PQ training always used 8 bits internally. That can produce a PQ codebook whose bit width does not match the later index build settings, which can make pre-trained PQ artifacts inconsistent with segment construction and hurt quantization/index quality. ## Summary - expose `num_bits` on Python PQ training helpers and keep the default at 8 bits - pass `num_bits` through the PyO3 PQ training path instead of hard-coding 8 - persist `num_bits` in saved `PqModel` metadata and reuse it when building from pre-trained PQ models ## Tests - `make build PYTHON=3.12` - `uv run --frozen pytest python/tests/test_indices.py::test_gen_pq python/tests/test_indices.py::test_indices_builder_multivector_distributed_dimensions` - `uv run --frozen ruff check python/tests/test_indices.py` - `uv run --frozen ruff format --check python/tests/test_indices.py` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added configurable Product Quantization (PQ) bit width via a new `num_bits` option (default: 8), including 4-bit models. - PQ training, vector transformation, and relevant index-building flows now use the selected bit width end-to-end. - PQ models now save and reload the chosen `num_bits` for consistent behavior and backward compatibility. - **Bug Fixes** - Improved PQ training validation to use the codebook size implied by the selected `num_bits`. - **Tests** - Extended PQ coverage to validate 4-bit codebooks and verify correct save/reload of `num_bits`. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 09174bc commit 2887837

5 files changed

Lines changed: 72 additions & 18 deletions

File tree

python/python/lance/indices/builder.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def train_pq(
164164
*,
165165
sample_rate: int = 256,
166166
max_iters: int = 50,
167+
num_bits: int = 8,
167168
fragment_ids: Optional[list[int]] = None,
168169
) -> PqModel:
169170
"""
@@ -195,14 +196,16 @@ def train_pq(
195196
This parameter is used in the same way as in the IVF model.
196197
max_iters: int
197198
This parameter is used in the same way as in the IVF model.
199+
num_bits: int
200+
The number of bits used to encode each PQ centroid.
198201
fragment_ids: list[int], optional
199202
If provided, train using only the specified fragments from the dataset.
200203
"""
201204
from lance.lance import indices
202205

203206
num_rows = self._count_rows(fragment_ids)
204207
num_subvectors = self._normalize_pq_params(num_subvectors, self.dimension)
205-
self._verify_pq_sample_rate(num_rows, sample_rate)
208+
self._verify_pq_sample_rate(num_rows, sample_rate, num_bits)
206209
distance_type = ivf_model.distance_type
207210
pq_codebook = indices.train_pq_model(
208211
self.dataset._ds,
@@ -214,8 +217,9 @@ def train_pq(
214217
max_iters,
215218
ivf_model.centroids,
216219
fragment_ids,
220+
num_bits,
217221
)
218-
return PqModel(num_subvectors, pq_codebook)
222+
return PqModel(num_subvectors, pq_codebook, num_bits=num_bits)
219223

220224
def prepare_global_ivf_pq(
221225
self,
@@ -226,6 +230,7 @@ def prepare_global_ivf_pq(
226230
accelerator: Optional[Union[str, "torch.Device"]] = None,
227231
sample_rate: int = 256,
228232
max_iters: int = 50,
233+
num_bits: int = 8,
229234
fragment_ids: Optional[list[int]] = None,
230235
) -> dict:
231236
"""
@@ -267,6 +272,7 @@ def prepare_global_ivf_pq(
267272
num_subvectors,
268273
sample_rate=sample_rate,
269274
max_iters=max_iters,
275+
num_bits=num_bits,
270276
fragment_ids=fragment_ids,
271277
)
272278

@@ -381,6 +387,7 @@ def transform_vectors(
381387
dest_uri,
382388
fragments,
383389
partition_ds_uri,
390+
pq.num_bits,
384391
)
385392

386393
def shuffle_transformed_vectors(
@@ -471,6 +478,7 @@ def load_shuffled_vectors(
471478
num_subvectors,
472479
distance_type,
473480
index_name,
481+
pq.num_bits,
474482
)
475483
else:
476484
raise ValueError("filenames must be a list of strings")
@@ -526,13 +534,17 @@ def _verify_base_sample_rate(self, sample_rate: int):
526534
f"The sample_rate must be an int greater than 1, got {sample_rate}"
527535
)
528536

529-
def _verify_pq_sample_rate(self, num_rows: int, sample_rate: int):
537+
def _verify_pq_sample_rate(
538+
self, num_rows: int, sample_rate: int, num_bits: int = 8
539+
):
530540
self._verify_base_sample_rate(sample_rate)
531-
if 256 * sample_rate > num_rows:
541+
required_rows = (2**num_bits) * sample_rate
542+
if required_rows > num_rows:
532543
raise ValueError(
533544
"There are not enough rows in the dataset to create PQ"
534-
f" codebook with a sample rate of {sample_rate}. {sample_rate * 256}"
535-
f" rows needed and there are {num_rows}"
545+
f" codebook with a sample rate of {sample_rate} and num_bits"
546+
f" of {num_bits}. {required_rows} rows needed and there are"
547+
f" {num_rows}"
536548
)
537549

538550
def _verify_ivf_sample_rate(

python/python/lance/indices/pq.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ class PqModel:
1414
Can be saved / loaded to checkpoint progress.
1515
"""
1616

17-
def __init__(self, num_subvectors: int, codebook: pa.FixedSizeListArray):
17+
def __init__(
18+
self, num_subvectors: int, codebook: pa.FixedSizeListArray, *, num_bits: int = 8
19+
):
1820
self.num_subvectors = num_subvectors
1921
"""The number of subvectors to divide source vectors into"""
22+
self.num_bits = num_bits
23+
"""The number of bits used to encode each PQ centroid"""
2024
self.codebook = codebook
2125
"""The centroids of the PQ clusters"""
2226

@@ -42,7 +46,10 @@ def save(self, uri: str, *, storage_options: Optional[Dict[str, str]] = None):
4246
uri,
4347
pa.schema(
4448
[pa.field("codebook", self.codebook.type)],
45-
metadata={b"num_subvectors": str(self.num_subvectors).encode()},
49+
metadata={
50+
b"num_subvectors": str(self.num_subvectors).encode(),
51+
b"num_bits": str(self.num_bits).encode(),
52+
},
4653
),
4754
storage_options=storage_options,
4855
) as writer:
@@ -65,9 +72,10 @@ def load(cls, uri: str, *, storage_options: Optional[Dict[str, str]] = None):
6572
"""
6673
reader = LanceFileReader(uri, storage_options=storage_options)
6774
num_rows = reader.metadata().num_rows
68-
metadata = reader.metadata().schema.metadata
75+
metadata = reader.metadata().schema.metadata or {}
6976
num_subvectors = int(metadata[b"num_subvectors"].decode())
77+
num_bits = int(metadata.get(b"num_bits", b"8").decode())
7078
codebook = (
7179
reader.read_all(batch_size=num_rows).to_table().column("codebook").chunk(0)
7280
)
73-
return cls(num_subvectors, codebook)
81+
return cls(num_subvectors, codebook, num_bits=num_bits)

python/python/lance/lance/indices/__init__.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ from typing import Optional
1717

1818
import pyarrow as pa
1919

20+
from .. import _Fragment
21+
2022
class IndexConfig:
2123
index_type: str
2224
config: str
@@ -48,6 +50,7 @@ def train_pq_model(
4850
max_iters: int,
4951
ivf_model: pa.Array,
5052
fragment_ids: Optional[list[int]] = None,
53+
num_bits: int = 8,
5154
) -> pa.Array: ...
5255
def transform_vectors(
5356
dataset,
@@ -58,6 +61,9 @@ def transform_vectors(
5861
ivf_centroids: pa.Array,
5962
pq_codebook: pa.Array,
6063
dst_uri: str,
64+
fragments: list[_Fragment],
65+
partitions_ds_uri: Optional[str] = None,
66+
num_bits: int = 8,
6167
): ...
6268
def build_rq_model(
6369
dimension: int,

python/python/tests/test_indices.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import numpy as np
99
import pyarrow as pa
1010
import pytest
11-
from lance.file import LanceFileReader
11+
from lance.file import LanceFileReader, LanceFileWriter
1212
from lance.indices import IndicesBuilder, IvfModel, PqModel
1313

1414
NUM_ROWS_PER_FRAGMENT = 10000
@@ -209,6 +209,29 @@ def test_gen_pq(tmpdir, rand_dataset, rand_ivf):
209209
assert pq.dimension == reloaded.dimension
210210
assert pq.codebook == reloaded.codebook
211211

212+
pq_4bit = IndicesBuilder(rand_dataset, "vectors").train_pq(
213+
rand_ivf,
214+
sample_rate=2,
215+
num_bits=4,
216+
)
217+
assert pq_4bit.num_bits == 4
218+
assert len(pq_4bit.codebook) == 16
219+
220+
pq_4bit.save(str(tmpdir / "pq_4bit"))
221+
reloaded = PqModel.load(str(tmpdir / "pq_4bit"))
222+
assert reloaded.num_bits == 4
223+
224+
legacy_pq_uri = str(tmpdir / "legacy_pq")
225+
with LanceFileWriter(
226+
legacy_pq_uri,
227+
pa.schema(
228+
[pa.field("codebook", pq.codebook.type)],
229+
metadata={b"num_subvectors": str(pq.num_subvectors).encode()},
230+
),
231+
) as writer:
232+
writer.write_batch(pa.table([pq.codebook], names=["codebook"]))
233+
assert PqModel.load(legacy_pq_uri).num_bits == 8
234+
212235

213236
def test_ivf_centroids_fragment_ids(tmpdir):
214237
rows_per_fragment = 32
@@ -300,7 +323,7 @@ def test_indices_builder_multivector_distributed_dimensions(tmpdir, monkeypatch)
300323

301324
captured_dimensions = {}
302325

303-
def train_pq_model(*args):
326+
def train_pq_model(*args, **kwargs):
304327
captured_dimensions["train_pq"] = args[2]
305328
return codebook
306329

python/src/indices.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,15 @@ async fn do_train_pq_model(
232232
distance_type: &str,
233233
sample_rate: u32,
234234
max_iters: u32,
235+
num_bits: u32,
235236
ivf_model: IvfModel,
236237
fragment_ids: Option<Vec<u32>>,
237238
) -> PyResult<ArrayData> {
238239
// We verify distance_type earlier so can unwrap here
239240
let distance_type = DistanceType::try_from(distance_type).unwrap();
240241
let params = PQBuildParams {
241242
num_sub_vectors: num_subvectors as usize,
242-
num_bits: 8,
243+
num_bits: num_bits as usize,
243244
max_iters: max_iters as usize,
244245
sample_rate: sample_rate as usize,
245246
..Default::default()
@@ -260,7 +261,7 @@ async fn do_train_pq_model(
260261

261262
#[pyfunction]
262263
#[allow(clippy::too_many_arguments)]
263-
#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, sample_rate, max_iters, ivf_centroids, fragment_ids=None))]
264+
#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, sample_rate, max_iters, ivf_centroids, fragment_ids=None, num_bits=8))]
264265
fn train_pq_model<'py>(
265266
py: Python<'py>,
266267
dataset: &Dataset,
@@ -272,6 +273,7 @@ fn train_pq_model<'py>(
272273
max_iters: u32,
273274
ivf_centroids: PyArrowType<ArrayData>,
274275
fragment_ids: Option<Vec<u32>>,
276+
num_bits: u32,
275277
) -> PyResult<Bound<'py, PyAny>> {
276278
let ivf_centroids = ivf_centroids.0;
277279
let ivf_centroids = FixedSizeListArray::from(ivf_centroids);
@@ -291,6 +293,7 @@ fn train_pq_model<'py>(
291293
distance_type,
292294
sample_rate,
293295
max_iters,
296+
num_bits,
294297
ivf_model,
295298
fragment_ids,
296299
),
@@ -398,7 +401,7 @@ async fn do_transform_vectors(
398401

399402
#[pyfunction]
400403
#[allow(clippy::too_many_arguments)]
401-
#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, ivf_centroids, pq_codebook, dst_uri, fragments, partitions_ds_uri=None))]
404+
#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, ivf_centroids, pq_codebook, dst_uri, fragments, partitions_ds_uri=None, num_bits=8))]
402405
pub fn transform_vectors(
403406
py: Python<'_>,
404407
dataset: &Dataset,
@@ -411,6 +414,7 @@ pub fn transform_vectors(
411414
dst_uri: &str,
412415
fragments: Vec<FileFragment>,
413416
partitions_ds_uri: Option<&str>,
417+
num_bits: u32,
414418
) -> PyResult<()> {
415419
let ivf_centroids = ivf_centroids.0;
416420
let ivf_centroids = FixedSizeListArray::from(ivf_centroids);
@@ -419,7 +423,7 @@ pub fn transform_vectors(
419423
let distance_type = DistanceType::try_from(distance_type).unwrap();
420424
let pq = ProductQuantizer::new(
421425
num_subvectors as usize,
422-
/*num_bits=*/ 8,
426+
num_bits,
423427
dimension,
424428
codebook,
425429
distance_type,
@@ -561,7 +565,7 @@ async fn do_load_shuffled_vectors(
561565
}
562566

563567
#[pyfunction]
564-
#[pyo3(signature=(filenames, dir_path, dataset, column, ivf_centroids, pq_codebook, pq_dimension, num_subvectors, distance_type, index_name=None))]
568+
#[pyo3(signature=(filenames, dir_path, dataset, column, ivf_centroids, pq_codebook, pq_dimension, num_subvectors, distance_type, index_name=None, num_bits=8))]
565569
#[allow(clippy::too_many_arguments)]
566570
pub fn load_shuffled_vectors(
567571
filenames: Vec<String>,
@@ -574,6 +578,7 @@ pub fn load_shuffled_vectors(
574578
num_subvectors: u32,
575579
distance_type: &str,
576580
index_name: Option<&str>,
581+
num_bits: u32,
577582
) -> PyResult<()> {
578583
let mut default_idx_name = column.to_string();
579584
default_idx_name.push_str("_idx");
@@ -595,7 +600,7 @@ pub fn load_shuffled_vectors(
595600
let distance_type = DistanceType::try_from(distance_type).unwrap();
596601
let pq_model = ProductQuantizer::new(
597602
num_subvectors as usize,
598-
/*num_bits=*/ 8,
603+
num_bits,
599604
pq_dimension,
600605
codebook,
601606
distance_type,

0 commit comments

Comments
 (0)