Skip to main content

hnsw-multivector-tuning

Measures what HNSW parameters cost on a Qdrant collection that carries more than one named vector, and models what it measured.

m and ef_construct are documented per collection. A collection with a 1536-dimensional image vector and a 1024-dimensional text vector builds two graphs from one setting, pays for both, and none of the three costs — memory, build time, recall — divides between them the way the dimensions suggest. This repository is the measurement of all three on both vectors of the same live collection, the script that produced it, and the sizing model fitted to the result.

Three things came out of it that I did not expect, and one of them is the reason the rest of this exists.

The graph does not know the dimension. At the same m over the same points, the 1536-dimensional index and the 1024-dimensional one come out the same size to within 0.2%. Raising m costs the same number of bytes on your narrow vector as on your wide one. There is therefore no reason to let both inherit a single collection-level m, and no reason to assume the big vector is the one to economise on.

One full_scan_threshold is two different cutoffs. It is configured as a size in kilobytes and compared against a count of points, so the server divides it by the width of the vector. The default of 10000 becomes 1666 points on the 1536-dimensional index and 2500 on the 1024-dimensional one. Between those numbers, the same query against the same segment walks the graph for one vector and brute forces the other.

You cannot retune one named vector. The optimiser rewrites segments, not vector indexes. Changing m on the image vector throws away and rebuilds the text vector's graph too, at whatever it was already set to.

Install

pip install mvhnsw

Python 3.10 or newer. The sizing model and the vector generator need only numpy. Talking to a server needs pip install "mvhnsw[qdrant]".

The smallest thing that runs

Size a collection before you build it, with no server involved:

from mvhnsw import CollectionPlan, HnswParams, VectorSpec, plan_memory

plan = CollectionPlan(
    [VectorSpec("image", 1536), VectorSpec("text", 1024)],
    HnswParams(m=64),
)
print(plan_memory(plan, 10_000_000).table())
print(plan.full_scan_points())
vector         dim    m     vectors       graph  graph %
image         1536   64   57.22 GiB    1.84 GiB     3.1%
text          1024   64   38.15 GiB    1.84 GiB     4.6%
total                     95.37 GiB    3.67 GiB     3.7%
{'image': 1666, 'text': 2500}

Same thing from a shell:

mvhnsw plan image:1536 text:1024 --m 64 --points 10000000 --budget-gib 3

And against a collection that already exists, to find out what it is really doing rather than what you configured:

mvhnsw scan /var/lib/qdrant --collection photos --verbose

What the numbers say

Everything below was measured on 100,000 points, one segment, Qdrant 1.19.0 on sixteen cores, the collection carrying both named vectors except where a section says otherwise. results/ holds the raw csv and bench/ holds the scripts that wrote it. Every recall figure was checked against the server's own execution counters to confirm the search actually went through the graph; see the note on brute force further down for why that matters.

Memory

m sweep at ef_construct=100, graph bytes read off the segment files:

m image graph text graph ratio build recall@10, ef=128 (image / text)
8 4.08 MiB 4.07 MiB 1.002 54.9 s 0.779 / 0.792
16 6.52 MiB 6.53 MiB 0.999 88.7 s 0.948 / 0.964
24 7.92 MiB 7.92 MiB 0.999 96.8 s 0.983 / 0.981
32 8.82 MiB 8.83 MiB 0.999 106.5 s 0.987 / 0.989
48 9.97 MiB 9.98 MiB 1.000 119.8 s 0.991 / 0.992
64 10.53 MiB 10.61 MiB 0.993 117.5 s 0.983 / 0.992

The ratio column is the point. A 50% wider vector produces the same graph, because the neighbour lists hold point offsets and a point offset has no opinion about how wide the vector it points at is.

The second thing in that table is the knee. Going from 48 to 64 buys nothing — recall is flat or slightly worse — and costs another half a megabyte per vector, which at ten million points is another 0.3 GiB across the two of them. The default of 16 is low for this data and 64 is past the point of usefulness; the useful band is narrow and it is worth finding rather than assuming.

