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

Choosing a pgvector column type by embedding shape Start from "What are your embeddings?" Dense float vectors branch to a precision decision: keep vector(n) float32 when magnitude is informative or the recall budget is tight, otherwise move to halfvec(n) float16 when embeddings are normalized and memory-bound; both support the L2, cosine, inner-product, and L1 operators and both HNSW and IVFFlat. Binary signatures map to bit(n) with Hamming and Jaccard operators. High-dimensional sparse or lexical vectors map to sparsevec with L2, cosine, and inner-product operators. What are your embeddings? Dense float vectorsBERT · CLIP · OpenAI · Cohere Binary signaturesLSH · quantized · pHash Sparse / lexicalSPLADE · TF-IDF · BM25 Magnitude informative,or recall budget tight? yes → keep exact no → memory-bound vector(n) float32 · 4 B/dim HNSW + IVFFlat <-> <=> <#> <+> halfvec(n) float16 · 2 B/dim HNSW + IVFFlat <-> <=> <#> <+> bit(n) 1 bit/dim · packed binary signature <~> <%> sparsevec (idx, val) pairs ≤ 1000 nonzero <-> <=> <#> Coloured text under each type lists its compatible distance operators; the operator class in CREATE INDEX must match both.

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:

SQL
-- 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:

SQL
-- 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.

SQL
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.

PYTHON
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.

PYTHON
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.

SQL
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.

SQL
-- 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:

SQL
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:

PYTHON
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 a vector_cosine_ops index, or a vector operator against a halfvec column) makes the planner ignore the index with no warning. Always confirm with EXPLAIN after any type or metric change.
  • Dimension mismatch is rejected, never coerced. Inserting a 1024-dim vector into a vector(1536) column raises expected 1536 dimensions, not 1024. There is no auto-padding or truncation; validate len(vec) in the pipeline.
  • NaN/Inf rejection mid-batch. A single non-finite value aborts the whole COPY transaction. Sanitize with np.isfinite(...).all() (or np.nan_to_num) before transmission, and treat a failure as a poison-message signal rather than retrying blindly.
  • halfvec overflow and underflow. Values outside roughly ±65,504 saturate 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 in vector.
  • Build-memory blowup on vector. HNSW construction holds the graph in maintenance_work_mem; a 10M-row vector build at m=16 needs 8–12 GB, and under-provisioning spills to disk, stretching the build from minutes to hours. Migrating to halfvec before the build cuts that pressure roughly in half.
  • WAL amplification during migration. ALTER COLUMN ... TYPE rewrites 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:

SQL
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:

SQL
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:

YAML
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.