Repository navigation
Add HNSW insertion after construction - #125
Conversation
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.
|
In 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.
|
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
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:
On the change to the pruning direction in 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 I am happy to rerun any of this on a later revision. |
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
Decisions
Linking without the original data.
constructborrows the caller's vectors throughrawDataPtr_and links by exact distances. That pointer dangles once it returns and is null afterload, soaddcannot 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.
addthrows once the index holdsmax_elements()points;resizeis the only way to grow. This matches hnswlib, whereaddPointthrows andresizeIndexis a separate public call.resizedoes not usereallocthe way hnswlib does - the level-0 block comes fromaligned_alloc, whichreallocdoes 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
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.Checks run
ctestpytest tests/pythonscripts/check-tidy.sh build-tidyscripts/check-format.shscripts/check-python.shmkdocs build --strictBuilt 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_construction200, 9 bits, 48 threads. Recall@10 measured only after the whole dataset is in the index. Two Xeon Gold 6418H, GCC 14.3, Release withRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF.The added points reach a cluster one of two ways:
cluster_ids, here the same k-means assignmentconstructused, read fromclusterids_64.ivecs.cluster_idsisnullptr, soaddpicks 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
constructconstruct, run 2construct, run 3addaddaddaddThe 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:
Every
addrow 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 dataaddcosts 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
add20%, suppliedadd20%, routedadd50%, suppliedadd50%, routedconstruct, for scaleaddruns 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
remove; this is insertion only.constructfix for thelevel_generator_race noted above. (To be tested and fixed in upcoming PRs)