The usual estimate for graph memory is m * 2 * 4 bytes per point: m0 links holding a four-byte offset each. It is too big, and the amount by which it is too big depends on m, which is the part that makes it dangerous. Measured against the table above, it overstates the graph by 1.6x at m=8 and by 4.7x at m=64, because it counts slots and the slots do not get filled — the next section is what fills them. graph_bytes_per_point(m, n) is fitted to results/graph_bytes.csv, six values of m across four segment sizes, and is 3% out at worst over that range; refit it for your own build with fit_graph_model(rows). At ten million points and m=64 it puts the two graphs at 3.7 GiB where the rule of thumb says 9.6 GiB, which is the difference between provisioning a box and provisioning a bigger one.

Two values of m are not enough to fit it, and that is not a detail. Fitted at m=16 and m=48 only, a straight line through the two is indistinguishable from the curve, and it says 280 bytes a point at m=64 where the segment says 110.

ef_construct is the cheap knob, and I expected it to be the free one. It mostly is. Sweeping it at m=16:

ef_construct build image graph recall@10, ef=64 recall@10, ef=128
50 45.4 s 5.88 MiB 0.802 0.918
100 88.7 s 6.52 MiB 0.861 0.948
200 152.8 s 6.66 MiB 0.871 0.965
400 246.2 s 6.66 MiB 0.887 0.970

Not flat, and not monotonic either. The graph grows 13% from 50 to 200 and then stops dead, because a wider construction search finds more valid neighbours and fills more of the m0 link slots, and at m=16 there are only thirty-two of them to fill. That ceiling is specific to this m; the next section is what happens when there are a hundred and twenty-eight. Either way, a low ef_construct does not just cost you recall, it leaves you holding an underfilled graph you are paying m for. Compare with 2.6x for m over 8 to 64: ef_construct is the cheap knob, worth raising before m when memory is the constraint, but the sizing model leaves the term out entirely and is a slight underestimate below 100.

Build time is close to linear in ef_construct and stays that way; recall is not, and past 200 it has stopped moving.

How much of the m you paid for you receive

The two paragraphs above disagree, and the disagreement is the useful part. The m sweep says the graph grows 2.6x while m grows 8x, so the link slots are not all being used. The ef_construct sweep at m=16 says they fill up and then stop. Both cannot be the whole story, so bench/fill_ratio.py holds m and moves ef_construct a long way at two larger values of m. The column that matters is bytes per layer-zero slot: if every slot were occupied it would be constant.

m m0 ef_construct bytes/point bytes/slot build recall@10 ef=128
32 64 100 92.6 1.42 101.7 s 0.987
32 64 400 102.7 1.58 317.8 s 0.996
64 128 100 110.7 0.86 113.8 s 0.991
64 128 400 136.6 1.06 367.6 s 0.997

At m0=64 a wider construction search adds 11%; at m0=128 it adds 23%. So the slots at m=16 really do fill, and at m=64 they never do: widening the search keeps finding neighbours the narrow search missed, and even at ef_construct=400 a slot at m0=128 costs a third less than one at m0=64, which it can only do by being empty more often. Doubling m from 32 to 64 therefore buys 20% more graph, not 100% more graph. That is the good news and the bad news in one number: the memory is cheaper than you feared, and the reason it is cheaper is that you are not getting what you asked for.

Read the last two columns together. m=64, ef_construct=400 costs 33% more memory and 16% more build time than m=32, ef_construct=400, for a recall difference of 0.001. Spend on ef_construct first.

Build time

The same 100,000 points built three ways on the same idle server: as a collection holding only the 1536-dimensional vector, only the 1024-dimensional one, and both. Sixteen cores, max_indexing_threads at its default of all of them, so the optimistic answer is that the two graphs go up side by side and the pair costs what the slower one costs.

m / ef_construct image only text only both sum of parts both / sum image / text
16 / 100 52.4 s 34.2 s 87.1 s 86.6 s 1.006 1.53
32 / 200 114.3 s 79.1 s 183.8 s 193.4 s 0.950 1.45

