Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Random rotation for INT8/INT4 quantization** (#177)
- `ZVecIndexParams::setQuantizerEnableRotate(bool)` (fluent) enables random rotation before INT8/INT4 quantization for HNSW, Flat, IVF, and Vamana indexes — reduces quantization error and improves recall on quantized indexes.
- Mirrors upstream zvec v0.6.0 `QuantizerParam(enable_rotate)` (C API: `zvec_index_params_set_quantizer_enable_rotate`).

### Fixed

- **Deprecated index creation warnings** (#169)
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,11 @@ $params = ZVecIndexParams::forInvert(
bool $enableWildcard = false
): self

// Random rotation before INT8/INT4 quantization (reduces quantization error,
// improves recall; only effective with QUANTIZE_INT8 / QUANTIZE_INT4).
$params = ZVecIndexParams::forHnsw(ZVecSchema::METRIC_IP, quantizeType: ZVec::QUANTIZE_INT8)
->setQuantizerEnableRotate(true);

// Usage:
$collection->createIndex('embedding', ZVecIndexParams::forHnsw(ZVecSchema::METRIC_IP, quantizeType: ZVec::QUANTIZE_FP16));
```
Expand Down
36 changes: 30 additions & 6 deletions ffi/zvec_ffi.cc
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ struct IndexParamsHolder {
int rabitq_num_clusters_;
int rabitq_sample_count_;
bool hnsw_use_contiguous_memory_;
bool quantizer_enable_rotate_;
int vamana_max_degree_;
int vamana_search_list_size_;
float vamana_alpha_;
Expand All @@ -918,26 +919,43 @@ struct IndexParamsHolder {
invert_enable_range_(true), invert_enable_wildcard_(false),
rabitq_total_bits_(7), rabitq_num_clusters_(16), rabitq_sample_count_(0),
hnsw_use_contiguous_memory_(false),
quantizer_enable_rotate_(false),
vamana_max_degree_(64), vamana_search_list_size_(100), vamana_alpha_(1.2f),
vamana_saturate_graph_(false), vamana_use_contiguous_memory_(false), vamana_use_id_map_(false) {}

IndexParams::Ptr build() const {
IndexParams::Ptr params;
switch (type_) {
case IndexType::HNSW:
return std::make_shared<HnswIndexParams>(metric_type_, hnsw_m_, hnsw_ef_construction_, quantize_type_, hnsw_use_contiguous_memory_);
params = std::make_shared<HnswIndexParams>(metric_type_, hnsw_m_, hnsw_ef_construction_, quantize_type_, hnsw_use_contiguous_memory_);
break;
case IndexType::HNSW_RABITQ:
return std::make_shared<HnswRabitqIndexParams>(metric_type_, rabitq_total_bits_, rabitq_num_clusters_, hnsw_m_, hnsw_ef_construction_, rabitq_sample_count_);
params = std::make_shared<HnswRabitqIndexParams>(metric_type_, rabitq_total_bits_, rabitq_num_clusters_, hnsw_m_, hnsw_ef_construction_, rabitq_sample_count_);
break;
case IndexType::FLAT:
return std::make_shared<FlatIndexParams>(metric_type_, quantize_type_);
params = std::make_shared<FlatIndexParams>(metric_type_, quantize_type_);
break;
case IndexType::IVF:
return std::make_shared<IVFIndexParams>(metric_type_, ivf_n_list_, ivf_n_iters_, ivf_use_soar_, quantize_type_);
params = std::make_shared<IVFIndexParams>(metric_type_, ivf_n_list_, ivf_n_iters_, ivf_use_soar_, quantize_type_);
break;
case IndexType::INVERT:
return std::make_shared<InvertIndexParams>(invert_enable_range_, invert_enable_wildcard_);
params = std::make_shared<InvertIndexParams>(invert_enable_range_, invert_enable_wildcard_);
break;
case IndexType::VAMANA:
return std::make_shared<VamanaIndexParams>(metric_type_, vamana_max_degree_, vamana_search_list_size_, vamana_alpha_, vamana_saturate_graph_, vamana_use_contiguous_memory_, vamana_use_id_map_, quantize_type_);
params = std::make_shared<VamanaIndexParams>(metric_type_, vamana_max_degree_, vamana_search_list_size_, vamana_alpha_, vamana_saturate_graph_, vamana_use_contiguous_memory_, vamana_use_id_map_, quantize_type_);
break;
default:
return nullptr;
}
if (quantizer_enable_rotate_) {
// Rotation applies only to vector index types; non-vector types (INVERT)
// silently skip it (upstream C API rejects with INVALID_ARGUMENT instead).
auto* vector_params = dynamic_cast<VectorIndexParams*>(params.get());
if (vector_params) {
vector_params->set_quantizer_param(QuantizerParam(true));
}
}
return params;
}
};

Expand Down Expand Up @@ -1022,6 +1040,12 @@ void zvec_index_params_set_quantize_type(zvec_index_params_t params, int quantiz
h->quantize_type_ = to_quantize_type(quantize_type);
}

void zvec_index_params_set_quantizer_enable_rotate(zvec_index_params_t params, int enable_rotate) {
if (!params) return;
auto* h = static_cast<IndexParamsHolder*>(params);
h->quantizer_enable_rotate_ = (bool)enable_rotate;
}

void zvec_index_params_set_metric_type(zvec_index_params_t params, int metric_type) {
if (!params) return;
auto* h = static_cast<IndexParamsHolder*>(params);
Expand Down
1 change: 1 addition & 0 deletions ffi/zvec_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ void zvec_index_params_set_ivf(zvec_index_params_t params, int n_list, int n_ite
void zvec_index_params_set_vamana(zvec_index_params_t params, int max_degree, int search_list_size, float alpha, int saturate_graph, int use_contiguous_memory, int use_id_map, int quantize_type);
void zvec_index_params_set_invert(zvec_index_params_t params, int enable_range, int enable_wildcard);
void zvec_index_params_set_quantize_type(zvec_index_params_t params, int quantize_type);
void zvec_index_params_set_quantizer_enable_rotate(zvec_index_params_t params, int enable_rotate);
void zvec_index_params_set_metric_type(zvec_index_params_t params, int metric_type);
zvec_status_t zvec_collection_create_index(zvec_collection_t coll, const char* field_name, zvec_index_params_t params, uint32_t concurrency);

Expand Down
1 change: 1 addition & 0 deletions ffi/zvec_ffi_php.h
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ void zvec_index_params_set_ivf(zvec_index_params_t params, int n_list, int n_ite
void zvec_index_params_set_vamana(zvec_index_params_t params, int max_degree, int search_list_size, float alpha, int saturate_graph, int use_contiguous_memory, int use_id_map, int quantize_type);
void zvec_index_params_set_invert(zvec_index_params_t params, int enable_range, int enable_wildcard);
void zvec_index_params_set_quantize_type(zvec_index_params_t params, int quantize_type);
void zvec_index_params_set_quantizer_enable_rotate(zvec_index_params_t params, int enable_rotate);
void zvec_index_params_set_metric_type(zvec_index_params_t params, int metric_type);
zvec_status_t zvec_collection_create_index(zvec_collection_t coll, const char* field_name, zvec_index_params_t params, uint32_t concurrency);

Expand Down
15 changes: 15 additions & 0 deletions src/ZVecIndexParams.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ public static function forInvert(bool $enableRange = true, bool $enableWildcard
return new self($handle);
}

/**
* Enable/disable random rotation before INT8/INT4 quantization.
*
* Only effective with QUANTIZE_INT8 or QUANTIZE_INT4 quantize types.
* When enabled, vectors are randomly rotated before quantization to
* reduce quantization error (improves recall for quantized indexes).
*
* @throws ZVecException On FFI error
*/
public function setQuantizerEnableRotate(bool $enableRotate): self
{
self::ffi()->zvec_index_params_set_quantizer_enable_rotate($this->handle, $enableRotate ? 1 : 0);
return $this;
}

private static function ffi(): FFI
{
return ZVec::ffi();
Expand Down
169 changes: 169 additions & 0 deletions tests/test_quantizer_enable_rotate.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
--TEST--
Quantizer: random rotation (setQuantizerEnableRotate) for INT8/INT4 quantized indexes
--SKIPIF--
<?php if (!extension_loaded('ffi')) die('skip FFI extension not available'); ?>
--FILE--
<?php
require_once __DIR__ . '/../src/ZVec.php';
ZVec::init(logType: ZVec::LOG_CONSOLE, logLevel: ZVec::LOG_WARN);

$path = __DIR__ . '/../test_dbs/quantize_rotate_' . uniqid();
try {
$schema = new ZVecSchema('rotate_test');
$schema->setMaxDocCountPerSegment(1000)
->addInt64('id', nullable: false)
->addVectorFp32('v', dimension: 8, metricType: ZVecSchema::METRIC_IP);

$c = ZVec::create($path, $schema);

for ($i = 1; $i <= 20; $i++) {
$vec = [];
for ($j = 0; $j < 8; $j++) {
$vec[] = 0.01 * $i + 0.001 * $j;
}
$doc = new ZVecDoc("doc$i");
$doc->setInt64('id', $i)
->setVectorFp32('v', $vec);
$c->insert($doc);
}
$c->optimize();

$queryVec = [];
for ($j = 0; $j < 8; $j++) {
$queryVec[] = 0.01 + 0.001 * $j;
}

// Baseline: non-quantized HNSW query results
$baseline = $c->query('v', $queryVec, topk: 5);
assert(count($baseline) === 5, "Expected 5 baseline results");
$baselineIds = array_map(fn($r) => $r->getPk(), $baseline);

// HNSW + QUANTIZE_INT8 + random rotation enabled
$params = ZVecIndexParams::forHnsw(
metricType: ZVecSchema::METRIC_IP,
m: 16,
efConstruction: 200,
quantizeType: ZVec::QUANTIZE_INT8
)->setQuantizerEnableRotate(true);
$c->createIndex('v', $params);
$c->flush();
$c->optimize();
echo "Created HNSW QUANTIZE_INT8 index with random rotation\n";

$int8Rot = $c->query('v', $queryVec, topk: 5);
assert(count($int8Rot) === 5, "Expected 5 INT8 rotated results");
foreach ($int8Rot as $r) {
assert(strpos($r->getPk(), 'doc') === 0, "Expected valid doc ID");
}
$common = count(array_intersect($baselineIds, array_map(fn($r) => $r->getPk(), $int8Rot)));
assert($common >= 3, "Expected at least 3 common results with rotated INT8, got $common");
echo "HNSW rotated INT8 query returns accurate results ($common/5 overlap)\n";

// Flat + QUANTIZE_INT8 + rotation disabled (default) still skips rotation
$c->dropIndex('v');
$c->flush();
$params2 = ZVecIndexParams::forFlat(
metricType: ZVecSchema::METRIC_IP,
quantizeType: ZVec::QUANTIZE_INT8
);
$c->createIndex('v', $params2);
$c->flush();
$c->optimize();
echo "Created Flat QUANTIZE_INT8 index without rotation\n";

$flatNoRot = $c->query('v', $queryVec, topk: 5);
assert(count($flatNoRot) === 5, "Expected 5 Flat INT8 results");
echo "Flat QUANTIZE_INT8 query without rotation works\n";

// Flat + QUANTIZE_INT8 + rotation enabled
$c->dropIndex('v');
$c->flush();
$params3 = ZVecIndexParams::forFlat(
metricType: ZVecSchema::METRIC_IP,
quantizeType: ZVec::QUANTIZE_INT8
)->setQuantizerEnableRotate(true);
$c->createIndex('v', $params3);
$c->flush();
$c->optimize();
echo "Created Flat QUANTIZE_INT8 index with random rotation\n";

$flatRot = $c->query('v', $queryVec, topk: 5);
assert(count($flatRot) === 5, "Expected 5 rotated Flat INT8 results");
foreach ($flatRot as $r) {
assert(strpos($r->getPk(), 'doc') === 0, "Expected valid doc ID");
}
echo "Rotated Flat INT8 query returns valid results\n";

// IVF + QUANTIZE_INT8 + rotation enabled
$params4 = ZVecIndexParams::forIvf(
metricType: ZVecSchema::METRIC_IP,
nList: 4,
nIters: 5,
quantizeType: ZVec::QUANTIZE_INT8
)->setQuantizerEnableRotate(true);
$c->createIndex('v', $params4);
$c->flush();
$c->optimize();
echo "Created IVF QUANTIZE_INT8 index with random rotation\n";

$ivfRot = $c->query('v', $queryVec, topk: 5);
assert(count($ivfRot) === 5, "Expected 5 rotated IVF INT8 results");
echo "Rotated IVF INT8 query returns valid results\n";

// Vamana + QUANTIZE_INT8 + rotation enabled
$c->dropIndex('v');
$c->flush();
$params6 = ZVecIndexParams::forVamana(
metricType: ZVecSchema::METRIC_IP,
maxDegree: 32,
searchListSize: 50,
alpha: 1.0,
quantizeType: ZVec::QUANTIZE_INT8
)->setQuantizerEnableRotate(true);
$c->createIndex('v', $params6);
$c->flush();
$c->optimize();
echo "Created Vamana QUANTIZE_INT8 index with random rotation\n";

$vamanaRot = $c->query('v', $queryVec, topk: 5);
assert(count($vamanaRot) === 5, "Expected 5 rotated Vamana INT8 results");
echo "Rotated Vamana INT8 query returns valid results\n";

// Rotation flag is a no-op on non-quantized index (still works)
$c->dropIndex('v');
$c->flush();
$params5 = ZVecIndexParams::forHnsw(
metricType: ZVecSchema::METRIC_IP,
m: 16,
efConstruction: 200,
quantizeType: ZVec::QUANTIZE_UNDEFINED
)->setQuantizerEnableRotate(true);
$c->createIndex('v', $params5);
$c->flush();
$c->optimize();
echo "Created HNSW QUANTIZE_UNDEFINED index with rotation flag (no-op)\n";

$noQuant = $c->query('v', $queryVec, topk: 5);
assert(count($noQuant) === 5, "Expected 5 results from non-quantized index");
echo "Non-quantized index with rotation flag works\n";

$c->close();
echo "PASS: quantizer random rotation works\n";
} finally {
exec("rm -rf " . escapeshellarg($path));
}
?>
--EXPECT--
Created HNSW QUANTIZE_INT8 index with random rotation
HNSW rotated INT8 query returns accurate results (5/5 overlap)
Created Flat QUANTIZE_INT8 index without rotation
Flat QUANTIZE_INT8 query without rotation works
Created Flat QUANTIZE_INT8 index with random rotation
Rotated Flat INT8 query returns valid results
Created IVF QUANTIZE_INT8 index with random rotation
Rotated IVF INT8 query returns valid results
Created Vamana QUANTIZE_INT8 index with random rotation
Rotated Vamana INT8 query returns valid results
Created HNSW QUANTIZE_UNDEFINED index with rotation flag (no-op)
Non-quantized index with rotation flag works
PASS: quantizer random rotation works
Loading