A million embeddings in Postgres costs 15 GB, not 6 GB

Measured on pgvector 0.8.6: a million 1536-dimension vectors is 785 MB per 50,000 rows once the index and TOAST are counted. The estimate misses two thirds.

The estimate everyone starts with is dimensions times four bytes. For OpenAI's 1536-dimension embeddings that is 6,144 bytes a row, so a million chunks is about 5.7 GB, which fits comfortably on the database you already have.

That number is wrong by a factor of nearly three, and the two things it misses are the two things that decide whether the feature is fast: the index, which is about the same size as the data, and TOAST, which is where Postgres puts the vector once it stops fitting in a page.

I measured all of it rather than estimating, because I could not find the index numbers published anywhere.

What the published formulas do and do not tell you

pgvector documents the row widths precisely. From its README: "Each vector takes 4 * dimensions + 8 bytes of storage", "Each half vector takes 2 * dimensions + 8 bytes", and "Each bit vector takes dimensions / 8 + 8 bytes."

Those are exact and they are the easy part. What is missing is the HNSW index, and an HNSW index is not a small pointer structure — it stores the vectors themselves, in a graph, with connections. That means the index is roughly as large as the table, and unlike the table it really wants to be in memory. The README is oblique about this: "Indexes build significantly faster when the graph fits into maintenance_work_mem", with the warning not to set that so high that "it exhausts the memory on the server."

So I measured it: 50,000 rows per case, three dimension counts, three element types, on pgvector/pgvector:pg17 in Docker with maintenance_work_mem at 4 GB and parallel maintenance off.

A measured table of nine cases at 50,000 rows each. vector 384d is 78 MB heap, no TOAST, 97 MB index, 176 MB total. halfvec 384d is 39 MB, 55 MB index, 95 MB. bit 384d is 4552 kB, 16 MB index, 22 MB. vector 768d is 2944 kB heap, 198 MB TOAST, 193 MB index, 395 MB. halfvec 768d is 78 MB heap, 97 MB index, 176 MB. vector 1536d is 2944 kB heap, 395 MB TOAST, 386 MB index, 785 MB total.
The row-width formulas are published. The index column is not, and it is about a third larger than the data it indexes.

Two patterns fall out of that table, and both are useful.

The index is 1.32× the vector bytes, every time. 97 MB for 1,544-byte vectors, 193 MB for 3,080-byte ones, 386 MB for 6,152-byte ones. That ratio holds across types and dimensions because the graph stores the vector plus a fixed number of connections, and at these widths the vector dominates.

halfvec(1536) and vector(768) are the same size. Both are 3,080 bytes a row, both give a 193 MB index, both total 395 MB. The index follows bytes per vector, not dimension count, which is the whole argument for half precision: you keep every dimension and pay for half of them.

The cliff at two kilobytes

The heap column is where the surprise lives. vector(384) puts 78 MB in the heap and nothing in TOAST. vector(768) puts 2.9 MB in the heap and 198 MB in TOAST. The data did not get smaller; it moved.

A table of column types against bytes per row and where they are stored. vector 384d and halfvec 768d are both 1,544 bytes and live in the heap at 3.5 GB per million. vector 512d and halfvec 1024d are 2,056 bytes and go to TOAST at 5.2 GB. vector 768d and halfvec 1536d are 3,080 bytes, TOAST, 7.7 GB. vector 1536d is 6,152 bytes, TOAST, 15.4 GB.
The same bytes per row means the same storage, whatever combination of dimensions and precision produced it.

Postgres stores rows in 8 kB pages, and a value that would make the row too wide gets pushed into a side table. The TOAST documentation sets the trigger precisely: it fires "only when a row value to be stored in a table is wider than TOAST_TUPLE_THRESHOLD bytes (normally 2 kB)", and then moves fields out of line "until the row value is shorter than TOAST_TUPLE_TARGET bytes". For vectors this matters more than usual, because pgvector marks the column EXTERNAL, which the docs define as storage that "allows out-of-line storage but not compression". The value is kept whole, never compressed, and fetched separately. The measurement confirms it — every vector column came back with storage mode e.

The practical consequence is a second read for every vector you touch, which shows up as buffer reads rather than as anything obviously wrong. And the crossing point is lower than people expect: 384 dimensions is inside the heap, 512 is not. Most of the models teams actually use — 768, 1536, 3072 — are all well past it.

