Skip to content

Add HNSW insertion after construction - #125

Merged
gouyt13 merged 13 commits into
VectorDB-NTU:mainfrom
TamimEhsan:dynamic-hnsw-2
Oct 1, 2026
Merged

gouyt13 merged 13 commits into
VectorDB-NTU:mainfrom
TamimEhsan:dynamic-hnsw-2

Conversation

@TamimEhsan

Copy link
Copy Markdown
Member

Summary

Adds points to a built or loaded HNSW index without the original vectors, following the convention #124 set for IVF: dense labels returned to the caller, optional cluster ids, and no change to the index file format.

API

std::vector<PID> HierarchicalNSW::add(
    const float* data,
    size_t n,
    const PID* cluster_ids = nullptr,   // nullptr routes to the nearest centroid
    bool faster = false,
    size_t num_threads = 1
);

void HierarchicalNSW::resize(size_t new_max_elements);
new_ids = index.add(vectors) # route
new_ids = index.add(vectors, cluster_ids=labels, num_threads=8)
index.resize(index.max_elements + 100_000)
index.num_points

Decisions

Linking without the original data.

construct borrows the caller's vectors through rawDataPtr_ and links by exact distances. That pointer dangles once it returns and is null after load, so add cannot use it. Instead it scores with the estimator the search already uses: the point being compared from is rebuilt in rotated coordinates from its own stored RaBitQ code, and the point being compared to is scored straight from its packed code. This is the approach quantized SymphonyQG construction already takes (6303994), including the asymmetry - only one side is reconstructed.

Consequence, stated in the docs: graph quality depends on how a point arrived, and the centroids and rotation are never retrained.

Capacity is explicit.

add throws once the index holds max_elements() points;resize is the only way to grow. This matches hnswlib, where addPoint throws and resizeIndex is a separate public call. resize does not use realloc the way hnswlib does - the level-0 block comes from aligned_alloc, which realloc does not keep aligned - so it allocates, copies and frees, building every new resource before releasing any old one.

Seperate path for update

The update path is kept seperate explicitly to make the review easier. The add point path during the construction and update are quite similar and can be generalized. I will create a PR in the future after the update path is stable.

Tests

  • 10 C++ cases (HnswAddTest, HnswResizeTest): searchability, multi-cluster over both metrics, routing, parallel insert preserving labels and the graph, scratch not reused across differently shaped indexes, capacity exhaustion leaving the index usable, bad input rejected without mutation, and save/load round trips.
  • 12 Python cases covering the same surface through the bindings.
  • Pack/unpack round trip for extra-code widths 1–8 at four dimensions.

Checks run

check result
ctest 266 passed
pytest tests/python 630 passed, 5 skipped
scripts/check-tidy.sh build-tidy passed, all first-party translation units
scripts/check-format.sh clean
scripts/check-python.sh clean
mkdocs build --strict clean

Built with RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF. Only the backend this CPU selects (AVX-512) actually executed; AVX2 and NEON are covered indirectly by the existing generic-vs-dispatched byte equality assertion, not by the new round trip directly.

Measured behaviour

GIST-1M (960 dimensions) with its own 1,000 queries and ground truth, 64 clusters, M 32, ef_construction 200, 9 bits, 48 threads. Recall@10 measured only after the whole dataset is in the index. Two Xeon Gold 6418H, GCC 14.3, Release with RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF.

The added points reach a cluster one of two ways:

  • supplied - the caller passes cluster_ids, here the same k-means assignment construct used, read from clusterids_64.ivecs.
  • routed - cluster_ids is nullptr, so add picks each point's nearest centroid itself, scored the way a query is routed.

Both are measured because routing is its own code path: it could pick worse clusters
than real k-means, and it costs a pass over every centroid per point.

Recall@10

built by clusters for the added part ef 10 ef 50 ef 100 ef 200 ef 500
all construct - 0.4844 0.7920 0.8772 0.9338 0.9714
all construct, run 2 - 0.4867 0.7840 0.8725 0.9299 0.9701
all construct, run 3 - 0.4872 0.7848 0.8769 0.9361 0.9720
80% + 20% add supplied 0.4870 0.7907 0.8754 0.9347 0.9746
80% + 20% add routed 0.4948 0.7855 0.8746 0.9325 0.9718
50% + 50% add supplied 0.4928 0.7850 0.8718 0.9299 0.9698
50% + 50% add routed 0.4899 0.7820 0.8734 0.9328 0.9713

The first three rows are the same build repeated. They differ by up to 0.0080 at ef 50, because parallel construction does not fix the insertion order:

ef 10 ef 50 ef 100 ef 200 ef 500
spread over three identical builds 0.0028 0.0080 0.0047 0.0062 0.0019

Every add row sits inside that band, at both fractions and with clusters supplied or routed. The largest deviation from the mean of the three baselines is −0.0049, at ef 50, where identical builds differ by 0.0080. On this data add costs no measurable recall, and routing costs no measurable recall over supplying the real k-means assignment.

A later 50/50 supplied run, after the code had been reordered and re-documented, reproduced it: 0.4905 / 0.7817 / 0.8708 / 0.9323 / 0.9700.

Insert rate

points time rate
add 20%, supplied 200,000 29.8 s 6,705/s
add 20%, routed 200,000 29.7 s 6,726/s
add 50%, supplied 500,000 71.4 s 7,003/s
add 50%, routed 500,000 71.8 s 6,963/s
construct, for scale 1,000,000 98.7 s ~10,130/s