It is the sum. Adding a second named vector to a collection does not cost a fraction of a build, it costs a whole second build, and the parallelism you were counting on is already spent inside the one graph. Plan a reindex of a three-vector collection as three reindexes.

The asymmetry between the two is the dimension and only the dimension. 1536 / 1024 is 1.50; the measured ratios are 1.53 and 1.45. A build spends its time computing distances, and a distance over 1536 floats costs half again what one over 1024 does. build_work(m, ef_construct, dim, n) is that ratio and nothing more; use it to convert a build you have timed into one you have not, never as a time.

Which is the mirror image of the memory result, and worth holding both at once: the wide vector costs 50% more to build and exactly the same to keep.

Raw rows in results/build_time.csv, script in bench/build_time.py.

Recall

Recall is the one number here that does not transfer, and publishing a single curve for it would be misleading. It is governed by the intrinsic dimension of your embeddings, not by the width of the column they are stored in. Three collections, all 1536 dimensional, all 100,000 points, all built at m=16 and ef_construct=100, differing only in how many dimensions the data actually occupies:

latent dim two_nn estimate build recall@10 ef=32 ef=64 ef=128 ef=256
8 9.7 21.1 s 0.996 1.000 1.000 1.000
32 31.9 53.4 s 0.709 0.873 0.955 0.983
128 79.0 66.4 s 0.210 0.318 0.467 0.641

Nothing in the schema distinguishes those three rows. At ef=128 they span 0.467 to 1.000, where the entire m sweep from 8 to 64 spanned 0.779 to 0.991 on the same axis. The data matters more than the parameter, which is why a recall number published for somebody else's embeddings — including the ones in the table further up — tells you close to nothing about yours.

Build time moves with it too, three-fold across the same three rows at identical parameters, because a harder neighbourhood means more candidates examined per insertion. A build estimate carried over from an easy collection to a hard one is wrong in the expensive direction.

So the useful thing is not a number, it is the axis. Run two_nn_dimension(v) on a sample of each of your named vectors. If they come back different — and an image encoder and a text encoder generally do — then they want different m, and any shared collection-level hnsw_config is overpaying on one of them and underserving the other.

Note also what did not differ. At matched intrinsic dimension the 1536-dimensional and 1024-dimensional vectors in the sweep above reach the same recall at the same m, to within a percent. Nominal width drives build time and nothing else.

Raw rows in results/intrinsic_dim.csv, script in bench/intrinsic_dim.py.

Failure modes

Your benchmark is measuring brute force. Below the resolved full scan threshold Qdrant ignores the graph and scans the segment, which has perfect recall, so a small trial collection reports 1.0 for a configuration it never used and the number collapses in production. Worse, on a two-vector collection the cutoff is different per vector, so you get one of each. Measured:

points vector cutoff path taken recall@10
1200 image (1536d) 1666 brute force 1.000
1200 text (1024d) 2500 brute force 1.000
2000 image (1536d) 1666 hnsw 1.000
2000 text (1024d) 2500 brute force 1.000
3200 image (1536d) 1666 hnsw 1.000
3200 text (1024d) 2500 hnsw 1.000

The recall column is identical everywhere and tells you nothing. The path column is what you need, and it comes from the server's own counters, not from inference. execution_paths() reads them; anything that lands on unfiltered_exact was not served by the index you are trying to measure.

indexing_threshold=0 does not drop an index. It stops new segments from being indexed. Existing segments keep their graphs and keep serving searches. If you are waiting for indexed_vectors_count to fall to zero before timing a rebuild, you will wait forever. What drops the index is a configuration change, which makes the optimiser rewrite the segment; with the threshold at zero it rewrites it as plain. Two calls, and the order matters. rebuild() does it in the right order.

