Skip to content

Add HNSW removal with a deleted mark - #127

Merged
gouyt13 merged 4 commits into
VectorDB-NTU:mainfrom
pma1999:hnsw-remove
Oct 2, 2026
Merged

gouyt13 merged 4 commits into
VectorDB-NTU:mainfrom
pma1999:hnsw-remove

Conversation

@pma1999

@pma1999 pma1999 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #126. This builds on #125 and is rebased on main now that #125 is merged.

It adds remove to the HNSW index with the design from #126: a deleted mark, ef left to the caller.

size_t HierarchicalNSW::remove(const PID* labels, size_t n);  // returns how many were newly removed
removed = index.remove(ids)

How it works

  • remove sets the high bit of a point's stored cluster ID. construct and load already keep num_cluster_ at or below 2^31, so a real cluster ID never has that bit, and a static_assert ties the two together. get_clusterid_by_internalid masks the bit, so the existing readers (the search estimators, reconstruct_rotated, get_quant_dist, cluster_id_of) see the real cluster.
  • In the base layer search, a removed point never enters boundedKNN, so it is never returned. It still enters candidate_set with its usual estimate, so the search walks through it as before. A removed entry point is treated the same way. The upper layers do not change.
  • add from Add HNSW insertion after construction #125 does not change. Removed points stay valid neighbours for new points, as in hnswlib.
  • search does not change ef. The docs describe searching with ef * num_points() / live points as a tuning option for callers.

The rules follow IVF::remove. The arguments are external labels, the IDs that add and search return, translated through label_lookup_. Every label is checked before anything changes, so an unknown label throws std::invalid_argument and removes nothing. The return value counts newly removed points: a repeated label counts once, and removing it again returns 0. Removed points stay in num_points() and cannot be restored. The marks survive save, load and resize. Like add, it is not safe to call while another thread searches or adds.

The Python binding takes any 1-D integer array and raises ValueError, with nothing removed, for an ID outside [0, num_points).

File compatibility

  • A file with no removed points does not change. Files saved by 153f391 (nbits 1, 4 and 9, L2 and IP), loaded and saved again by this branch, are byte for byte identical, also after resize and add, and every search result matches.
  • load masks the bit before its range check, so a cluster ID out of range is still refused when it carries the mark.
  • Releases 0.3.7 to 0.5.1 refuse a file with removed points ("HNSW: invalid or truncated index file"). I checked this with the 0.5.0 and 0.5.1 wheels and with 153f391. Releases before 0.3.7 have no cluster ID check, and the docs say not to open these files with them. This differs from IVF, whose +inf tombstones older releases still read.

Testing

Tests

  • HnswRemoveTest, over L2 and IP and nbits 1, 2, 4, 8 and 9: survivors keep their order and distances, the entry point can be removed, search reaches live points with 90% removed, labels that differ from internal IDs, save/load/resize and later adds, bad input removes nothing, removing every point, and load refusing an out-of-range cluster ID that carries the mark.
  • HnswRemoveAllocationTest.FailureRemovesNothing in the allocation failure harness from Add HNSW insertion after construction #125.
  • Python tests in the shape of the IVF ones: exclusion and reload, invalid and non-integer IDs, every integer dtype, empty input.
  • HnswRemoveTest.* is added to the SDE filter in CI.

The tests compare each result with the same index before the removal, or with a copy that removes nothing, instead of expecting every point to be found. While testing I saw that add can leave a point unreachable from the entry point. With 120 built points, 120 added points, M 8 and L2, this happened in 2 to 9 of 100 runs (nbits 1, 2 and 4), with or without removals and in the same runs. With IP it never happened. In every run, removal only hid the removed points from the set of reachable points. A first version of the Python test assumed full reachability and failed on Windows and macOS in my fork's CI, so all the remove tests now use this comparison. The C++ tests passed 300 repeated runs and the Python ones 60.

Each of these changes to the code makes at least one test fail: returning a removed entry point, returning removed neighbours, not walking through removed points, reading the cluster ID without the mask, load without the mask, marking before validating, using labels as internal IDs, and counting a repeated label twice.

Checks run locally on an i7-12700KF (Ubuntu 24.04 under WSL, GCC 13.3):

check result
ctest 281 passed (5 AVX-512 tests skipped on this CPU)
ctest under ASan and UBSan 281 passed
Intel SDE, -hsw 76 passed (5 AVX-512 tests skipped)
Intel SDE, -icl 81 passed
scripts/check-tidy.sh passed
scripts/check-format.sh (clang-format 15) clean
scripts/check-python.sh (ruff) clean
mkdocs build --strict built
pytest 633 passed, 21 skipped (faiss, examples and wheel tests)

