Vector Data Type Selection in pgvector: vector, halfvec, bit and sparsevec
Choosing the wrong column type in pgvector does not raise an error — it silently caps your recall, doubles your index build memory, and forces the planner into sequential scans no amount of ef_search tuning can rescue. The physical type you write into CREATE TABLE is a first-class architectural constraint that binds index topology, distance-operator availability, maintenance_work_mem pressure during CREATE INDEX, and the byte-for-byte cost of every embedding you ingest. Before an engineering team calibrates HNSW graph parameters or IVFFlat probe counts, it must align the storage primitive — vector, halfvec, bit, or sparsevec — with the dimensionality, sparsity, precision, and distance metric of its embeddings. This page is the decision reference for making that choice deliberately, so AI/ML engineers, search platform developers, and DevOps teams lock the type down at schema-design time rather than discovering the mismatch during a painful, downtime-incurring index rebuild.
Up: pgvector Architecture & Vector Fundamentals
Architectural Divergence & Trade-offs
pgvector exposes four storage primitives, and each one maps to a different point on the recall–throughput–memory surface. The choice is not stylistic: a type determines which operator classes exist for it, how many bytes land on every 8KB heap page, and whether the type can be indexed by HNSW at all. Getting this right at the schema layer is the cheapest optimization available, because every downstream decision — the HNSW vs IVFFlat algorithm selection, the distance metric, the ingestion batch size — inherits from it.
| Type | Precision | Max dimensions | Bytes per element | Primary use case |
|---|---|---|---|---|
vector(n) |
32-bit float (IEEE 754 float32) |
16,000 | 4 | Standard dense embeddings (BERT, CLIP, OpenAI, Cohere) |
halfvec(n) |
16-bit float (IEEE 754 float16) |
16,000 | 2 | Memory-constrained dense retrieval, GPU-native pipelines |
bit(n) |
1-bit binary | 64,000 | 1/8 | Binary/quantized signatures, LSH outputs, perceptual hashing |
sparsevec |
32-bit float (coordinate list) | 1,000 non-zero | 4 per non-zero + index | TF-IDF, SPLADE, BM25-hybrid, high-dimensional lexical features |
vector(n) is the production default. It stores n full-precision float32 values plus an 8-byte header, giving the widest dynamic range and exact reproduction of most model output. A 1536-dimension OpenAI embedding therefore costs 4 * 1536 + 8 = 6152 bytes per row before alignment. It supports every distance operator and both index types, so when you are unsure, start here and specialize only under measured pressure.
halfvec(n) stores the same dimensions in IEEE 754-2008 half precision, halving base storage to 2 * 1536 + 8 = 3080 bytes. That reduction cascades: smaller heap tuples, smaller indexes, higher buffer-cache hit ratios, lighter WAL, and lower maintenance_work_mem demand during builds. Half precision carries roughly 3–4 significant decimal digits, which is ample for cosine-normalized transformer output but risky for embeddings whose raw magnitude encodes information. halfvec is fully HNSW- and IVFFlat-indexable, making it the standard first specialization for high-dimensional models — the concrete recall trade-offs are quantified in the pgvector Storage Overhead Analysis.
bit(n) packs binary signatures at one bit per dimension. It is the target for locality-sensitive hashing, binary-quantized embeddings, and perceptual image hashes, where Hamming (<~>) or Jaccard (<%>) distance over bitwise operations is dramatically cheaper than float arithmetic. A bit(1024) signature is 128 bytes — roughly 48× smaller than the equivalent vector(1024).
sparsevec stores only non-zero coordinates as (index, value) pairs, making it the right primitive for high-dimensional lexical representations (SPLADE, learned sparse retrieval, TF-IDF) where a nominal 30,000-dimension space has only a few hundred active terms per document. Storing that as a dense vector would waste enormous space and be un-indexable; sparsevec keeps it compact and searchable.
Operator binding is part of the type decision
Distance-operator availability is strictly bound to the type, and the operator class you name in CREATE INDEX — not the query — is what the planner matches against. A query whose ORDER BY uses an operator the index was not built for silently falls back to a sequential scan.
| Operator | Metric | Types |
|---|---|---|
<-> |
L2 / Euclidean | vector, halfvec, sparsevec |
<=> |
Cosine | vector, halfvec, sparsevec |
<#> |
Negative inner product (MIPS) | vector, halfvec, sparsevec |
<+> |
L1 / Manhattan | vector, halfvec |
<~> |
Hamming | bit |
<%> |
Jaccard | bit |
Aligning the operator with the normalization your model applies is a decision that lives alongside the type choice; the full treatment is in Cosine vs L2 Distance Metrics, which explains why unnormalized vectors misbehave under inner product and when L2 beats cosine on raw feature spaces.
Parameter Space & Diagnostic Workflow
The knobs that interact with type selection are the column definition itself, the operator class on the index, and the memory the build consumes. The table below contrasts naive defaults with production-shaped values.
| Knob | Naive default | Production recommendation | Why it matters |
|---|---|---|---|
| Column type | vector(n) everywhere |
halfvec(n) for ≥768-dim normalized embeddings; vector where magnitude matters |
Halves storage, WAL, and build memory at ~0–2% recall cost when normalized |
| Operator class | inferred/omitted | explicit halfvec_cosine_ops / vector_l2_ops to match query |
Wrong or missing class → sequential scan |
maintenance_work_mem |
64MB |
sized to hold the graph: 8–12 GB for 10M vector rows at m=16 |
Under-provisioning spills the build to disk, turning minutes into hours |
Dimension n |
max the model emits | match model output exactly; consider Matryoshka truncation | Padding/truncation is rejected, not silently coerced |
Diagnose the storage cost of an existing column directly from the catalog before you commit to a migration:
-- Per-row and total on-disk footprint of a vector column
SELECT
pg_size_pretty(pg_relation_size('items')) AS heap_size,
pg_size_pretty(pg_total_relation_size('items')) AS heap_plus_indexes,
(SELECT AVG(pg_column_size(embedding)) FROM items) AS avg_bytes_per_vector;To confirm which operator classes a candidate type actually supports before you design the index, query the catalog rather than trusting documentation from memory:
-- Which operator classes exist for halfvec?
SELECT opcname, amname
FROM pg_opclass oc
JOIN pg_am am ON am.oid = oc.opcmethod
WHERE opcname LIKE 'halfvec%'
ORDER BY amname, opcname;Step-by-Step Implementation
The following sequence takes a table from a naive full-precision definition to a memory-efficient halfvec layout with a matching index, in the order that avoids rebuilds.
1. Enable the extension and define the table to the model’s exact dimensionality. pgvector neither pads short vectors nor truncates long ones, so the dimension must match model output precisely.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content text NOT NULL,
embedding vector(1536) NOT NULL -- OpenAI text-embedding-3-small
);2. Enforce the type contract in the ingestion layer. Cast to the target dtype and validate shape before serialization; psycopg and asyncpg transmit raw buffers and will surface an invalid input syntax for type vector error at the database boundary if the shape or precision is off. Enforcing unit norm here also lets you adopt cosine or inner-product operators without per-query normalization — the full procedure is covered in normalizing embeddings before pgvector insertion.
import numpy as np
from pgvector.psycopg import register_vector
import psycopg
def to_pgvector(vec: np.ndarray, dim: int, half: bool = False) -> np.ndarray:
assert vec.shape == (dim,), f"expected ({dim},), got {vec.shape}"
assert np.isfinite(vec).all(), "NaN/Inf rejected by pgvector"
dtype = np.float16 if half else np.float32
return vec.astype(dtype, copy=False)
conn = psycopg.connect("dbname=app")
register_vector(conn)3. Bulk-load with COPY, not row-by-row INSERT. ORM-style single-row inserts saturate connection pools and multiply WAL records; binary COPY is the throughput path.
with conn.cursor() as cur, cur.copy(
"COPY items (content, embedding) FROM STDIN WITH (FORMAT BINARY)"
) as copy:
copy.set_types(["text", "vector"])
for content, vec in rows:
copy.write_row([content, to_pgvector(vec, 1536)])
conn.commit()4. If memory or storage is the constraint, migrate to halfvec. Convert in place with a cast, then build the index against the half-precision operator class.
ALTER TABLE items
ALTER COLUMN embedding TYPE halfvec(1536)
USING embedding::halfvec(1536);
SET maintenance_work_mem = '8GB';
CREATE INDEX CONCURRENTLY items_embedding_hnsw
ON items
USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);Note the operator class is halfvec_cosine_ops, not vector_cosine_ops; the class must match both the column type and the operator your queries use. For calibrating m and ef_construction against this type, see Optimizing m and ef_construction Parameters.
5. For binary or sparse workloads, choose the specialized primitive up front. Binary signatures use bit with a Hamming or Jaccard index; learned-sparse retrieval uses sparsevec.
-- Binary signature column with Hamming-distance HNSW
CREATE TABLE fingerprints (
id bigint PRIMARY KEY,
sig bit(1024) NOT NULL
);
CREATE INDEX ON fingerprints USING hnsw (sig bit_hamming_ops);
-- Learned-sparse column (30k nominal dims, few hundred non-zero)
CREATE TABLE lexical (
id bigint PRIMARY KEY,
terms sparsevec(30000) NOT NULL
);
CREATE INDEX ON lexical USING hnsw (terms sparsevec_l2_ops);Validation & Recall Testing
A type migration is only safe once you have proven the index is used and that quantization has not degraded results below tolerance. First confirm the planner chooses the index rather than a sequential scan:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM items
ORDER BY embedding <=> $1
LIMIT 10;A healthy plan shows an Index Scan using items_embedding_hnsw. If you instead see Seq Scan followed by a Sort, the operator in your ORDER BY does not match the operator class the index was built with — the single most common cause of “the index isn’t helping.”
Then measure recall against exact ground truth. Compute the exact top-k with a sequential scan (or a brute-force pass), then compare against the ANN result set for the same queries:
def recall_at_k(cur, query_vecs, k=10):
hits, total = 0, 0
for q in query_vecs:
cur.execute("SET LOCAL enable_indexscan = off")
cur.execute(
"SELECT id FROM items ORDER BY embedding <=> %s LIMIT %s", (q, k)
)
exact = {r[0] for r in cur.fetchall()}
cur.execute("SET LOCAL enable_indexscan = on")
cur.execute("SET LOCAL hnsw.ef_search = 100")
cur.execute(
"SELECT id FROM items ORDER BY embedding <=> %s LIMIT %s", (q, k)
)
approx = {r[0] for r in cur.fetchall()}
hits += len(exact & approx)
total += k
return hits / total
# Run before and after the halfvec migration on the SAME query set.Run this identical harness against the vector and halfvec versions of the table. A recall@10 drop of 0–2% is typically acceptable for semantic search; a larger gap means the model’s output magnitude carries information that half precision is discarding, and you should stay on full-precision vector.
Failure Modes & Gotchas
- Silent sequential-scan fallback. An operator/operator-class mismatch (
<->query against avector_cosine_opsindex, or avectoroperator against ahalfveccolumn) makes the planner ignore the index with no warning. Always confirm withEXPLAINafter any type or metric change. - Dimension mismatch is rejected, never coerced. Inserting a 1024-dim vector into a
vector(1536)column raisesexpected 1536 dimensions, not 1024. There is no auto-padding or truncation; validatelen(vec)in the pipeline. NaN/Infrejection mid-batch. A single non-finite value aborts the wholeCOPYtransaction. Sanitize withnp.isfinite(...).all()(ornp.nan_to_num) before transmission, and treat a failure as a poison-message signal rather than retrying blindly.halfvecoverflow and underflow. Values outside roughly±65,504saturate to infinity in half precision, and very small magnitudes flush to zero. Unnormalized embeddings with large activations can therefore corrupt silently on cast — normalize first or keep them invector.- Build-memory blowup on
vector. HNSW construction holds the graph inmaintenance_work_mem; a 10M-rowvectorbuild atm=16needs 8–12 GB, and under-provisioning spills to disk, stretching the build from minutes to hours. Migrating tohalfvecbefore the build cuts that pressure roughly in half. - WAL amplification during migration.
ALTER COLUMN ... TYPErewrites the entire table and generates WAL proportional to its size, which can stall replicas. Run type migrations off-peak and watch replication lag; the storage and WAL math is worked through in calculating pgvector storage requirements for 10M embeddings.
Monitoring & Alerting Hooks
Track whether the vector index is actually being scanned — a type or operator regression shows up first as idx_scan going flat while sequential scans climb:
SELECT
relname AS table,
indexrelname AS index,
idx_scan AS index_scans,
idx_tup_read AS tuples_read
FROM pg_stat_user_indexes
WHERE indexrelname LIKE '%embedding%'
ORDER BY idx_scan;Watch heap vs sequential scan balance on the vector table to catch a silent planner fallback:
SELECT relname, seq_scan, idx_scan,
CASE WHEN seq_scan + idx_scan > 0
THEN round(100.0 * idx_scan / (seq_scan + idx_scan), 1)
END AS pct_index
FROM pg_stat_user_tables
WHERE relname IN ('items', 'fingerprints', 'lexical');Expose the index-scan counter to Prometheus with a postgres_exporter custom query so a drop after a deploy pages the on-call engineer:
pgvector_index_usage:
query: >
SELECT indexrelname AS index, idx_scan
FROM pg_stat_user_indexes
WHERE indexrelname LIKE '%embedding%'
metrics:
- index: { usage: "LABEL" }
- idx_scan: { usage: "COUNTER", description: "Scans of the vector index" }Wire a recall@k regression check (the harness from the validation section) into CI on a fixed query set, so a model swap, a normalization change, or an accidental type migration that shifts the effective precision is caught before it ships. Access controls on these tables are addressed separately in security boundaries for vector data.
FAQ
When should I use halfvec instead of vector?
Use halfvec when your embeddings are unit-normalized (or otherwise bounded) and memory, storage, or index-build time is a constraint — typically at 768 dimensions and above. It halves per-row bytes, index size, and WAL at a recall@10 cost that is usually 0–2% for cosine-normalized transformer output. Stay on vector when raw magnitude carries meaning, when activations can exceed half-precision range, or when your recall budget is near zero (fraud, medical, legal retrieval).
Does changing the column type require rebuilding the index?
Yes. Both the type change (ALTER COLUMN ... TYPE) and the resulting index must be rebuilt, because the operator class is bound to the specific type — a vector_cosine_ops index cannot serve a halfvec column. Build the replacement with CREATE INDEX CONCURRENTLY against the new operator class, validate it with EXPLAIN, then drop the old index. Schedule it off-peak: the table rewrite and index build generate significant WAL and hold maintenance_work_mem.
Can I mix types or store multiple vector columns in one table?
Yes. A table can hold, for example, a halfvec dense column and a sparsevec lexical column, each with its own index, which is the standard layout for hybrid dense-plus-lexical search. Each column needs an index built with the operator class matching that column’s type and the operator your queries use against it.
What is the maximum dimensionality pgvector supports per type?
vector and halfvec support up to 16,000 dimensions, but HNSW and IVFFlat indexes are limited to 2,000 dimensions for vector and 4,000 for halfvec. bit supports up to 64,000 dimensions. sparsevec allows very large nominal dimensionality but caps indexed non-zero elements at 1,000. If your model exceeds the indexable limit, apply Matryoshka-style truncation or dimensionality reduction before insertion.
Why did my insert fail with “invalid input syntax for type vector”?
The serialized payload did not match the expected format or dimensionality — usually a wrong-shaped array, a NaN/Inf value, or a Python list of the wrong length. Validate vec.shape == (n,), confirm np.isfinite(vec).all(), and cast to float32 (or float16 for halfvec) before writing. Registering the type adapter with register_vector(conn) also ensures the client serializes the buffer correctly.
Related
- Cosine vs L2 Distance Metrics — which operator to pair with each type.
- pgvector Storage Overhead Analysis — exact byte, TOAST, and WAL math behind these types.
- HNSW vs IVFFlat algorithm selection — how the chosen type constrains index structure.
- Optimizing m and ef_construction Parameters — tuning HNSW builds for
vectorandhalfvec. - Normalizing embeddings before pgvector insertion — enforcing the dtype and unit-norm contract in the pipeline.
- Up: pgvector Architecture & Vector Fundamentals