Asking for the parameters that are already built is not a change. The optimiser rewrites a segment when its built configuration stops matching its intended one. Apply m=16 to a segment already built at m=16 and nothing matches differently, so nothing is rewritten, so nothing is dropped, and code waiting for the old index to disappear waits against a server sitting at zero percent CPU. It only bites on a cell that repeats a value or on the second run of a benchmark over a collection the first run left behind, which is why it reads as intermittent. rebuild() gets around it by asking for an m nothing is built at first, waiting for the segment to come back plain, and only then applying the parameters being measured.

Retuning one vector rebuilds all of them. Covered above. Budget for it.

indexed_vectors_count is not points. It counts point-and-vector pairs, so a healthy two-vector collection of a million points reports two million. People see the doubling and go looking for duplicates.

A batch of 1000 stops working when you add the second vector. One point carrying 1536 and 1024 float32 values is 10 KiB of binary and about 56 KiB of JSON, and the server refuses bodies over 32 MiB. The error mentions a payload size and neither a batch size nor a vector name. batch_size_for(dims) works the limit backwards; measured sizes are in results/payload_size.csv. Over gRPC the same collection takes five times the batch, which is reason enough to use it for bulk loads.

A hnsw_config on one vector does not isolate that vector. It is a field-by-field merge over the collection level, not a replacement. HnswConfigDiff(m=32) on the image vector leaves its ef_construct and its full_scan_threshold coming from the collection, so a later change at the collection level silently moves them. resolve_hnsw(spec, collection) gives you the effective values, and mvhnsw scan gives you the values a segment was genuinely built with, which is the only version that cannot be wrong.

The peak is during the build, not after it. A sweep that fits comfortably at rest still gets killed rebuilding the wide vector, because the optimiser holds the old segment while it writes the new one. run_sweep takes a guard for this, and the journal means the cells that already finished survive the kill.

What did not work

Moving one graph to disk to save RAM. on_disk=true on the narrower vector's hnsw_config moves the links out of the heap and into a mapping, which looks like a saving right up until the collection is under query load and the page cache pulls it all back. You have not freed the memory, you have reclassified it, and you have added a cliff on cold pages. It is worth doing when the index genuinely does not fit and you accept the tail latency; it is not worth doing to make a dashboard look better.

Quantising to fix a memory problem that was the graph. Scalar quantisation cuts the stored vectors, which is usually right, but it does not touch the graph at all. If you got where you are by setting m=64 on a collection with two named vectors, the thing that grew is the part quantisation does not address, and the fix is m.

Reaching for m when recall was short. It is the parameter everybody names first and it was the wrong one twice. Going from m=32 to m=64 at ef_construct=400 cost a third more memory and a sixth more build time for a thousandth of recall, and it costs that on every named vector at once. What moved recall was ef_construct, and after that hnsw_ef at query time, which is free to change and does not need a rebuild to try.

Tuning m on the harder vector and letting the other inherit it. This is the default behaviour and it was the original setup. Since the graph cost is identical per vector, inheriting means paying the hard vector's price twice while the easy vector sits well past its own knee. Setting the two separately costs one extra line of configuration.

Timing rebuilds by watching the collection status. green is reported both before a rebuild starts and after it finishes, so the first poll after issuing a config change succeeds and the measurement comes back as a fraction of a second. The wait in rebuild() requires the built parameters read from the segment files to match what was asked for, and requires the condition twice in a row.

Reproducing it

docker run -d --name qlab -p 6333:6333 -p 6334:6334 \
    -v /tmp/qstore:/qdrant/storage qdrant/qdrant:v1.19.0

pip install -e ".[dev]"
export MVHNSW_STORAGE=/tmp/qstore

python bench/full_scan_threshold.py   # a few minutes
python bench/graph_scaling.py         # fits the memory model, about half an hour
python bench/sweep_m.py               # the main sweep, the better part of an hour
python bench/build_time.py            # additivity of two builds
python bench/intrinsic_dim.py         # recall against intrinsic dimension
python bench/fill_ratio.py            # how much of m0 a build actually fills
python bench/payload_size.py          # where a batch size stops fitting