halfvec moves the cliff out by one doubling. At 768 dimensions, half precision keeps the vector in the heap page where full precision does not. That is a change in the read path, not just in the bytes, and it costs a cast.

The number per million

Bars of total storage per million rows. vector 1536d is 15.4 GB, halfvec 1536d is 7.7 GB, vector 768d is 7.7 GB, halfvec 768d is 3.5 GB, vector 384d is 3.5 GB, and bit 1536d is 704 MB.
The usual estimate for a million 1536-dimension vectors is 5.7 GB. The measured total is about 15.4 GB.

Fifteen gigabytes per million is the number to plan with, and it changes decisions. Ten million chunks — a mid-sized documentation corpus — is 154 GB, of which 77 GB is index that wants to be resident. That is not a table you add to the production database on a Thursday; it is a capacity decision, and quite possibly a separate instance.

calculator · vector storage

What a million embeddings actually costs in Postgres

THE BACK-OF-ENVELOPE FIGURE—
WHAT IT ACTUALLY TAKES—
HNSW INDEX—
PUSHED OUT TO TOAST—
RAM TO HOLD THE INDEX—

total on disk, by element type, same rows and dimensions

where the space goes

Every constant here was measured rather than estimated: 50,000 rows per case on pgvector 0.8.6 and Postgres 17, with maintenance_work_mem at 4 GB, on 1 Oct 2026. The index and TOAST multipliers are fitted to those nine cases and land within about 2% of each. Your numbers will differ with m, ef_construction, fill factor and how compressible your vectors are — real embeddings are not random floats. The RAM figure is what the index wants resident to answer quickly; Postgres will happily run with it on disk and much slower.

Worth trying:

Switch the element type to halfvec. Everything halves, including the RAM the index wants. This is the single cheapest change available and the one most teams have not made.

Set dimensions to 768. If your model supports shortened embeddings — several do, through Matryoshka representation learning — halving the dimensions halves the storage again on top of that. A halfvec(768) store is 3.5 GB per million against 15.4 GB for vector(1536): the same corpus at just over a fifth of the size.

Set the type to bit. 704 MB per million. Binary quantization is not a free lunch and the recall cost depends entirely on your data, but two orders of magnitude is enough to change which architecture you can afford. The usual pattern is to search the binary index for a wide candidate set and rerank with the full vectors. A second lever in the same direction: OpenAI's embeddings guide documents a dimensions parameter that returns shorter vectors from the same model, so fewer dimensions need not mean a different model.

Four ways to make this smaller, in order of what they cost you

ChangeSize per million, 1536dWhat it costs
Nothing: vector(1536)15.4 GB—
halfvec(1536)7.7 GBA cast, and a small recall loss you can measure
Shorter embeddings, halfvec(768)3.5 GBRe-embedding, and recall that depends on the model
Binary + rerank704 MB plus the full vectors elsewhereA two-stage query and real accuracy work
Fewer, larger chunksProportionalRetrieval precision, usually the wrong trade

The last row deserves a warning because it is the one people reach for first. Halving the number of chunks halves the storage, and it also halves the granularity of what you can retrieve, which is normally the thing your users are complaining about. Reduce the bytes per vector before you reduce the number of vectors.

What the index size actually buys

It is worth being clear about why the index is so large, because "use IVFFlat instead, it is smaller" is a common and often wrong response.

HNSW stores a navigable graph over the vectors. Its defaults, per the README, are "m — the max number of connections per layer (16 by default)" and "ef_construction — the size of the dynamic candidate list for constructing the graph (64 by default)". Raising m improves recall and makes the index bigger; lowering it does the reverse. Nothing about this is free, and the measured 1.32× is what the defaults cost.

One behaviour worth knowing before you ship: an HNSW scan can return fewer rows than you asked for. Asking for 1,000 nearest neighbours with hnsw.ef_search at 1,000 returned 621 rows in my test. That is normal for an approximate index — the graph traversal simply does not reach every point — and it means a query that requests a large LIMIT and assumes it gets one is a bug waiting for a corpus large enough to expose it.

quick check

You store vector(1536) and your search feels slow even though the table is only 15 GB and the server has 32 GB of RAM. What is the most likely cause?

The index is roughly the size of the data, and the vectors themselves sit in TOAST and are fetched out of line. Between them they are asking for most of the machine's memory, so the rest of your workload evicts them and the graph traversal starts hitting disk. Halving the bytes per vector — halfvec, or fewer dimensions — usually fixes this faster than adding RAM.

