Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
48fe6db
[MOD-17706] Implement SVSIndex::relabelVector
nonirosenfeldredis Sep 9, 2026
a68d357
Expose relabelVector to python, and fix get_vector for SVS and tiered
nonirosenfeldredis Sep 9, 2026
326edc0
Address review: reuse isLabelExists, guard the relabel binding
nonirosenfeldredis Sep 10, 2026
06d42e6
Cover getDataByLabel's buffered and ingest-window cases
nonirosenfeldredis Sep 10, 2026
f548f3d
Revert the relabel binding's indexGuard lock
nonirosenfeldredis Sep 10, 2026
4115d72
Release the GIL in the relabel binding
nonirosenfeldredis Sep 10, 2026
219cd1d
trim comment
nonirosenfeldredis Sep 10, 2026
7066792
Pin that relabel is unsupported on a tiered SVS index
nonirosenfeldredis Sep 11, 2026
743cd20
Implement relabelVector for the tiered SVS index
nonirosenfeldredis Sep 11, 2026
086a3ba
Cover tiered SVS relabel from the python bindings
nonirosenfeldredis Sep 11, 2026
87d8fc5
Run the SVS relabel tests across the quantization modes, and cover multi
nonirosenfeldredis Sep 11, 2026
b7c0c38
Cover relabel against a deleted label, on SVS and on a tier
nonirosenfeldredis Sep 11, 2026
cd09a53
Add the concurrent relabel + query flow test
nonirosenfeldredis Sep 11, 2026
52c5917
Remove the skip-marked concurrent relabel + query flow test
nonirosenfeldredis Sep 12, 2026
98e7edc
Gate tiered SVS relabel on HAVE_SVS_REPLACE_EXTERNAL_ID
nonirosenfeldredis Sep 12, 2026
88be7eb
Acquire tiered SVS relabel's guards together to avoid a lock-order cycle
nonirosenfeldredis Sep 12, 2026
492ae88
Remove the plain-index parallel test suites
nonirosenfeldredis Sep 14, 2026
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
23 changes: 23 additions & 0 deletions cmake/svs.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,29 @@ if(USE_SVS)
message("SVS LVQ implementation not found")
add_compile_definitions(VectorSimilarity PUBLIC "HAVE_SVS_LVQ=0")
endif()

# `replace_external_id` is newer than the pre-built SVS releases that SVS_SHARED_LIB downloads,
# so whether it is available depends on which SVS this build ended up with rather than on the
# platform. Detect it in the header instead of assuming: it is a method, not a file, so this
# greps the header the LVQ check would have tested for existence.
set(SVS_DYNAMIC_INDEX_HEADER "svs/index/vamana/dynamic_index.h")
set(SVS_HAS_REPLACE_EXTERNAL_ID 0)
if(EXISTS "${svs_SOURCE_DIR}/include/${SVS_DYNAMIC_INDEX_HEADER}")
file(READ "${svs_SOURCE_DIR}/include/${SVS_DYNAMIC_INDEX_HEADER}" SVS_DYNAMIC_INDEX_SRC)
string(FIND "${SVS_DYNAMIC_INDEX_SRC}" "replace_external_id" SVS_REPLACE_EXTERNAL_ID_POS)
if(NOT SVS_REPLACE_EXTERNAL_ID_POS EQUAL -1)
set(SVS_HAS_REPLACE_EXTERNAL_ID 1)
endif()
unset(SVS_DYNAMIC_INDEX_SRC)
endif()

if(SVS_HAS_REPLACE_EXTERNAL_ID)
message("SVS replace_external_id found - SVS relabeling enabled")
add_compile_definitions(VectorSimilarity PUBLIC "HAVE_SVS_REPLACE_EXTERNAL_ID=1")
else()
message("SVS replace_external_id not found - SVS relabeling reports unsupported")
add_compile_definitions(VectorSimilarity PUBLIC "HAVE_SVS_REPLACE_EXTERNAL_ID=0")
endif()
else()
message(STATUS "SVS support disabled")
add_compile_definitions("HAVE_SVS=0")
Expand Down
2 changes: 1 addition & 1 deletion deps/ScalableVectorSearch
23 changes: 23 additions & 0 deletions src/VecSim/algorithms/svs/svs.h
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,29 @@ class SVSIndex : public VecSimIndexAbstract<svs_details::vecsim_dt<DataType>, fl
return deleteVectorsImpl(labels, n);
}

