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.
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.
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
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
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
| Change | Size per million, 1536d | What it costs |
|---|---|---|
Nothing: vector(1536) | 15.4 GB | — |
halfvec(1536) | 7.7 GB | A cast, and a small recall loss you can measure |
Shorter embeddings, halfvec(768) | 3.5 GB | Re-embedding, and recall that depends on the model |
| Binary + rerank | 704 MB plus the full vectors elsewhere | A two-stage query and real accuracy work |
| Fewer, larger chunks | Proportional | Retrieval 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.