Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
38 changes: 33 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,10 @@ class PyVecSimIndex {

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

virtual VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) {
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.

}

py::object getVector(labelType label) {
VecSimIndexBasicInfo info = index->basicInfo();
size_t dim = info.dim;
Expand Down Expand Up @@ -369,6 +372,21 @@ class PyHNSWLibIndex : public PyVecSimIndex {
return wrap_results(results, max_results_num, n_queries);
}

// Plain HNSW leaves synchronisation to its caller: `relabelVector` takes `indexDataGuard`
// exclusively, but `topKQuery` does not take it at all, so that guard orders relabel only
// against other writers -- not against a reader resolving ids through `getExternalLabel`.
// `ElementMetaData` is `#pragma pack(1)`, so the label store is unaligned and a racing reader
// can see a torn value, not merely a stale one. `indexGuard` is this binding's stand-in for
// the caller's lock, and `knn_parallel` takes it shared, so a relabel has to take it
// exclusively to be excluded from those workers.
VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) override {
// The GIL is released before blocking on the mutex, as everything else that takes this
// guard does: a worker that already holds it needs the GIL back to finish.
py::gil_scoped_release py_gil;
std::unique_lock<std::shared_mutex> lock(*indexGuard);
return VecSimIndex_RelabelVector(index.get(), old_label, new_label);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

void addVectorsParallel(const py::object &input, const py::object &vectors_labels,
int n_threads) {
py::array vectors_data(input);
Expand Down Expand Up @@ -713,6 +731,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 +825,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