I did not run NEON. The change is in the search template that every SIMD backend shares.

Recall after remove

Same setup as #126: M 16, ef_construction 200, 4·√n KMeans clusters, inner product on normalized vectors, 100k synthetic vectors with 384 dimensions, and 69.5k HotpotQA passages embedded with Qwen3-Embedding-4B and truncated to 384 dimensions (1,000 held-out queries). Each cell is the change in recall@10 on the live points against an index rebuilt without the removed points. The last column searches with the scaled ef and compares it with the rebuild at ef 10.

data nbits removed rebuild, ef 10 ef 10 ef 50 ef 100 ef 200 scaled ef
HotpotQA 1 10% 0.650 -0.004 -0.001 +0.001 -0.001 ef 12: +0.012
HotpotQA 1 30% 0.648 -0.008 +0.008 +0.009 +0.009 ef 15: +0.024
HotpotQA 1 50% 0.648 -0.031 -0.003 +0.002 +0.001 ef 20: +0.027
HotpotQA 4 10% 0.795 -0.012 -0.003 +0.001 +0.003 ef 12: +0.014
HotpotQA 4 30% 0.793 -0.039 -0.005 -0.002 +0.001 ef 15: +0.023
HotpotQA 4 50% 0.792 -0.072 -0.020 -0.002 +0.002 ef 20: +0.035
synthetic 1 10% 0.385 -0.004 +0.004 +0.006 +0.005 ef 12: +0.012
synthetic 1 30% 0.409 -0.017 +0.001 -0.002 -0.003 ef 15: +0.009
synthetic 1 50% 0.448 -0.056 -0.004 -0.004 -0.004 ef 20: +0.016
synthetic 4 10% 0.694 -0.027 -0.012 -0.006 -0.006 ef 12: +0.016
synthetic 4 30% 0.744 -0.092 -0.012 -0.006 -0.005 ef 15: -0.001
synthetic 4 50% 0.778 -0.161 -0.011 +0.003 +0.004 ef 20: +0.008
synthetic 8 10% 0.720 -0.019 -0.002 +0.002 +0.001 ef 12: +0.029
synthetic 8 30% 0.756 -0.103 -0.012 +0.001 +0.001 ef 15: +0.011
synthetic 8 50% 0.801 -0.173 -0.022 -0.002 +0.001 ef 20: +0.008

No search returned a removed point, and every search filled all k results.

Search speed without removals

This measures the cost of the mask and the extra check for users who never remove anything. Setup: 100k synthetic 384-d vectors, inner product, M 16, 2,000 queries, k 10, one thread pinned to one core of an otherwise idle machine. Both builds are portable Python wheels (native optimization off, AVX2 dispatch at run time) built with GCC 13.3, and both load the same saved index files. There are 12 rounds, and the order of the two builds alternates each round. Each round reports the median of 5 timed passes.

nbits ef 153f391 QPS (median) this branch QPS (median) ratio per-round ratio range
1 10 16164 15906 0.984 0.944 to 1.092
1 100 9657 9636 0.998 0.970 to 1.031
4 10 14148 14154 1.000 0.926 to 1.135
4 100 8576 8482 0.989 0.926 to 1.048

Every per-round range includes 1.0, but the rounds lean slightly below it: at nbits 1 and ef 10 the median per-round ratio is 0.973 and 9 of 12 rounds were slower. So I'd put the cost at 1 to 3% at most, which is within the noise of this machine.

@pma1999
pma1999 requested a review from gouyt13 as a code owner October 1, 2026 10:56
@pma1999

pma1999 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

The only failing job, Windows x86-64 (VS 2026), stopped before any test ran: CTest timed out while listing the tests (gtest_discover_tests runs rabitq_tests.exe --gtest_list_tests with the default 5 s timeout). The same commit passed that job in my fork's CI ten minutes earlier, and this PR doesn't change any CMake file or add static initialization to the tests. Could you re-run the failed job? I don't have permission to.

remove sets the high bit of each point's stored cluster ID. construct and
load keep the cluster count at or below 2^31, so a real cluster ID never
has that bit, and the getter masks it for every existing reader.

Search keeps marked points out of boundedKNN but still adds them to
candidate_set, so it walks through them as before. add still links new
points to them. The arguments are external labels; every one is checked
before any point is marked, as in IVF::remove. load masks the bit before
its range check.
Cover the API, the deleted mark in the file layout, scaling ef after
removals as a caller choice, and which older releases refuse files with
removed points.
add can leave a point unreachable from the entry point, with or without
removals, so a test that expects every point to be found fails at random
across rotators. Compare each search with the same index before the
removal, or with a copy that removes nothing, which is the property
remove has to keep.
@pma1999

