add_items: insert in a random permutation by default - #674
Open
SilvioM97 wants to merge 1 commit into
Open
Conversation
HNSW is analysed as an average case over a random insertion order. hnswlib inserts in whatever order the caller supplies, so on a collection stored in a meaningful order the resulting graph may have worse quality. add_items now permutes the batch before inserting. Labels are unaffected: only the order of the addPoint calls changes. Pass shuffle=False to insert in array order.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HierarchicalNSWrandomises each element's level (getRandomLevel) but inserts elements in whatever order the caller supplies. HNSW's analysis is average-case over a random insertion order and the average case may not hold for a specific provided order, especially when the provided order is not random but given by some similarity factor (e.g. clustered documents from real world collections).On a tested 8.8M-passage text embedding collection it is worth ~2 accuracy points at fixed
ef, and the graph needs 1.4×–2.6× the distance computations for equal recall.This PR makes
add_itemsinsert in a random permutation by default, withshuffle=Falseto opt out.Why the order matters
At the moment an element is inserted, its neighbours are selected from the elements already in the graph. It can acquire a later element only if that element subsequently selects it, and
getNeighborsByHeuristic2is not symmetric, so often it does not. What the insertion order governs is therefore what each element is allowed to choose from, and the two orders differ in the shape of that candidate pool, not its size:The heuristic diversifies whatever pool it is handed; it cannot supply directions the pool does not contain. This is why the effect does not wash out as the graph grows.
Evidence
Two arms differing only in insertion order, same unmodified library at
d9b3608, samegetRandomLevel, sameaddPoint, sameParallelFor.M=32,ef_construction=200, 64 build threads,k=10, accuracy@10 against exact ground truth. Machine: Intel Xeon Silver 4314 (2×16 cores, AVX-512, 503 GB RAM),g++ -O3 -march=native -std=c++17.1. Synthetic, reproducible in ~2 minutes with no download
1M × 128 points drawn from 2,000 Gaussian clusters and written cluster by cluster, which manufactures the one property the effect needs — a file stored in a meaningful order. L2, 1,000 queries. Three independent builds per arm, mean ± sd:
Mean +6.03 points over a 13-point
efladder. The file-order arm never exceeds 97.6% accuracy@10 at any beam width tested, while the shuffled arm saturates at 100%. File order is also markedly less stable build-to-build (sd up to 1.4, against ≤0.8 shuffled).The generator is at the bottom of this description, numpy only, no download.
2. A real collection
MS MARCO v1 passage / Dragon embeddings, 8,841,823 × 768, inner product, 6,980 queries. One build per arm, three search repetitions each (search is deterministic, so the repetitions bound timing noise, not build noise; the build-noise estimate comes from SIFT1M below).
Mean over the full 13-point ladder: +1.98 accuracy points.
Work-normalised, which is the stronger statement, distance computations needed to reach the same accuracy, both arms measured on the same two indexes:
(file-order column interpolated across its own measured ladder.) Counting distance computations requires a small fix to
metric_distance_computations, which does not fire at ground level as shipped; that is a separate issue and I will open it separately.3. Control: the effect tracks corpus orderedness, as predicted
The effect requires the input file to be ordered. Measured directly on 4,000-row samples, we measure how similar (cosine for Dragon, L2 distance for SIFT1M) are consecutive vectors and random ones:
And the effect follows it: on SIFT1M, where the file is unordered, shuffling is worth only +0.11 accuracy points on average (three independent builds per arm, per-point sd 0.03–0.12), against +1.98 on Dragon.
What this PR changes
add_itemsgainsshuffle=True. It permutes the batch and inserts in that order.addPointcalls changes, not which label a vector is stored under. Covered by a test.init_index'srandom_seed, so builds stay reproducible to the extent they already were.add_itemsAPI description, one entry inALGO_PARAMS.md.tests/python/bindings_test_shuffle.py: label integrity under shuffling, and the recall effect on a small ordered corpus (0.960 → 0.990 at the tested configuration, runs in ~1 s).All existing Python test modules pass:
bindings_test,_labels,_getdata,_metadata,_pickle,_filter,_recall,_replace,_resize,_spaces.Trade-offs, stated up front
shuffle=Falseis a one-line change and users still get the switch.add_itemscan only permute within the batch it is given, so a user inserting in chunks of 1,000 gets little benefit. A single large call randomises thoroughly; this is a partial fix by construction.addPointsees one point at a time and the caller has already fixed the order. C++ users have to permute at their own call site.Reproducing the synthetic result
then build twice with
M=32,ef_construction=200, once in array order, once with the rows permuted, and compare accuracy@10 across anefladder.make_ordered_synthetic.py
Both real collections used above are public: Dragon (27 GB) and SIFT1M (0.5 GB).