halfvec vs vector: Type Selection for High-Dimensional Models

This page is a decision guide for choosing between pgvector’s vector (fp32) and halfvec (fp16) column types when your embeddings are 1536- or 3072-dimensional. It scopes the problem narrowly: how the two types differ in bytes-per-row and index build memory, how to move to an fp16 index without rewriting the source column via an expression index, and how to prove the recall cost is negligible before you commit.

Up: Quantization & halfvec Storage

At 3072 dimensions — the size of text-embedding-3-large at full width — every stored vector costs 4*d + 8 = 12296 bytes, and an HNSW graph over millions of those rows becomes memory-bound at build time and cache-hostile at query time. halfvec, added in pgvector 0.7.0, stores each coordinate as an IEEE fp16 value, halving the payload to 2*d + 8 = 6152 bytes with a recall impact that is measurable but usually in the third decimal place. The choice is not free-for-all: fp16 has ~3 significant decimal digits and a narrow dynamic range, so it is only safe on vectors you have already normalized in fp32. This guide sits under quantization and halfvec storage and refines the broader vector data type selection decision for the specific case of high-dimensional models where the storage delta is largest.

Prerequisites

  • PostgreSQL 15+ with pgvector 0.7.0 or newerhalfvec does not exist before 0.7. Confirm with SELECT extversion FROM pg_extension WHERE extname = 'vector';.
  • Embeddings already L2-normalized in fp32, following normalizing embeddings before pgvector insertion. Casting to fp16 before normalizing bakes rounding error into the unit vector.
  • A ground-truth query set and a recall@k harness so the type change is validated, not assumed.
  • A storage budget estimate for the table and its index — size both types with calculating pgvector storage requirements for 10M embeddings before choosing.
  • The distance metric fixed (cosine here); the opclass you index against must match the column type exactly.

Step-by-step procedure

1. Size the two types before deciding

The per-row payload is deterministic: vector(d) is 4*d + 8 bytes, halfvec(d) is 2*d + 8. For 3072 dimensions that is 12296 vs 6152 bytes per row — roughly a 2x reduction on both the heap column and the HNSW graph’s stored vectors, which also lowers maintenance_work_mem pressure during the build. Treat these as first-order estimates; TOAST thresholds and index overhead add to the real footprint.

SQL
-- per-row vector payload, both types, at your dimensionality
SELECT d,
       4 * d + 8 AS vector_bytes,
       2 * d + 8 AS halfvec_bytes,
       round((4*d + 8)::numeric / (2*d + 8), 3) AS ratio
FROM (VALUES (1536), (3072)) AS t(d);

2. Move to an fp16 index WITHOUT rewriting the source column

You do not have to change the column type to get an fp16 index. Keep the recall-critical fp32 embedding vector(3072) column as the source of truth and build an expression index that casts to halfvec on the fly. The index stores fp16, so it gets the memory and build-time savings, while the base column stays full precision for exact reranking or a future re-index.

SQL
-- source column stays fp32; the index is fp16 via an expression
CREATE INDEX doc_chunks_hv_idx ON doc_chunks
    USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);

-- the query must cast the SAME expression so the planner uses the index
SELECT doc_id, chunk_index
FROM doc_chunks
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 10;

The cast in the ORDER BY must textually match the indexed expression, and the probe literal must be halfvec too, or the planner falls back to a sequential scan. If you instead want the storage savings on the heap as well, alter the column type directly — but that forces a full table rewrite and gives up the fp32 source, so prefer the expression index unless heap size is the binding constraint.

3. Always normalize in fp32, then let the cast happen at the boundary

fp16 has a coarse mantissa; normalizing after the downcast pushes the norm off 1.0 and distorts cosine distance. Normalize in fp32, verify unit length, and only then allow the ::halfvec cast — whether that cast lives in the expression index (step 2) or at write time.

PYTHON
import numpy as np

def to_fp32_unit(embeddings) -> np.ndarray:
    arr = np.asarray(embeddings, dtype=np.float32)
    norms = np.linalg.norm(arr, axis=1, keepdims=True)
    return arr / np.maximum(norms, 1e-8)   # normalize in fp32; cast to fp16 later

4. Validate recall@k before committing

Measure recall@k of the fp16 index against an exact fp32 brute-force ground truth on the same query set. A well-normalized high-dimensional set typically loses a fraction of a percent; if you see more, the cause is almost always casting before normalizing, not fp16 itself.

PYTHON
def recall_at_k(approx_ids: list[list], truth_ids: list[list], k: int) -> float:
    hits = sum(
        len(set(a[:k]) & set(t[:k])) for a, t in zip(approx_ids, truth_ids)
    )
    return hits / (k * len(truth_ids))

Parameter reference

Type / choice Bytes per row Index opclass Recall vs fp32 When to use
vector(d) (fp32) 4*d + 8 vector_cosine_ops baseline Recall-critical search, small/medium row counts, when heap precision must stay exact.
halfvec(d) (fp16) 2*d + 8 halfvec_cosine_ops typically within a fraction of a percent High-dimensional (1536/3072) models where storage and build memory dominate and a recall A/B holds.
Expression index (embedding::halfvec(d)) fp16 in index, fp32 on heap halfvec_cosine_ops same fp16 index recall, exact heap for rerank Best default at high dimensions: fp16 index savings without rewriting the source column.
vector_ip_ops / halfvec_ip_ops inner product equal, if pre-normalized Only when every stored vector is unit-length; cheaper than cosine on the hot path.
d (dimensionality) drives both formulas must match column larger d widens the fp32/fp16 gap 3072 gains most from fp16; below ~768 the absolute savings are smaller.

Verification

Confirm the extension is new enough, the fp16 index is actually being used, and the storage delta materialized.

SQL
-- pgvector must be 0.7+ for halfvec
SELECT extversion FROM pg_extension WHERE extname = 'vector';
SQL
-- prove the planner picks the halfvec expression index, not a seq scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT doc_id FROM doc_chunks
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 10;
SQL
-- compare on-disk index sizes to confirm the fp16 saving landed
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS idx_size
FROM pg_stat_user_indexes
WHERE relname = 'doc_chunks'
ORDER BY pg_relation_size(indexrelid) DESC;

The EXPLAIN plan must show an Index Scan using doc_chunks_hv_idx; if it shows a Seq Scan, the query expression does not match the index (step 2).

Troubleshooting

  • type "halfvec" does not exist. The pgvector build predates 0.7. Confirm with SELECT extversion FROM pg_extension WHERE extname='vector';, then ALTER EXTENSION vector UPDATE; after installing 0.7+ binaries. Nothing about halfvec works on older versions.
  • operator class "halfvec_cosine_ops" does not accept data type vector. The opclass and the indexed expression disagree on type. A halfvec index needs a halfvec expression (embedding::halfvec(3072)) and a halfvec probe literal; mixing vector_cosine_ops with a halfvec cast (or vice versa) is rejected at CREATE INDEX time.
  • Recall drops far more than a fraction of a percent. The vectors were cast to fp16 before normalization, so quantization error is baked into the unit vector. Re-normalize in fp32 first per normalizing embeddings before pgvector insertion, then rebuild the index — the fp16 index recall recovers.
  • The query does a sequential scan despite the index existing. The ORDER BY cast does not textually match the indexed expression, or the probe parameter is vector not halfvec. Align both to embedding::halfvec(3072) <=> $1::halfvec(3072) and re-check with EXPLAIN.
  • Heap size did not shrink after adding the expression index. An expression index only makes the index fp16; the base column is still fp32 by design. If the heap is the binding constraint, size the alternatives with calculating pgvector storage requirements for 10M embeddings and only then consider an in-place column type change and rewrite.