add runs at roughly 0.66–0.69× construct's per-point rate, the gap being the reconstruction each estimate needs. Routing cost 0.3% at 64 clusters and grows with the cluster count, since every point is scored against every centroid.

The harness that produced all of this is deliberately not in the PR, so these numbers cannot be reproduced from the tree. They are stated here as review context, which is why the documentation carries none of them.

Not included

  • No remove; this is insertion only.
  • No parallel construct fix for the level_generator_ race noted above. (To be tested and fixed in upcoming PRs)

@TamimEhsan
TamimEhsan requested a review from gouyt13 as a code owner September 30, 2026 17:35
gouyt13 and others added 2 commits October 1, 2026 12:29
Prepare returned IDs, quantized rows, and owned link lists before publishing
new points. Keep the index valid if graph linking fails, and document the
partial-insertion behavior.

Reject resizing an uninitialized index and score all pruning candidates with
the same query direction. Add allocation-failure, resize, and pruning
regression coverage.

Validation: 272 C++ tests; 633 Python tests passed, 2 skipped; full core and
binding clang-tidy; focused test clang-tidy; formatting; strict MkDocs build.
Focused pruning regression also passed ASan/UBSan with leak detection disabled.
Benchmarks omitted because these changes fix correctness; no performance
improvement is claimed.
@gouyt13

gouyt13 commented Oct 1, 2026

Copy link
Copy Markdown
Member

In mutually_connect_quant(), pruning a full neighbor list uses:

get_quant_dist(N, new_point);     // reconstructs new_point
get_quant_dist(old_neighbor, N);  // reconstructs N

, which introduces some inconsistency. Now, I have changed this to:

get_quant_dist(new_point, N);
get_quant_dist(old_neighbor, N);

Please double check the correctness. Thank you.

Keep exact checks for assigned cluster IDs, returned labels, and point count.
Check finite, valid search results in the expected well-separated cluster
instead of requiring exact point IDs from approximate search.

The original test failed 13 of 500 local repetitions because random rotation
can change ANN results. The updated test passed 1,000 repetitions.
@pma1999

pma1999 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for this. It is the piece txtai needs for its hnsw mode (neuml/txtai#1297), and I had been measuring the same approach on a local prototype. In case it helps the review, here are numbers for the low bit widths, since the description only covers 9 bits. txtai defaults to 1 bit.

I ran the current head (153f391) with txtai's settings: M 16, ef_construction 200, 4·√n KMeans clusters, inner product on normalized vectors, 8 threads. The first p% of the rows go through construct, and the rest go through resize and add with no cluster_ids, so they are routed. Recall@10 change against construct on all rows, mean of 5 seeds:

data nbits first build ef 10 ef 50 ef 200
synthetic, 100k × 384 1 10% -0.025 -0.023 -0.020
synthetic, 100k × 384 1 50% -0.041 -0.008 -0.002
synthetic, 100k × 384 4 10% -0.004 -0.008 -0.005
synthetic, 100k × 384 4 50% -0.003 -0.004 -0.003
HotpotQA, 69.5k × 384 1 10% -0.049 -0.036 -0.030
HotpotQA, 69.5k × 384 4 10% -0.004 -0.005 -0.004

construct reaches 0.369 / 0.447 / 0.447 on the synthetic data at 1 bit and 0.672 / 0.894 / 0.901 at 4 bits. On HotpotQA it reaches 0.649 / 0.711 / 0.715 and 0.793 / 0.928 / 0.950. The HotpotQA rows are Qwen3-Embedding-4B vectors truncated to 384 dimensions, with 1,000 held-out passages as queries. Ten identical construct runs differ by up to 0.023 at 1 bit and 0.013 at 4 bits.

So with 4 bits the loss stays under 0.01, which matches your 9-bit result. With 1 bit there is a loss, largest at low ef. Two things I found on my prototype:

  • The loss comes from the 1-bit estimates, not from inserting incrementally. In one ablation (synthetic data, 1 bit, half the rows added, ef 10, on the hybrid variant described below), exact distances only between pairs of already stored points recovered about half of the gap, and exact distances everywhere recovered all of it, down to a rebuild with the same centroids.
  • On HotpotQA, part of it comes from the clusters: 4·√n on 10% of the rows gives 333 clusters instead of 1055, and 1 bit quantizes worse against coarser clusters. A rebuild with those same 333 centroids already loses 0.024 at ef 10.

On the change to the pruning direction in mutually_connect_quant: I ran the previous head (0d5701c) with the same 5 seeds. Every cell above moves by 0.006 or less between the two, with no consistent sign (1 bit, 10%: 0.004 to 0.006 lower after the change; 1 bit, 50%: up to 0.004 higher; the rest within 0.004). On recall I can't tell the two apart, so the consistent form looks fine to me.

My prototype also fed the raw vector of the new point in as the source while the batch was being inserted. At 1 bit with inner product that gained about 0.006 on average (up to 0.016). With L2 it was clearly worse, so I would not suggest it.

Add ran at about 10.6k points/s at 1 bit and 6.6k/s at 4 bits, against 18k to 20k/s for construct (100k × 384, 8 threads, portable build).

I am happy to rerun any of this on a later revision.

@gouyt13
gouyt13 merged commit 232ce6c into VectorDB-NTU:main Oct 1, 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.

3 participants