#if HAVE_SVS_REPLACE_EXTERNAL_ID
// Only declared when the SVS this was built against offers `replace_external_id`. The
// pre-built SVS releases predate it, so where it is missing this override is left out and the
// interface default reports `VecSimRelabel_Unsupported` - which tells a caller to fall back to
// delete + insert rather than read it as a no-op.
VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) override {
if (old_label == new_label) {
return VecSimRelabel_SameLabel;
}
// `isLabelExists` also covers the index that never held a vector, where `impl_` has not
// been created yet and so trivially holds nothing.
if (!isLabelExists(old_label)) {
return VecSimRelabel_OldLabelMissing;
}
if (isLabelExists(new_label)) {
return VecSimRelabel_NewLabelTaken;
}

impl_->replace_external_id(old_label, new_label);
return VecSimRelabel_OK;
}
#endif // HAVE_SVS_REPLACE_EXTERNAL_ID

bool isLabelExists(labelType label) const override {
return impl_ ? impl_->has_id(label) : false;
}
Expand Down
55 changes: 55 additions & 0 deletions src/VecSim/algorithms/svs/svs_tiered.h
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,61 @@ class TieredSVSIndex : public VecSimTieredIndex<DataType, float> {
}
return ret;
}
/**
* Move `old_label` onto `new_label`, leaving the vector where it is in whichever tier holds
* it. `new_label` must be unused in both tiers, not just the one holding `old_label`: a
* multi-value label routinely has copies in each, and a target taken in either would collide
* once the buffer drains.
*
* Reports `Unsupported` when the backend holds the label and cannot move it, which is the
* case when built against an SVS without `replace_external_id`. All-or-nothing: on any code
* other than `VecSimRelabel_OK` both tiers are untouched.
*
* `updateJobMutex` is taken first, in the order `updateSVSIndex` takes its own locks. Holding
* it is what makes this correct rather than merely serialised: an update job snapshots the
* buffer's labels *by value* and afterwards reconciles only id swaps and deletions, so a
* rename landing inside its window would be invisible to it and the vector would reach the
* backend under the old label.
*/
VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) override {
if (old_label == new_label) {
return VecSimRelabel_SameLabel;
}
auto *svs_index = GetSVSIndex();

std::lock_guard<std::mutex> update_lock(this->updateJobMutex);
std::unique_lock<std::shared_mutex> flat_lock(this->flatIndexGuard);
std::unique_lock<std::shared_mutex> main_lock(this->mainIndexGuard);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const bool in_flat = this->frontendIndex->isLabelExists(old_label);
const bool in_backend = svs_index->isLabelExists(old_label);
if (!in_flat && !in_backend) {
return VecSimRelabel_OldLabelMissing;
}
if (this->frontendIndex->isLabelExists(new_label) || svs_index->isLabelExists(new_label)) {
return VecSimRelabel_NewLabelTaken;
}

// The backend goes first because it is the tier that can refuse; refusing after the
// buffer had already moved would leave the label half applied.
if (in_backend) {
const VecSimRelabelCode backend_ret =
this->backendIndex->relabelVector(old_label, new_label);
if (backend_ret != VecSimRelabel_OK) {
return backend_ret;
}
}
if (in_flat) {
const VecSimRelabelCode flat_ret =
this->frontendIndex->relabelVector(old_label, new_label);
#ifdef BUILD_TESTS
assert(flat_ret == VecSimRelabel_OK && "the buffer just reported holding this label");
#endif
UNUSED(flat_ret);
}
return VecSimRelabel_OK;
}