What these numbers do not cover

Random floats are the worst case for some things and the best case for others. My vectors were random(), which is incompressible — but since pgvector stores vector columns EXTERNAL and never compresses them, that does not change the storage figures. It could change the index slightly, because real embeddings cluster and a clustered graph has different connectivity. Treat the 1.32× as a planning figure, and measure your own corpus before committing to a machine size.

Fifty thousand rows is not ten million. The per-row costs here are linear in the data and should extrapolate cleanly, but HNSW build time is not linear, and at tens of millions of rows the build becomes the operational problem rather than the storage. Build time in my measurements went from 3 seconds to 11 for the same row count purely on vector width.

Updates and deletes are not modelled. A vector column is large, and updating it means a new row version and a new index entry. A corpus that is re-embedded regularly will carry bloat that none of these numbers include, and that is a vacuum question rather than a sizing one. The same shape of problem as any wide, frequently updated column.

This is one index type on one engine. IVFFlat has different size and recall characteristics; dedicated vector databases make different trade-offs again. The reason to measure pgvector specifically is that "just put it in Postgres" is the default decision for most teams, and it is usually right — but it should be made with the real number rather than the estimate.

Storage is rarely the binding cost. Fifteen gigabytes of disk is cheap. The reason this arithmetic matters is memory: an HNSW index that does not stay resident answers slowly, and that turns into the same conversation as sizing a connection pool — a resource that looks adequate on paper and is contended in practice. The token cost of what you retrieve is usually the larger bill anyway.

questions people ask

How much storage does a million embeddings need in Postgres?

About 15.4 GB for 1536-dimension vectors with an HNSW index, measured on pgvector 0.8.6 and Postgres 17. That is roughly 7.7 GB of vector data in TOAST, 7.7 GB of index, and a nearly empty heap. The common estimate of dimensions times four bytes gives 5.7 GB and misses both the index and the TOAST overhead.

How big is an HNSW index in pgvector?

About 1.32 times the raw vector bytes at default settings, consistently across the cases measured: 97 MB for 50,000 rows of 1,544-byte vectors, 193 MB for 3,080-byte, 386 MB for 6,152-byte. It stores the vectors themselves plus graph connections, so raising m makes it larger and improves recall.

Should I use halfvec instead of vector?

Usually yes, as a first move. It halves storage and index size for a small recall loss you can measure on your own data, and at 768 dimensions it keeps the row inside the heap page instead of pushing it out to TOAST. halfvec(1536) costs exactly what vector(768) costs.

Why is my pgvector table size almost zero?

Because the vectors are in TOAST, not the heap. Anything wider than about 2 kB — which is any vector over roughly 500 dimensions at full precision — is stored out of line, and pg_relation_size only counts the main heap. Use pg_total_relation_size, or check the TOAST table separately.

Does binary quantization actually work?

It is 22 times smaller in these measurements, and whether it works depends entirely on your data. The safe pattern is two-stage: search the binary index for a wide candidate set, then rerank those candidates with the full-precision vectors. Test recall on your own queries before trusting it alone.

Does the HNSW index need to fit in RAM?

Not strictly, but it needs to be resident to be fast, because graph traversal is a series of random accesses. Budget for the index size plus your normal working set. Build time also depends on maintenance_work_mem — pgvector's docs note that builds are significantly faster when the graph fits.

The short version

A million 1536-dimension embeddings in Postgres is about 15 GB, not the 6 GB the obvious arithmetic gives. Half of it is an HNSW index that is consistently about 1.32× the vector data, and most of the rest is in TOAST because the rows are too wide for a heap page.

Two consequences follow. The first is memory: the index wants to be resident, so the real question is not disk but RAM, and it is roughly half the total. The second is that the cheapest optimisation available is halfvec — one cast, half the storage, half the index, and at 768 dimensions it keeps the vector in the heap page where full precision would not.

Measure your own corpus before buying the machine. And if you do measure, run the build and the size query in one session: mine disagreed with itself threefold until I did.

S

Sanjeev Sharma

Product Engineer at Acefone, building real-time communications at carrier scale: WhatsApp, voice and IVR in one agent inbox. Built and runs PostEngage, a WhatsApp automation SaaS, on his own. Contributor to litellm and the Vercel AI SDK. Takes on a small number of consulting engagements each year.