Run them one at a time. Two of these against the same server at once is an easy mistake to make when the first one is slow, and it does not fail, it just returns build times inflated by however much the other one was doing.

MVHNSW_STORAGE has to point at the host side of the server's storage mount. The size of an index is not available over the API — telemetry reports ram_usage_bytes and disk_usage_bytes as zero, and not per named vector either — so scan_storage(path) reads the segment directories instead. Point it at the container side, or at a directory this user cannot read, and every measurement in here silently has nothing to measure; storage_problem(path) is checked before anything long starts and says which of those it was. MVHNSW_HOST and MVHNSW_PORT move the scripts off localhost:6333 if that port is already taken.

The sweep journals every cell as it finishes and skips completed work on a rerun, which matters because it does get interrupted. Failed cells are recorded as failures rather than dropped, so a table with a hole in it looks like one. The loads are skipped the same way: a script that finds its collection already present with the right vector names, widths and point count reuses it rather than spending another few minutes uploading the same vectors.

Turn a journal into a csv with:

mvhnsw report results/sweep_m.jsonl -o results/sweep_m.csv

About the vectors

The benchmark generates its vectors rather than shipping a dataset, because recall depends on the shape of your embeddings and no single dataset would let you see that dependency. iter_vectors(n, dim, intrinsic_dim=...) produces clustered, spectrally decaying, L2-normalised vectors on a manifold of the width you ask for, streamed in chunks so that ten million of them is a stream and not a 61 GiB array. Isotropic Gaussian noise, which is what most quick benchmarks use, is the hardest possible input for an ANN index and produces recall numbers nobody will ever reproduce.

To run any of this against your own embeddings instead, hand upload_vectors(client, name, sources) an iterator of numpy chunks — a memory mapped .npy sliced into blocks does fine — and the rest of the harness does not care where the vectors came from.

The memory and build-time results do not depend on the data at all. The recall results do, which is the reason intrinsic_dim is a knob and not a constant.

Reference

resolve_hnsw(spec, collection) effective parameters for one named vector
full_scan_points(kb, dim) the threshold in points rather than kilobytes
plan_memory(plan, points) per-vector vectors and graph, and the totals
graph_bytes_per_point(m, n) the fitted model, one number
suggest_m(plan, points, budget) largest uniform m whose graphs fit
fit_graph_model(rows) refit the model from your own measurements
scan_storage(path) what every index in a storage directory really is
storage_problem(path) why a storage path cannot be read, before you wait on it
two_nn_dimension(vectors) intrinsic dimension of a sample
iter_vectors(n, dim) streamed synthetic vectors
batch_size_for(dims) largest upsert that fits the request limit
upload_vectors(client, name, sources) streamed, resumable, backs off
run_sweep(cells, fn, journal) resumable sweep with a memory guard

IndexProbe wraps a live collection: rebuild(), ground_truth(), recall(), index_bytes(), execution_paths(), resolved().

Licence

Apache-2.0.

Release files for mvhnsw 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mvhnsw 0.2.1
File Size Uploaded
mvhnsw-0.2.1.tar.gz 69.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mvhnsw 0.2.1
File Interpreter ABI Platform
mvhnsw-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 114.6 kB

Release files / mvhnsw-0.2.1.tar.gz

Download URL mvhnsw-0.2.1.tar.gz
Size 69.6 kB
Tags Source
SHA-256 checksum
How to use checksums
e8e4a31215940af70f40f12db426f7e7e0516759e9b726343b77d3619b58e174
BLAKE2b-256 checksum
How to use checksums
d47e0e73e0ef8a536a0ccba43d543b7f19ee06b0daf7ca52b109844a4f1ef0f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / mvhnsw-0.2.1-py3-none-any.whl

Download URL mvhnsw-0.2.1-py3-none-any.whl
Size 45.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
febfaa975f6a369457abadaa08aed338594e36e4acc1f79d0051645bf1684fa7
BLAKE2b-256 checksum
How to use checksums
eefd86bcbca382231c864fa64de700892922123c5c3419b718c898b2b95e0759
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page