Skip to content

Lazy remove() leaves surviving nodes unreachable: contains()=True but search() cannot find them #8

Description

@titusz

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:

  1. Repair inbound links of surviving neighbors when a slot is freed (correct but costly).
  2. A working compact()/rebuild that provably restores reachability, exposed on the Python Index.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions