Storing Embeddings: Types, Precision and Row Size
12 min read · updated August 4, 2026
A million 1536-dimension embeddings occupy 6.15 GB as vector, 3.08 GB as halfvec and 0.2 GB as bit — and adding the HNSW index roughly doubles whichever of those you chose. Every one of those numbers comes from the type’s byte layout, so this page derives them rather than asserting them, and you can redo the arithmetic for your own dimension in one line.
The four types and their byte layouts
pgvector 0.7.0 added three types alongside the original vector. Each is a varlena — a Postgres variable-length datum with a header — followed by the components.
| Type | Description |
|---|---|
| vector(d) | float32 components. 4d + 8 bytes: four per component plus an eight-byte header holding the length and the dimension. Available since the first release. |
| halfvec(d) | float16 components. 2d + 8 bytes. pgvector 0.7.0. Roughly three decimal digits of precision, exponent range unchanged from float32. |
| bit(d) | One bit per dimension, so d/8 + 8 bytes. A Postgres built-in type, indexable by pgvector 0.7.0's bit_hamming_ops. Used for binary quantisation. |
| sparsevec(d) | Only non-zero elements stored: roughly 8 bytes per non-zero (an int4 index and a float4 value) plus a header. pgvector 0.7.0. Worth it below about 20 per cent density, useless above. |
Put your dimension in and the per-value sizes fall out. At d = 1536:
vector(1536) = 4 × 1536 + 8 = 6152 bytes halfvec(1536) = 2 × 1536 + 8 = 3080 bytes bit(1536) = 1536 / 8 + 8 = 200 bytes At d = 768: vector(768) = 3080 bytes halfvec(768) = 1544 bytes bit(768) = 104 bytes Check any of these against your own table: SELECT pg_column_size(embedding) FROM chunks LIMIT 1;
That last line matters. pg_column_size reports the on-disk size of the datum including its header and any compression, so it is the arbiter if this page and your database disagree.
sparsevec is the one with a threshold worth working out rather than guessing at. It stores an index and a value per non-zero element, eight bytes each, against four bytes per element for the dense form — so it wins only when fewer than half the components are non-zero, and it wins meaningfully only well below that.
sparsevec beats vector when 8 × nnz + header < 4d + 8 i.e. roughly when nnz / d < 0.5 Worked at d = 30,000 (a lexical expansion vector such as a learned sparse retrieval model produces): dense: 4 × 30,000 + 8 = 120,008 bytes sparse at 200 non-zeros: 8 × 200 + 16 = 1,616 bytes a factor of 74. At d = 1536 with 700 non-zeros — a dense embedding that happens to have some zeros — sparse is 5,616 bytes against 6,152. A 9% saving for a type change: not worth it.
The practical reading: sparsevec exists for genuinely sparse representations, the kind produced by learned sparse retrieval models with vocabulary-sized outputs. A dense text embedding is not sparse and never becomes sparse, so this type is not a compression option for the embeddings most readers of this page have.
A million rows, totalled
A vector is never alone on a row, and the index is never free. Here is the whole thing for one million rows at 1536 dimensions, with every term named.
ASSUMPTIONS
d = 1536, N = 1,000,000
Heap row: 23-byte tuple header + 1 byte alignment padding
+ bigint id (8) + uuid tenant (16) + bigint version (8)
+ text content, average 400 bytes
+ 4-byte line pointer in the page
HNSW index: vector copy + 6 bytes × (2m + m/(m−1)) neighbour slots
+ ~32 bytes tuple overhead, at m = 16 → 33.1 slots
HEAP, float32
fixed columns + header + line pointer = 60 B
content (average) = 400 B
vector(1536) = 6152 B
------------------------------------------------------
per row = 6612 B
× 1,000,000 = 6.61 GB
Postgres pages are 8192 B and never more than ~96% full,
so add ~6% for page slack -> ≈ 7.0 GB
HNSW INDEX, float32
6152 + 199 + 32 = 6383 B × 1,000,000 -> ≈ 6.38 GB
TOTAL, float32 -> ≈ 13.4 GB
HEAP, halfvec
60 + 400 + 3080 = 3540 B × 1e6 + slack -> ≈ 3.75 GB
HNSW INDEX, halfvec
3080 + 199 + 32 = 3311 B × 1e6 -> ≈ 3.31 GB
TOTAL, halfvec -> ≈ 7.1 GB
HEAP, bit + full vector kept for rerank
60 + 400 + 6152 + 200 = 6812 B × 1e6 -> ≈ 7.2 GB
HNSW INDEX on bit(1536)
200 + 199 + 32 = 431 B × 1e6 -> ≈ 0.43 GB
TOTAL, binary index + float32 heap -> ≈ 7.6 GBThree conclusions come straight out of the arithmetic and none of them is obvious from a feature list.
- The index is about half your total. A pgvector HNSW index stores a full copy of every vector; it is not a lightweight structure over the heap. Any plan that budgets for the table and forgets the index is out by a factor of two.
- Half precision halves everything. Both the heap and the index, because both store the vector. It is the single largest lever available and it costs almost nothing in recall — see below.
- Binary quantisation shrinks the index, not the table. The index drops by 93 per cent, which is the number people quote, but you must keep the full vectors to rerank with, so the total falls by far less than the headline suggests. It is a query-speed and memory-residency optimisation, not a storage one.
Where the bytes actually live
A Postgres row must fit in an 8 kB page, so a value over roughly 2 kB is moved to a TOAST table. A vector(1536) at 6152 bytes is always toasted; a vector(384) at 1544 bytes usually is not. Check which storage strategy your installation uses rather than assuming:
SELECT typname, typstorage FROM pg_type WHERE typname IN ('vector','halfvec');
-- 'x' = extended (out of line, compressed)
-- 'e' = external (out of line, NOT compressed)
-- And where the bytes are:
SELECT pg_size_pretty(pg_relation_size('chunks')) AS main,
pg_size_pretty(pg_total_relation_size('chunks')) AS main_plus_toast_plus_indexes;Compression is not the point of that check — a float32 embedding is effectively incompressible, because the low mantissa bits are noise, so a compressing strategy just burns CPU to save nothing. The point is the extra fetch. A toasted value is a separate read from a separate relation, so SELECT id, content FROM chunks is cheap and SELECT * FROM chunks is not, and the difference grows with your row count. This is the concrete argument for keeping vectors in their own table, as the documents schema does.
Half precision: what it costs you
float16 has a 10-bit mantissa against float32’s 23, giving roughly three significant decimal digits instead of seven. The exponent range is smaller too — the largest finite float16 is 65,504 — but embedding components are essentially always in [−1, 1], so overflow is not a practical concern.
Why the precision loss barely matters for retrieval: you are computing a dot product over d terms and then ranking the results. Independent rounding errors of relative size 2−11 accumulate as roughly the square root of d times that, so at 1536 dimensions the relative error in a score is on the order of 10−4 to 10−5. Two documents whose true scores differ by less than that may swap places. Two documents whose scores differ by that little are, for retrieval purposes, the same document.
-- Migrating an existing column, offline: ALTER TABLE chunk_embeddings ALTER COLUMN embedding TYPE halfvec(1536) USING embedding::halfvec(1536); -- Or index a half-precision cast of a float32 column, which lets you -- keep full precision in the heap for reranking and halve only the index: CREATE INDEX chunk_embeddings_half_hnsw ON chunk_embeddings USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops); -- The query must then use the same expression, or the index is ignored: SELECT chunk_id FROM chunk_embeddings ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536) LIMIT 10;
That second form is the one to reach for. It halves index memory — the number that decides whether your index stays resident — while leaving the exact float32 vectors available for a rerank pass, and it requires no data migration at all.
halfvec, sparsevec and their operator classes require pgvector 0.7.0 or later. On an older extension the ALTER … TYPE halfvec above fails with type "halfvec" does not exist, which is the version telling you rather than a syntax problem.Binary quantisation and rerank
Binary quantisation keeps the sign of each component and throws away the magnitude: 32 bits become one. Distance becomes Hamming distance, which is a XOR and a popcount — instructions that run at several bytes per cycle rather than a multiply per component.
-- Store the binary form alongside the full vector. ALTER TABLE chunk_embeddings ADD COLUMN embedding_bit bit(1536) GENERATED ALWAYS AS (binary_quantize(embedding)) STORED; -- pgvector 0.7.0+ CREATE INDEX chunk_embeddings_bit_hnsw ON chunk_embeddings USING hnsw (embedding_bit bit_hamming_ops); -- Two-stage query: 200 candidates by Hamming, reranked exactly by cosine. SELECT chunk_id FROM ( SELECT chunk_id, embedding FROM chunk_embeddings ORDER BY embedding_bit <~> binary_quantize($1::vector(1536)) LIMIT 200 ) candidates ORDER BY embedding <=> $1 LIMIT 10;
<~> is the Hamming distance operator, added with bit_hamming_ops in pgvector 0.7.0. The inner query walks a 0.43 GB index instead of a 6.38 GB one; the outer query computes 200 exact distances, which is 300,000 multiply-adds and takes microseconds.
The honest limitation: binary quantisation loses more information at low dimensions than at high ones, because there is less redundancy to spare. It is generally reported to work well at 1024 dimensions and above and poorly below 512. That is a claim you should verify on your own corpus with the recall harness in choosing and tuning a pgvector index — it runs unchanged against this two-stage query, and the candidate count of 200 is the knob to raise if recall is short. The broader treatment of the technique is in quantising vectors.
Choosing, in order
- Start with
vectorand no cleverness. Below a few million rows the arithmetic above says you are talking about single-digit gigabytes, and engineering time costs more than RAM. - Reduce the dimension before reducing the precision. A model that supports truncation — see Matryoshka embeddings — going from 1536 to 768 halves every number on this page and is usually a smaller quality loss than it sounds, because the leading dimensions carry most of the signal by construction.
- Then half precision. Halves everything again, for a recall cost that is generally below measurement noise. Index the cast rather than migrating the column, so the change is reversible by dropping an index.
- Then binary plus rerank, when index residency is the binding constraint — that is, when your
EXPLAIN (ANALYZE, BUFFERS)showsshared readrather thanshared hitand adding memory is not an option. - Only then consider a different engine. Most migrations away from Postgres are made at a scale where two of the four steps above had not been tried. Do you even need a vector database works through where the real boundary sits.
Two of these compose and one does not. Truncation and half precision stack cleanly — a 768-dimension halfvec is a quarter of a 1536-dimension vector, and the recall costs are largely independent, so you can reason about them separately. Binary quantisation does not stack with aggressive truncation, because both are removing the same redundancy: quantising an already-truncated 768-dimension vector down to one bit per component leaves far less signal than the numbers suggest, and the rerank stage has to work harder to compensate. Measure that combination specifically rather than assuming the savings multiply.
And one number to sanity-check the whole exercise against before you start: what the storage actually costs. Six gigabytes of managed Postgres is not expensive at any provider, and a week of engineering time to halve it is. The arithmetic on this page is worth doing because it tells you whether the index stays in memory — which changes query latency by an order of magnitude — not because gigabytes are dear.