Sizing a vector index in memory
The pod has 16 GB. Your vectors are 1.2 GB. The index OOMs during build, and nobody can explain where the other 14 GB went.
This is the most common capacity surprise with vector databases, and it happens because the raw vector payload is usually the smallest term in the total. Everything else — the graph structure, the build-time scratch space, the metadata, the second copy that exists during a rebuild — is invisible until you go looking for it.
The terms
A serviceable estimate looks like this:
steady-state RAM ≈ vector payload
+ index structure
+ metadata / payload fields
+ engine overhead and free-list slack
And separately, the number that actually kills you:
peak RAM ≈ steady-state + build scratch + (second copy, if rebuilding in place)
You provision for peak, not steady state. Taking the terms in order.
Vector payload
The easy one:
vector bytes = vectors × dimensions × bytes_per_component
bytes_per_component is 4 for float32, 2 for float16 or bfloat16, 1 for int8, and a fraction of a
byte under product quantization. This term is fully predictable and it is the one everyone
calculates.
Worked example — all inputs below are hypothetical placeholders. Substitute yours.
Take 5,000,000 vectors at 768 dimensions, float32:
5,000,000 × 768 × 4 bytes = 15.36 GB
Index structure
This is where the surprise lives, and it depends on the index family.
Graph indexes (the HNSW family, and most defaults) store, for every vector, a list of neighbour references — and multiple such lists, one per layer. The dominant term is the base layer:
graph bytes ≈ vectors × avg_degree × bytes_per_reference × layer_factor
avg_degree is governed by the engine’s connectivity parameter, bytes_per_reference is typically
4 or 8, and layer_factor accounts for the upper layers — those are geometrically smaller, so it’s
a modest multiplier above 1, not a large one.
The important property: this term is independent of dimensionality. It scales with vector count and with the connectivity setting. A collection of 100-dimension vectors and one of 1536-dimension vectors, at the same count and connectivity, carry near-identical graph overhead. That’s why low-dimensional collections often show the graph dominating the payload, which surprises people who budgeted from dimensionality alone.
Cluster-based indexes (the IVF family) are much lighter structurally — centroids plus an inverted list assignment per vector — but they buy that saving with a recall/latency profile you tune differently, and quantized variants trade payload size for a codebook and a reconstruction step.
Continuing the hypothetical example, at 5,000,000 vectors with an average degree of 32 and 4-byte references, allowing a modest multiplier for upper layers:
5,000,000 × 32 × 4 × 1.15 ≈ 0.74 GB
Small next to the payload here, because 768 dimensions is a lot. Rerun it at 128 dimensions and the payload drops to 2.56 GB while the graph term doesn’t move — now it’s a quarter of the total.
Metadata and payload fields
Anything you store alongside the vector: IDs, source URLs, tenant keys, timestamps, chunk text if you kept it in the same store. Highly variable, frequently underestimated, and it does not compress the way you hope when it’s held for fast filtered access.
If you filter on metadata, some engines maintain secondary structures for those fields. Those are additional, and they scale with cardinality.
The honest approach is measurement: write 10,000 representative records into a scratch collection, measure the delta, multiply.
Engine overhead and slack
Allocator fragmentation, free lists, per-segment headers, in-flight request buffers, and the engine’s own working set. This is not a rounding error — treat it as a real fraction of the total rather than assuming zero.
Build scratch and the second copy
The two terms that turn a comfortable estimate into an OOM.
Build scratch. Constructing a graph index is not a streaming operation. The builder holds candidate sets, priority queues, and often a partially-built structure alongside the data. Some engines build segment-by-segment and keep the peak bounded; others build the whole thing in one pass and peak far above steady state.
The second copy. If you rebuild in place, both the old index (still serving) and the new one (being built) exist simultaneously. That is close to a doubling of the index terms, on top of build scratch. This is the single most common cause of “it worked yesterday and OOMed today” — nothing about the data changed, someone just triggered a rebuild.
Whether you pay this depends on your rebuild strategy, which is a decision to make deliberately rather than discover during an incident — see reindexing without downtime.
Putting it together
For the hypothetical 5M × 768 float32 collection:
| Term | Estimate |
|---|---|
| Vector payload | 15.36 GB |
| Graph structure | 0.74 GB |
| Metadata | measure it |
| Overhead / slack | a real fraction, not zero |
| Steady state | ~16 GB + metadata + slack |
| Rebuild peak | roughly double the index terms, plus scratch |
The lesson from the table isn’t the numbers, which are made up. It’s the shape: at high dimensionality the payload dominates and the graph is a rounding error; at low dimensionality they converge; and the rebuild peak is the number you actually provision against.
Levers, and what each one costs
When the estimate doesn’t fit the budget, in rough order of value-for-pain:
Reduce bytes_per_component. Moving float32 → int8 or float16 cuts the largest term by 2–4×.
It costs some recall, and how much is entirely dependent on your embedding distribution — measure
it against a known query set rather than accepting a general claim.
Reduce dimensionality. Some embedding models are explicitly trained so that truncated prefixes remain useful, which makes this cheap where available. Otherwise it means a different model and a full rebuild.
Lower the connectivity parameter. Shrinks the graph term directly and reduces build time. Costs recall, more so on hard queries. Only meaningful when the graph term is actually significant — at 768 dimensions it isn’t.
Move payload off the hot path. Keep IDs in the vector store and the chunk text in whatever you’re already running. Adds a lookup to every query; often worth it.
Shard. Doesn’t reduce total memory, but it splits it across machines and caps the blast radius of a rebuild. This is usually the answer past a certain size regardless.
What to do before provisioning
- Write 10,000 representative records — real vectors, real metadata — into a scratch collection.
- Record resident memory before and after. Divide by 10,000 to get bytes-per-record as your engine actually stores it, including metadata and overhead. This one measurement replaces three of the estimates above.
- Multiply by your projected count at your planning horizon, not today’s count.
- Add your rebuild strategy’s peak multiplier.
- Provision for that, then confirm by building a full-scale index once in a scratch environment before it matters.
Rollback: if the full-scale build fails, the scratch environment absorbs it and nothing is serving from it. That is the entire point of doing it there. Delete the collection, adjust one lever, repeat.
Sizing that’s been validated by an actual full-scale build is worth more than any formula on this page. Then watch it — what to monitor covers the signals that tell you the estimate is drifting before it becomes an incident.