pma1999 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main now that #125 is merged. The new run passed every job, Windows x86-64 (VS 2026) included, so the re-run I asked for above is no longer needed.

@gouyt13

gouyt13 commented Oct 2, 2026

Copy link
Copy Markdown
Member

Currently in hnsw.hpp, the !is_removed(...) condition skips both result insertion and distance refinement. With multi-bit codes, marking a node deleted therefore changes the score used to decide whether search will traverse it. Could you please do some experiments to decide if we should do the distance refinement for removed vertices or keep current impl?

Thank you.

@pma1999

pma1999 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

I ran the experiment. Short answer: I'd keep the current implementation. Refining removed neighbors recovers some recall at the same ef, but it costs more than raising ef does, and the docs already tell callers to raise ef after removals.

What I compared. A is the PR as it stands: a removed neighbor keeps its 1-bit estimate in candidate_set. B refines a removed neighbor with the same rule as a live one (boundedKNN.size() < TOPK || low_dist < distk) and puts the refined estimate in candidate_set, but it never enters boundedKNN and never moves distk. Both variants run in one binary behind a study-only switch, on the same saved index, so graph, rotator and removed set are identical. With nbits 1 there is no refinement, and the 168 paired rows I ran gave identical results for A and B.

Recall at the same ef. M 16, ef_construction 200, inner product, recall@10 on the live points against exact ground truth, 1,000 queries, 3 builds per cell. Data: 69.5k HotpotQA passages (Qwen3-Embedding-4B, 384 dimensions) and 100k synthetic vectors with 384 dimensions. Numbers are B minus A:

data nbits removed ef 10 ef 20 ef 50 ef 100 ef 200
HotpotQA 4 30% +0.003 +0.004 +0.001 +0.001 -0.000
HotpotQA 4 50% +0.010 +0.007 +0.005 +0.002 +0.000
HotpotQA 8 30% +0.016 +0.006 +0.000 +0.000 -0.000
synthetic 4 30% +0.023 +0.007 +0.001 +0.000 -0.000
synthetic 4 50% +0.031 +0.016 +0.003 +0.001 +0.000
synthetic 8 50% +0.034 +0.018 +0.003 +0.001 -0.001

B is better at low ef, more so with more removed points, and the gain is gone by ef 100 to 200. Over the 33 cells with nbits above 1 and 30% or more removed, the mean at ef 10 is positive in all 33 and no cell is significantly negative. I also ran L2 on the synthetic data (2 builds) and saw the same pattern.

Cost. B does one extra full estimate for every removed neighbor that looks promising. At ef 10 with 4 bits on HotpotQA that is +31 full estimates per query on top of 74 with 30% removed, and +62 on top of 63 with 50% removed. On one pinned core (i7-12700KF, GCC 13.3, AVX2, 100k x 384 synthetic, 12 alternating rounds), B's QPS is 2 to 6% lower than A's at the same ef, and B was slower in 11 or 12 of 12 rounds in every cell.

Same cost. Since ef is the caller's knob, I also compared them at equal QPS (4 bits, 50% removed, ef 10):

variant ef QPS recall@10
B 10 14934 0.656
A 11 15219 0.653
A 13 14734 0.701
A 15 14788 0.731

With 8 bits, B at ef 10 gives 0.658 at 13655 QPS, and A at ef 14 gives 0.726 at 13710 QPS. Interpolating A's measured curve at B's QPS (4 and 8 bits, 30% and 50% removed, ef 10 to 200; 16 of the 20 cells fall inside A's range), A is better by more than 0.0005 in 10 cells and B in 1. The difference is largest at ef 10 to 20. The documented ef * num_points / live points already brings A to the recall of a rebuild, as the table in the PR shows.

On the invariant you raised. You are right that marking a node changes the score search uses to traverse it, and B removes that difference. The data say it is a small effect that a slightly larger ef recovers more cheaply. A removed entry point already gets the full estimate, because search computes it before the removed check, and I left that as is.

If you would still rather have B for the invariance, it is a change of a few lines in searchBaseLayerST_AdaptiveRerankOptDirect and I can push it with tests. Otherwise I'll leave the PR unchanged.

@gouyt13
gouyt13 merged commit 8a6349a into VectorDB-NTU:main Oct 2, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HnswIndex.remove: hiding points in a built HNSW index

2 participants