Summary
After remove() + add() churn, surviving nodes can become unreachable from the HNSW graph:
contains(key) returns True and get(key) returns the correct vector, but search() with that
key's exact vector (distance 0.0) never returns it. The damage persists across save()/load() —
it is baked into the serialized graph.
This is silent recall loss under any update-heavy workload. It was found in production
(the ISCC search aggregator at search.iscc.io, which re-adds ~183k entries as remove+add on every
restart), then isolated to the engine.
Metric-independent: reproduced with stock Hamming and Tanimoto as well as the fork's NPHD, so
it is not related to any of the fork's patches.
Reproduction
Pure usearch, stock MetricKind.Hamming, no fork-specific features:
"""remove()+add() churn makes surviving keys unreachable from the HNSW graph."""
import numpy as np
from usearch.index import Index, MetricKind, ScalarKind
N = 25_000
BATCH = 256
PROBE = 999_999
ATTEMPTS = 10
def make():
return Index(ndim=256, metric=MetricKind.Hamming, dtype=ScalarKind.B1)
def attempt(rng):
vecs = rng.integers(0, 256, size=(N, 32), dtype=np.uint8)
pvec = rng.integers(0, 256, size=(1, 32), dtype=np.uint8)
keys = np.arange(1, N + 1, dtype=np.uint64)
index = make()
index.add(keys, vecs)
buf = index.save() # save + load is part of the trigger
index = make()
index.load(buf)
index.add(np.array([PROBE], dtype=np.uint64), pvec)
def reachable():
hits = index.search(pvec, 10)
return PROBE in [int(k) for k in np.atleast_1d(hits.keys).ravel()]
assert reachable(), "probe unreachable before any churn"
for start in range(0, N, BATCH):
bk = keys[start : start + BATCH]
index.remove(bk)
index.add(bk, vecs[start : start + BATCH])
if (start // BATCH) % 10 == 9 and not reachable():
return start + BATCH, index, pvec
return None, index, pvec
rng = np.random.default_rng(1)
for i in range(ATTEMPTS):
at, index, pvec = attempt(rng)
if at is not None:
print(f"attempt {i}: ORPHANED after {at:,} churn ops")
print(f" contains(probe) = {bool(index.contains(PROBE))}")
got = np.asarray(index.get(PROBE), dtype=np.uint8).ravel()
print(f" get(probe) correct = {np.array_equal(got, pvec.ravel())}")
print(f" search(exact vector) finds it = False")
print(f" index size = {len(index)}")
break
print(f"attempt {i}: survived")
else:
print(f"no orphan in {ATTEMPTS} attempts (nondeterministic; base rate ~1 in 8)")
Output:
attempt 0: ORPHANED after 23,040 churn ops
contains(probe) = True
get(probe) correct = True
search(exact vector) finds it = False
index size = 25001
It is nondeterministic — identical inputs and seed produce different outcomes across runs
(Hamming seed=1 orphaned in one run and survived a re-run), consistent with multithreaded HNSW
construction. Measured base rate on a fixed-input repro was 1 in 8 runs for a specific probe key.
Sampling 500 random keys per index after 25k churned adds showed ~0.2–0.4% orphaned.
Observed orphan points across metrics, all with the same harness:
| metric |
orphaned after |
Hamming (stock) |
15,360 churn ops |
Tanimoto (stock) |
10,240 churn ops |
NPHD (fork) |
12,800 churn ops |
The first key added after load() is systematically the most susceptible.
Suspected mechanism
index_dense_gt::remove() is a lazy deletion: it stamps the slot with free_key_, pushes the slot
onto free_keys_, and drops the slot_lookup_ entry. It does not repair the inbound links
held by surviving nodes. A live node whose inbound links all ran through slots that were freed (and
later recycled by new add() calls) becomes unreachable from the entry point, while contains()
and get() keep working because they resolve through the key lookup rather than the graph.
Re-adding the same key relinks it, which is why the churned keys themselves stay reachable and
only bystander keys are lost.
Related: remove() does not update entry_slot_ when the entry node itself is removed.
Neither existing repair path helps
remove(keys, compact=True) (which calls isolate()) does not fix it. Measured orphan
rate 1/8 — identical to the 1/8 baseline — at ~70% higher cost (6.6s vs 3.9s mean for 25k churn
ops). Consistent with the mechanism: isolate() prunes links pointing to removed nodes, but
does not rebuild lost inbound links for surviving ones.
compact() does not repair it either, and is separately broken — see the companion issue
below.
The only reliable remedy we have found is rebuilding the index from the source of truth with a
single batch add().
What a fix might look like
Any of these would help, roughly in order of preference:
- Repair inbound links of surviving neighbors when a slot is freed (correct but costly).
- A working
compact()/rebuild that provably restores reachability, exposed on the Python Index.
- At minimum, a documented way to detect orphaned nodes (a reachability audit), so callers can
trigger a rebuild instead of silently losing recall.
Environment
- usearch-iscc 2.24.5 (reports
usearch.__version__ == 2.24.0)
- Python 3.12.0, Windows 10 x86_64
- Also observed on Linux/Docker in production
Companion issue
compact() returns another key's vector — see #9
Downstream tracking issue: iscc/iscc-usearch#30
Summary
After
remove()+add()churn, surviving nodes can become unreachable from the HNSW graph:contains(key)returnsTrueandget(key)returns the correct vector, butsearch()with thatkey's exact vector (distance 0.0) never returns it. The damage persists across
save()/load()—it is baked into the serialized graph.
This is silent recall loss under any update-heavy workload. It was found in production
(the ISCC search aggregator at search.iscc.io, which re-adds ~183k entries as remove+add on every
restart), then isolated to the engine.
Metric-independent: reproduced with stock
HammingandTanimotoas well as the fork'sNPHD, soit is not related to any of the fork's patches.
Reproduction
Pure usearch, stock
MetricKind.Hamming, no fork-specific features:Output:
It is nondeterministic — identical inputs and seed produce different outcomes across runs
(
Hammingseed=1 orphaned in one run and survived a re-run), consistent with multithreaded HNSWconstruction. Measured base rate on a fixed-input repro was 1 in 8 runs for a specific probe key.
Sampling 500 random keys per index after 25k churned adds showed ~0.2–0.4% orphaned.
Observed orphan points across metrics, all with the same harness:
Hamming(stock)Tanimoto(stock)NPHD(fork)The first key added after
load()is systematically the most susceptible.Suspected mechanism
index_dense_gt::remove()is a lazy deletion: it stamps the slot withfree_key_, pushes the slotonto
free_keys_, and drops theslot_lookup_entry. It does not repair the inbound linksheld by surviving nodes. A live node whose inbound links all ran through slots that were freed (and
later recycled by new
add()calls) becomes unreachable from the entry point, whilecontains()and
get()keep working because they resolve through the key lookup rather than the graph.Re-adding the same key relinks it, which is why the churned keys themselves stay reachable and
only bystander keys are lost.
Related:
remove()does not updateentry_slot_when the entry node itself is removed.Neither existing repair path helps
remove(keys, compact=True)(which callsisolate()) does not fix it. Measured orphanrate 1/8 — identical to the 1/8 baseline — at ~70% higher cost (6.6s vs 3.9s mean for 25k churn
ops). Consistent with the mechanism:
isolate()prunes links pointing to removed nodes, butdoes not rebuild lost inbound links for surviving ones.
compact()does not repair it either, and is separately broken — see the companion issuebelow.
The only reliable remedy we have found is rebuilding the index from the source of truth with a
single batch
add().What a fix might look like
Any of these would help, roughly in order of preference:
compact()/rebuild that provably restores reachability, exposed on the PythonIndex.trigger a rebuild instead of silently losing recall.
Environment
usearch.__version__ == 2.24.0)Companion issue
compact()returns another key's vector — see #9Downstream tracking issue: iscc/iscc-usearch#30