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
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;
}
// `impl_` is only created on the first insertion, so an index that never held a vector
// trivially has nothing under `old_label`.
if (!impl_ || !impl_->has_id(old_label)) {
return VecSimRelabel_OldLabelMissing;
}
if (impl_->has_id(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
23 changes: 18 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()); }

VecSimRelabelCode relabelVector(labelType old_label, labelType new_label) {
return VecSimIndex_RelabelVector(index.get(), old_label, new_label);
}

py::object getVector(labelType label) {
VecSimIndexBasicInfo info = index->basicInfo();
size_t dim = info.dim;
Expand Down Expand Up @@ -713,6 +716,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 +810,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()

# 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