Skip to content
Open
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 src/VecSim/algorithms/hnsw/hnsw.h
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ class HNSWIndex : public VecSimIndexAbstract<DataType, DistType>,
#include "VecSim/algorithms/hnsw/hnsw_base_tests_friends.h"

#include "hnsw_serializer_declarations.h"

public:
// Serialization-only fields for V5 format (SQ8 support)
VecSimQuantType quantType = VecSimQuant_NONE;
std::vector<float> serializedMeanVector; // Mean vector for SQ8 indices (empty if not SQ8)

#endif

protected:
Expand Down
4 changes: 2 additions & 2 deletions src/VecSim/algorithms/hnsw/hnsw_serializer.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2006-Present, Redis Ltd.
* All rights reserved.
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
Expand Down Expand Up @@ -30,8 +31,7 @@ HNSWSerializer::EncodingVersion HNSWSerializer::ReadVersion(std::ifstream &input
}

void HNSWSerializer::saveIndex(const std::string &location) {
validateSave();
EncodingVersion version = EncodingVersion::V4;
EncodingVersion version = getWriteVersion();
std::ofstream output(location, std::ios::binary);
writeBinaryPOD(output, version);
saveIndexIMP(output);
Expand Down
5 changes: 4 additions & 1 deletion src/VecSim/algorithms/hnsw/hnsw_serializer.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2006-Present, Redis Ltd.
* All rights reserved.
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
Expand All @@ -22,6 +23,7 @@ class HNSWSerializer : public Serializer {
DEPRECATED = 2, // Last deprecated version
V3,
V4,
V5, // SQ8 quantization fields (quantType + mean vector)
INVALID
};

Expand All @@ -37,6 +39,7 @@ class HNSWSerializer : public Serializer {
EncodingVersion m_version;

private:
virtual void validateSave() const = 0;
virtual EncodingVersion getWriteVersion() const { return EncodingVersion::V4; }

void saveIndexFields(std::ofstream &output) const = 0;
};
5 changes: 3 additions & 2 deletions src/VecSim/algorithms/hnsw/hnsw_serializer_declarations.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2006-Present, Redis Ltd.
* All rights reserved.
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
Expand All @@ -26,8 +27,8 @@ virtual void saveIndexIMP(std::ofstream &output) override;
void restoreGraph(std::ifstream &input, HNSWSerializer::EncodingVersion version);

private:
// Functions for index saving.
void validateSave() const override;
HNSWSerializer::EncodingVersion getWriteVersion() const override;

void saveIndexFields(std::ofstream &output) const override;

void saveGraph(std::ofstream &output) const;
Expand Down
23 changes: 16 additions & 7 deletions src/VecSim/algorithms/hnsw/hnsw_serializer_impl.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2006-Present, Redis Ltd.
* All rights reserved.
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
Expand Down Expand Up @@ -40,13 +41,9 @@ HNSWIndex<DataType, DistType>::HNSWIndex(std::ifstream &input, const HNSWParams
}

template <typename DataType, typename DistType>
void HNSWIndex<DataType, DistType>::validateSave() const {
// V4 does not store quantization settings, and its loader always creates unquantized
// components. Reject the save rather than write a file the loader would misread.
if (this->isQuantized) {
throw std::runtime_error(
"Cannot save index: serialization of quantized indexes is not supported");
}
HNSWSerializer::EncodingVersion HNSWIndex<DataType, DistType>::getWriteVersion() const {
return this->isQuantized ? HNSWSerializer::EncodingVersion::V5
: HNSWSerializer::EncodingVersion::V4;
}

template <typename DataType, typename DistType>
Expand Down Expand Up @@ -265,6 +262,18 @@ void HNSWIndex<DataType, DistType>::saveIndexFields(std::ofstream &output) const
writeBinaryPOD(output, this->maxElements); // This will be used to restore the index initial
// capacity

// V5 fields: SQ8 quantization support (between factory params and index build params)
if (this->quantType != VecSimQuant_NONE) {
writeBinaryPOD(output, this->quantType);
// Write mean vector (dim floats). Empty vector means no mean (zero-mean SQ8).
bool hasMean = !this->serializedMeanVector.empty();
writeBinaryPOD(output, hasMean);
if (hasMean) {
output.write(reinterpret_cast<const char *>(this->serializedMeanVector.data()),
this->dim * sizeof(float));
}
}

// Save index build parameters
writeBinaryPOD(output, this->M);
writeBinaryPOD(output, this->M0);
Expand Down
115 changes: 104 additions & 11 deletions src/VecSim/index_factories/hnsw_factory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ template <VecSimMetric Metric>
: sq8::storage_bytes_count<Metric, false>(dim);
}

size_t GetSQ8StoredDataSize(VecSimMetric metric, size_t dim, bool with_mean) {
if (metric == VecSimMetric_L2) {
return GetSQ8StoredDataSize<VecSimMetric_L2>(dim, with_mean);
}
assert(metric == VecSimMetric_IP || metric == VecSimMetric_Cosine);
return GetSQ8StoredDataSize<VecSimMetric_IP>(dim, with_mean);
}

// Cosine over pre-normalized vectors is computed as inner product.
[[nodiscard]] constexpr VecSimMetric ResolveSQ8Metric(VecSimMetric metric, bool is_normalized) {
return (is_normalized && metric == VecSimMetric_Cosine) ? VecSimMetric_IP : metric;
Expand Down Expand Up @@ -71,8 +79,16 @@ VecSimIndex *NewIndex_SQ8(const HNSWParams *hnswParams, AbstractIndexInitParams

IndexComponents<DataType, float> components = CreateSQ8IndexComponents<DataType, Metric>(
abstractInitParams.allocator, abstractInitParams.dim, mean_ptr);
return NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams,
components);
auto index =
NewIndex_ChooseMultiOrSingle<DataType, float>(hnswParams, abstractInitParams, components);
#ifdef BUILD_TESTS
// Store quantization metadata for re-serialization.
index->quantType = hnswParams->quantType;
if (mean_ptr != nullptr) {
index->serializedMeanVector.assign(mean_ptr, mean_ptr + abstractInitParams.dim);
}
#endif
return index;
}

VecSimIndex *NewIndex(const VecSimParams *params, bool is_normalized) {
Expand Down Expand Up @@ -270,7 +286,8 @@ template <typename DataType, typename DistType = DataType>
inline VecSimIndex *NewIndex_ChooseMultiOrSingle(std::ifstream &input, const HNSWParams *params,
const AbstractIndexInitParams &abstractInitParams,
IndexComponents<DataType, DistType> &components,
HNSWSerializer::EncodingVersion version) {
HNSWSerializer::EncodingVersion version,
const float *mean_ptr = nullptr) {
HNSWIndex<DataType, DistType> *index = nullptr;
// check if single and call the ctor that loads index information from file.
if (params->multi)
Expand All @@ -282,17 +299,41 @@ inline VecSimIndex *NewIndex_ChooseMultiOrSingle(std::ifstream &input, const HNS

index->restoreGraph(input, version);

#ifdef BUILD_TESTS
// Store quantization metadata for re-serialization.
index->quantType = params->quantType;
if (mean_ptr != nullptr) {
index->serializedMeanVector.assign(mean_ptr, mean_ptr + abstractInitParams.dim);
}
#endif

return index;
}

// Initialize @params from file for V3
static void InitializeParams(std::ifstream &source_params, HNSWParams &params) {
// Initialize @params from file for V3+. For V5+ also reads quantType and mean vector.
static void InitializeParams(std::ifstream &source_params, HNSWParams &params,
HNSWSerializer::EncodingVersion version,
std::vector<float> &meanVector) {
Serializer::readBinaryPOD(source_params, params.dim);
Serializer::readBinaryPOD(source_params, params.type);
Serializer::readBinaryPOD(source_params, params.metric);
Serializer::readBinaryPOD(source_params, params.blockSize);
Serializer::readBinaryPOD(source_params, params.multi);
Serializer::readBinaryPOD(source_params, params.initialCapacity);

// V5: read quantization fields
if (version >= HNSWSerializer::EncodingVersion::V5) {
Serializer::readBinaryPOD(source_params, params.quantType);
if (params.quantType == VecSimQuant_SQ8) {
bool hasMean = false;
Serializer::readBinaryPOD(source_params, hasMean);
if (hasMean) {
meanVector.resize(params.dim);
source_params.read(reinterpret_cast<char *>(meanVector.data()),
params.dim * sizeof(float));
}
}
}
}

VecSimIndex *NewIndex(const std::string &location, bool is_normalized) {
Expand All @@ -317,14 +358,66 @@ VecSimIndex *NewIndex(const std::string &location, bool is_normalized) {
bad_name);
}

HNSWParams params;
InitializeParams(input, params);

VecSimParams vecsimParams = {.algo = VecSimAlgo_HNSWLIB,
.algoParams = {.hnswParams = HNSWParams{params}}};
HNSWParams params = {};
std::vector<float> meanVector;
InitializeParams(input, params, version, meanVector);

AbstractIndexInitParams abstractInitParams =
VecSimFactory::NewAbstractInitParams(&params, vecsimParams.logCtx, is_normalized);
VecSimFactory::NewAbstractInitParams(&params, nullptr, is_normalized);

if (params.quantType != VecSimQuant_NONE) {
// Reject unknown quantizers instead of silently loading an unquantized index.
if (params.quantType != VecSimQuant_SQ8) {
return NULL;
}

const float *mean_ptr = meanVector.empty() ? nullptr : meanVector.data();
const VecSimMetric metric = ResolveSQ8Metric(params.metric, is_normalized);

if (!SQ8ParamsSupported(params.type, metric)) {
return NULL;
}

// Override blob sizes for SQ8 storage layout.
size_t dim = params.dim;
abstractInitParams.isQuantized = true;
if (metric == VecSimMetric_L2) {
abstractInitParams.storedDataSize =
GetSQ8StoredDataSize<VecSimMetric_L2>(dim, mean_ptr != nullptr);
} else {
abstractInitParams.storedDataSize =
GetSQ8StoredDataSize<VecSimMetric_IP>(dim, mean_ptr != nullptr);
}

if (params.type == VecSimType_FLOAT32) {
abstractInitParams.inputBlobSize = dim * sizeof(float);
if (metric == VecSimMetric_L2) {
auto components = CreateSQ8IndexComponents<float, VecSimMetric_L2>(
abstractInitParams.allocator, dim, mean_ptr);
return NewIndex_ChooseMultiOrSingle<float>(input, &params, abstractInitParams,
components, version, mean_ptr);
} else {
auto components = CreateSQ8IndexComponents<float, VecSimMetric_IP>(
abstractInitParams.allocator, dim, mean_ptr);
return NewIndex_ChooseMultiOrSingle<float>(input, &params, abstractInitParams,
components, version, mean_ptr);
}
} else if (params.type == VecSimType_FLOAT16) {
abstractInitParams.inputBlobSize = dim * sizeof(float16);
if (metric == VecSimMetric_L2) {
auto components = CreateSQ8IndexComponents<float16, VecSimMetric_L2>(
abstractInitParams.allocator, dim, mean_ptr);
return NewIndex_ChooseMultiOrSingle<float16, float>(
input, &params, abstractInitParams, components, version, mean_ptr);
} else {
auto components = CreateSQ8IndexComponents<float16, VecSimMetric_IP>(
abstractInitParams.allocator, dim, mean_ptr);
return NewIndex_ChooseMultiOrSingle<float16, float>(
input, &params, abstractInitParams, components, version, mean_ptr);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SQ8 load path doesn't reject unsupported type/metric combinations the way the construction path (SQ8ParamsSupported) does. If params.type isn't FLOAT32/FLOAT16, execution falls through here into the non-quantized loader below and misreads SQ8-quantized bytes as raw vectors, instead of rejecting cleanly. Same issue for metric: any non-L2 metric silently gets treated as IP (line 391-405) with no check it's actually IP. Worth adding the same SQ8ParamsSupported-style guard here.


if (params.type == VecSimType_FLOAT32) {
IndexComponents<float, float> indexComponents = CreateIndexComponents<float, float>(
abstractInitParams.allocator, params.metric, abstractInitParams.dim, is_normalized);
Expand Down
2 changes: 2 additions & 0 deletions src/VecSim/index_factories/hnsw_factory.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ namespace HNSWFactory {
*/
VecSimIndex *NewIndex(const VecSimParams *params, bool is_normalized = false);
VecSimIndex *NewIndex(const HNSWParams *params, bool is_normalized = false);

size_t GetSQ8StoredDataSize(VecSimMetric metric, size_t dim, bool with_mean);
size_t EstimateInitialSize(const HNSWParams *params, bool is_normalized = false);
size_t EstimateInitialSize(const HNSWParams *params, bool is_normalized, bool with_mean);
size_t EstimateElementSize(const HNSWParams *params);
Expand Down
9 changes: 7 additions & 2 deletions src/VecSim/index_factories/tiered_factory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ inline VecSimIndex *NewIndex(const TieredIndexParams *params) {
AbstractIndexInitParams abstractInitParams =
VecSimFactory::NewAbstractInitParams(&bf_params, params->primaryIndexParams->logCtx, false);
assert(hnsw_index->getInputBlobSize() == abstractInitParams.storedDataSize);
assert(hnsw_params.quantType != VecSimQuant_NONE ||
hnsw_index->getStoredDataSize() == abstractInitParams.storedDataSize);
[[maybe_unused]] const size_t expected_stored_size =
hnsw_params.quantType == VecSimQuant_SQ8
? HNSWFactory::GetSQ8StoredDataSize(
hnsw_params.metric, hnsw_params.dim,
backend_params.algoParams.hnswParams.quantParams != nullptr)
: abstractInitParams.storedDataSize;
assert(hnsw_index->getStoredDataSize() == expected_stored_size);
auto frontendIndex = static_cast<BruteForceIndex<DataType, DistType> *>(
BruteForceFactory::NewIndex(&bf_params, abstractInitParams, false));

Expand Down
9 changes: 8 additions & 1 deletion src/VecSim/index_factories/tiered_factory.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2006-Present, Redis Ltd.
* All rights reserved.
* SPDX-FileCopyrightText: Copyright 2026 Arm Limited and/or its affiliates <open-source-office@arm.com>
*
* Licensed under your choice of the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
Expand All @@ -15,6 +16,7 @@
#include "VecSim/algorithms/hnsw/hnsw_tiered.h"
#include "VecSim/algorithms/svs/svs_tiered.h"
#include "VecSim/algorithms/brute_force/brute_force.h"
#include "VecSim/index_factories/hnsw_factory.h"
#include "VecSim/index_factories/factory_utils.h"

namespace TieredFactory {
Expand Down Expand Up @@ -42,7 +44,12 @@ VecSimIndex *NewIndex(const TieredIndexParams *params, HNSWIndex<DataType, DistT
AbstractIndexInitParams abstractInitParams =
VecSimFactory::NewAbstractInitParams(&bf_params, nullptr, false);
assert(hnsw_index->getInputBlobSize() == abstractInitParams.storedDataSize);
assert(hnsw_index->getStoredDataSize() == abstractInitParams.storedDataSize);
[[maybe_unused]] const size_t expected_stored_size =
hnsw_index->quantType == VecSimQuant_SQ8
? HNSWFactory::GetSQ8StoredDataSize(bf_params.metric, bf_params.dim,
!hnsw_index->serializedMeanVector.empty())
: abstractInitParams.storedDataSize;
assert(hnsw_index->getStoredDataSize() == expected_stored_size);
auto frontendIndex = static_cast<BruteForceIndex<DataType, DistType> *>(
BruteForceFactory::NewIndex(&bf_params, abstractInitParams, false));

Expand Down
Loading