size_t getNumMarkedDeleted() const override {
return this->GetSVSIndex()->getNumMarkedDeleted();
}
Expand Down
24 changes: 19 additions & 5 deletions src/python_bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,11 @@ class PyVecSimIndex {
template <typename DataType, typename DistType, typename NPArrayType = DataType>
inline py::object rawVectorsAsNumpy(labelType label, size_t dim) {
std::vector<std::vector<DataType>> vectors;
if (index->basicInfo().algo == VecSimAlgo_BF) {
dynamic_cast<BruteForceIndex<DataType, DistType> *>(this->index.get())
->getDataByLabel(label, vectors);
if (auto *tiered =
dynamic_cast<VecSimTieredIndex<DataType, DistType> *>(this->index.get())) {
tiered->getDataByLabel(label, vectors);
} else {
// index is HNSW
dynamic_cast<HNSWIndex<DataType, DistType> *>(this->index.get())
dynamic_cast<VecSimIndexAbstract<DataType, DistType> *>(this->index.get())
->getDataByLabel(label, vectors);
}
size_t n_vectors = vectors.size();
Expand Down Expand Up @@ -216,6 +215,11 @@ class PyVecSimIndex {

void runGC() { VecSimTieredIndex_GC(index.get()); }

VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) {
py::gil_scoped_release py_gil;
return VecSimIndex_RelabelVector(index.get(), old_label, new_label);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do you think we need to find a way to synchronize the new relabel_vector binding with concurrent HNSW queries? knn_parallel releases the GIL and its workers hold indexGuard, but this call bypasses that guard. Relabeling writes idToMetaData[id].label while queries can read it through getExternalLabel, without a common lock.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

decided to remove completly the parael tests on plain indexes. we have the concurrent tests on tiered instead

}

py::object getVector(labelType label) {
VecSimIndexBasicInfo info = index->basicInfo();
size_t dim = info.dim;
Expand Down Expand Up @@ -713,6 +717,14 @@ PYBIND11_MODULE(VecSim, m) {
.def_readwrite("initialCapacity", &BFParams::initialCapacity)
.def_readwrite("blockSize", &BFParams::blockSize);

py::enum_<VecSimRelabelCode>(m, "VecSimRelabelCode")
.value("VecSimRelabel_OK", VecSimRelabel_OK)
.value("VecSimRelabel_OldLabelMissing", VecSimRelabel_OldLabelMissing)
.value("VecSimRelabel_NewLabelTaken", VecSimRelabel_NewLabelTaken)
.value("VecSimRelabel_SameLabel", VecSimRelabel_SameLabel)
.value("VecSimRelabel_Unsupported", VecSimRelabel_Unsupported)
.export_values();

py::enum_<VecSimSvsQuantBits>(m, "VecSimSvsQuantBits")
.value("VecSimSvsQuant_NONE", VecSimSvsQuant_NONE)
.value("VecSimSvsQuant_Scalar", VecSimSvsQuant_Scalar)
Expand Down Expand Up @@ -799,6 +811,8 @@ PYBIND11_MODULE(VecSim, m) {
.def("create_batch_iterator", &PyVecSimIndex::createBatchIterator, py::arg("query_blob"),
py::arg("query_param") = nullptr)
.def("get_vector", &PyVecSimIndex::getVector)
.def("relabel_vector", &PyVecSimIndex::relabelVector, py::arg("old_label"),
py::arg("new_label"))
.def("run_gc", &PyVecSimIndex::runGC);

py::class_<PyHNSWLibIndex, PyVecSimIndex>(m, "HNSWIndex")
Expand Down
58 changes: 58 additions & 0 deletions tests/flow/test_bruteforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,61 @@ def test_range_query(self):

def test_multi_value(self):
self.multi_value(create_uint8_vectors)


def test_relabel_vector(test_logger):
dim = 16
num_elements = 100
index = create_flat_index(dim, VecSimMetric_L2, VecSimType_FLOAT32)

data = np.float32(np.random.random((num_elements, dim)))
for label, vector in enumerate(data):
index.add_vector(vector, label)

old_label, new_label = 7, num_elements + 500
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

# The label moved without the data moving, and the index neither grew nor shrank.
assert index.index_size() == num_elements
assert_allclose(index.get_vector(new_label)[0], data[old_label], rtol=1e-6)
assert index.get_vector(old_label).shape == (0, dim)

# Still searchable, and answering under the new label.
labels, distances = index.knn_query(data[old_label], 1)
assert labels[0][0] == new_label
assert distances[0][0] < 1e-6

# Each rejection is reported distinctly, and none of them modifies the index.
assert index.relabel_vector(num_elements + 1, 0) == VecSimRelabel_OldLabelMissing
assert index.relabel_vector(0, 1) == VecSimRelabel_NewLabelTaken
assert index.relabel_vector(0, 0) == VecSimRelabel_SameLabel
assert index.index_size() == num_elements
test_logger.info("flat index relabel_vector moved the label, keeping the vector data")


def test_relabel_vector_multi(test_logger):
dim = 16
num_labels = 20
per_label = 3
index = create_flat_index(dim, VecSimMetric_L2, VecSimType_FLOAT32, is_multi=True)

data = np.float32(np.random.random((num_labels, per_label, dim)))
for label in range(num_labels):
for vector in data[label]:
index.add_vector(vector, label)

old_label, new_label = 7, num_labels + 500
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

# Every vector under the label moves as a unit, keeping its data and insertion order.
assert index.index_size() == num_labels * per_label
assert_allclose(index.get_vector(new_label), data[old_label], rtol=1e-6)
assert index.get_vector(old_label).shape == (0, dim)

# Moving onto an occupied label must be rejected: accepting it would silently merge two
# labels' vectors, which is the failure mode unique to a multi index.
assert index.relabel_vector(new_label, 0) == VecSimRelabel_NewLabelTaken
assert index.get_vector(new_label).shape == (per_label, dim)
assert index.get_vector(0).shape == (per_label, dim)
assert index.index_size() == num_labels * per_label
test_logger.info("flat multi relabel_vector moved every vector under the label")
61 changes: 61 additions & 0 deletions tests/flow/test_hnsw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,3 +1156,64 @@ def test_range_query(self, test_logger):

def test_multi_value(self, test_logger):
self.multi_value(create_uint8_vectors, test_logger)


def test_relabel_vector(test_logger):
dim = 16
num_elements = 100
index = create_hnsw_index(dim, num_elements, VecSimMetric_L2, VecSimType_FLOAT32)

data = np.float32(np.random.random((num_elements, dim)))
for label, vector in enumerate(data):
index.add_vector(vector, label)

old_label, new_label = 7, num_elements + 500
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

# A relabel is bookkeeping only: the graph is untouched, so the vector keeps its data and its
# place in the index rather than being reinserted.
assert index.index_size() == num_elements
assert_allclose(index.get_vector(new_label)[0], data[old_label], rtol=1e-6)
assert index.get_vector(old_label).shape == (0, dim)
assert index.check_integrity()

labels, distances = index.knn_query(data[old_label], 1)
assert labels[0][0] == new_label
assert distances[0][0] < 1e-6

# Each rejection is reported distinctly, and none of them modifies the index.
assert index.relabel_vector(num_elements + 1, 0) == VecSimRelabel_OldLabelMissing
assert index.relabel_vector(0, 1) == VecSimRelabel_NewLabelTaken
assert index.relabel_vector(0, 0) == VecSimRelabel_SameLabel
assert index.index_size() == num_elements
assert index.check_integrity()
test_logger.info("HNSW relabel_vector moved the label, leaving the graph intact")


def test_relabel_vector_multi(test_logger):
dim = 16
num_labels = 20
per_label = 3
index = create_hnsw_index(dim, num_labels * per_label, VecSimMetric_L2, VecSimType_FLOAT32,
is_multi=True)

data = np.float32(np.random.random((num_labels, per_label, dim)))
for label in range(num_labels):
for vector in data[label]:
index.add_vector(vector, label)

old_label, new_label = 7, num_labels + 500
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

# All the label's internal ids are re-pointed together, and the graph is left alone.
assert index.index_size() == num_labels * per_label
assert_allclose(index.get_vector(new_label), data[old_label], rtol=1e-6)
assert index.get_vector(old_label).shape == (0, dim)
assert index.check_integrity()

# Accepting a move onto an occupied label would merge two labels' vectors.
assert index.relabel_vector(new_label, 0) == VecSimRelabel_NewLabelTaken
assert index.get_vector(new_label).shape == (per_label, dim)
assert index.get_vector(0).shape == (per_label, dim)
assert index.check_integrity()
test_logger.info("HNSW multi relabel_vector moved every vector under the label")
75 changes: 75 additions & 0 deletions tests/flow/test_hnsw_tiered.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,78 @@ def test_multi_range_query(test_logger):
# Expect zero results for radius==0
tiered_labels, tiered_distances = index.range_query(query_data, radius=0)
assert len(tiered_labels[0]) == 0


def test_relabel_vector(test_logger):
dim = 16
num_elements = 1000
hnsw_params = create_hnsw_params(dim, num_elements, VecSimMetric_L2, VecSimType_FLOAT32)
# A flat buffer large enough to hold everything, so the relabel below has a real chance of
# landing while the vector is still buffered with a pending ingest job.
index = Tiered_HNSWIndex(hnsw_params, create_tiered_hnsw_params(), num_elements)

data = np.float32(np.random.random((num_elements, dim)))
for label, vector in enumerate(data):
index.add_vector(vector, label)

# Relabel one early and one late label. The workers ingest in insertion order, so by now the
# early one is most likely already in HNSW while the late one is most likely still buffered
# with a pending ingest job - between them the two tiers both get covered. The buffered case is
# the delicate one: a job left holding the old label would either ingest the vector under it or
# throw out of the worker thread, and neither would survive the assertions below.
buffered = index.get_curr_bf_size()
test_logger.info(f"relabeling with {buffered} of {num_elements} vectors still buffered")
moved = {7: num_elements + 500, num_elements - 1: num_elements + 501}
for old_label, new_label in moved.items():
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

index.wait_for_index()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we also test get_vector while a vector is still buffered, and for a multi-value label whose vectors are split across both tiers?
The current tiered tests call it only after wait_for_index(), so they exercise retrieval after ingestion completes. Controlling the ingestion workers would make those additional states deterministic. The assertion should verify that every expected vector is returned exactly once.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

tested it in C++ now


# Once ingestion has drained, the vector sits in HNSW under the new label and under no other.
assert index.index_size() == num_elements
assert index.hnsw_label_count() == num_elements
for old_label, new_label in moved.items():
assert_allclose(index.get_vector(new_label)[0], data[old_label], rtol=1e-6)
assert index.get_vector(old_label).shape == (0, dim)

labels, distances = index.knn_query(data[old_label], 1)
assert labels[0][0] == new_label
assert distances[0][0] < 1e-6

# Each rejection is reported distinctly, and none of them modifies the index.
assert index.relabel_vector(num_elements + 1, 0) == VecSimRelabel_OldLabelMissing
assert index.relabel_vector(0, 1) == VecSimRelabel_NewLabelTaken
assert index.relabel_vector(0, 0) == VecSimRelabel_SameLabel
assert index.index_size() == num_elements
test_logger.info("tiered relabel_vector moved the label across both tiers")


def test_relabel_vector_multi(test_logger):
dim = 16
num_labels = 200
per_label = 5
hnsw_params = create_hnsw_params(dim, num_labels * per_label, VecSimMetric_L2,
VecSimType_FLOAT32, is_multi=True)
index = Tiered_HNSWIndex(hnsw_params, create_tiered_hnsw_params(), num_labels * per_label)

data = np.float32(np.random.random((num_labels, per_label, dim)))
for label in range(num_labels):
for vector in data[label]:
index.add_vector(vector, label)

# In a multi index a label can hold several pending ingest jobs at once, so a late label
# exercises re-keying all of them together while an early one is most likely already in HNSW.
buffered = index.get_curr_bf_size()
test_logger.info(f"relabeling with {buffered} of {num_labels * per_label} vectors buffered")
moved = {7: num_labels + 500, num_labels - 1: num_labels + 501}
for old_label, new_label in moved.items():
assert index.relabel_vector(old_label, new_label) == VecSimRelabel_OK

index.wait_for_index()

assert index.index_size() == num_labels * per_label
assert index.hnsw_label_count() == num_labels
for old_label, new_label in moved.items():
assert index.get_vector(new_label).shape == (per_label, dim)
assert index.get_vector(old_label).shape == (0, dim)
test_logger.info("tiered multi relabel_vector moved every vector under the label")
Loading
Loading