Normalizing Embeddings Before pgvector Insertion

This page shows how to normalize embedding vectors to unit length at ingestion time so a pgvector column is ready for cosine-similarity search without any query-time magnitude math. It scopes the problem narrowly: given raw float32 model outputs headed for a vector or halfvec column, how to L2-normalize them in the right precision order, guard against zero-magnitude vectors, verify every row landed on the unit hypersphere, and pick the inner-product operator that only works once the data is normalized.

Up: Type Casting & Vector Normalization

Normalization is the deterministic transform that lets pgvector’s cosine distance operator <=> collapse into a bare dot product. pgvector does not auto-normalize on INSERT, so if you skip this step the operator re-derives each vector’s magnitude on every comparison — an O(d) cost paid on the read hot path forever — and high-magnitude vectors dominate ranking regardless of semantic alignment. Doing the work once at write time, before the vector ever reaches the index, is why the parent type casting and vector normalization stage treats it as a hard gate rather than a database-side computed column.

Prerequisites

  • PostgreSQL 15+ with the pgvector extension 0.5+ installed (CREATE EXTENSION vector;). Use 0.7+ if you intend to store halfvec.
  • numpy >= 1.24 for the batched L2 norm, and psycopg 3.1+ or asyncpg 0.28+ to bind the vector to the column.
  • Embeddings available as float32 (or float64) arrays — the full-precision output of the model, not a pre-quantized fp16 cast.
  • The target metric decided in advance. This procedure assumes cosine similarity; whether normalization is even required depends on the operator you index against, laid out in cosine vs L2 distance metrics.
  • A column already typed: vector(d) for recall-critical search, or halfvec(d) once a recall A/B holds — the trade-off is covered in vector data type selection.
The normalization write path, with a zero-magnitude reject branch A left-to-right pipeline of five stages. The first three stages — raw float32 model output, L2 normalize in float32, and assert the L2 norm is approximately 1 with tolerance 1e-4 — sit inside a dashed zone labelled "float32 arithmetic: normalize before any downcast". A serialization boundary follows, after which the vector is cast to halfvec (fp16) and inserted into pgvector; the post-cast norm is re-checked against the widened range 0.999 to 1.001. From the L2 normalize stage, a branch guarded by "norm less than epsilon" drops zero-magnitude vectors into a reject box that logs the doc_id instead of inserting. Normalization write path — paid once, before the index float32 arithmetic: normalize before any downcast INPUT Raw float32 model output STEP 1 L2 normalize in float32 GUARD Assert ‖v‖₂ ≈ 1 (atol 1e-4) STEP 2 · BOUNDARY Cast → halfvec (fp16) STEP 3 INSERT into pgvector SERIALIZATION BOUNDARY re-check ‖v‖₂ ∈ [0.999, 1.001] after cast ‖v‖ < ε Reject: zero-magnitude vector log doc_id · drop before insert

Why unit-length vectors matter

Cosine similarity between vectors A\mathbf{A} and B\mathbf{B} is:

similarity=ABA2B2 \text{similarity} = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\|_2 \cdot \|\mathbf{B}\|_2}

When both vectors are unit-normalized (A2=B2=1\|\mathbf{A}\|_2 = \|\mathbf{B}\|_2 = 1), the denominator collapses to 11 and the operation reduces to a pure dot product AB\mathbf{A} \cdot \mathbf{B}. That equivalence is what lets you swap pgvector’s <=> cosine operator for <#> (negative inner product), which skips the two per-candidate magnitude divisions in the traversal hot path and typically shaves 20–40% off p95 latency depending on dimensionality and CPU. It also stabilizes index construction: HNSW graph building relies on a well-behaved distance distribution to place entry points and layer transitions, and unnormalized magnitude variance skews neighborhood radii, which quietly costs recall and pushes engineers to inflate ef_search to compensate. Calibrating those graph knobs is a separate task, covered in optimizing m and ef_construction parameters.

Step-by-step procedure

1. Normalize the batch at full precision

Do the L2 division in float32 (or higher), never after a downcast. A vectorized NumPy routine hits BLAS-level performance and lets you clamp the denominator in one place. The epsilon floor is non-negotiable: a zero-magnitude vector from a failed inference call or an empty chunk otherwise produces NaN, which pgvector rejects during index builds or lets slip through as a corrupt row.

PYTHON
import numpy as np
from typing import Union

def normalize_embeddings_batch(
    embeddings: Union[list[list[float]], np.ndarray],
    epsilon: float = 1e-8,
) -> np.ndarray:
    arr = np.asarray(embeddings, dtype=np.float32)
    norms = np.linalg.norm(arr, axis=1, keepdims=True)
    # Clamp near-zero magnitudes to prevent NaN propagation
    norms = np.maximum(norms, epsilon)
    return arr / norms

2. Cast to the storage type only at the serialization boundary

Normalize first, cast last. Quantizing to fp16 before the division bakes rounding error into the unit vector and is a leading cause of unexplained recall drift. Enforce this exact order:

  1. Generate embeddings in float32 / float64.
  2. Normalize to unit length using float32 arithmetic (step 1).
  3. Cast to float16 (halfvec) only here, as you serialize for the wire.
  4. Re-check the magnitude after the cast (step 4 below).
PYTHON
def to_halfvec_literal(vec: np.ndarray) -> str:
    # cast happens at the boundary, after normalization
    half = vec.astype(np.float16)
    return "[" + ",".join(f"{x:.6f}" for x in half) + "]"

3. Insert with the vector bound to the column

Bind the normalized array as a vector/halfvec literal. With psycopg 3 you can register the pgvector adapter and pass the NumPy row directly, or format the bracketed literal yourself as above. Use INSERT ... ON CONFLICT so a retried batch overwrites the same logical row instead of appending a duplicate embedding — the idempotency contract shared with batch chunking strategies for embeddings.

SQL
CREATE TABLE IF NOT EXISTS doc_chunks (
    doc_id      text     NOT NULL,
    chunk_index int      NOT NULL,
    model       text     NOT NULL,
    embedding   vector(1536) NOT NULL,
    normalized_at timestamptz DEFAULT now(),
    PRIMARY KEY (doc_id, chunk_index)
);

INSERT INTO doc_chunks (doc_id, chunk_index, model, embedding)
VALUES ($1, $2, $3, $4)
ON CONFLICT (doc_id, chunk_index)
DO UPDATE SET embedding = EXCLUDED.embedding,
              model = EXCLUDED.model,
              normalized_at = now();

4. Query with the inner-product operator

Once every stored row is unit-length, build the index against the inner-product opclass and query with <#>. pgvector returns negative inner product, so order ascending and negate to recover similarity.

SQL
CREATE INDEX ON doc_chunks
    USING hnsw (embedding vector_ip_ops);

-- probe vector must itself be normalized before it is sent
SELECT doc_id, chunk_index, (embedding <#> $1) * -1 AS similarity
FROM doc_chunks
ORDER BY embedding <#> $1
LIMIT 10;

Parameter reference

Parameter Type Default Production recommendation Notes
epsilon float 1e-8 1e-8 to 1e-12 Denominator floor; must be smaller than any legitimate norm but non-zero to block NaN from zero vectors.
dtype (normalize) numpy dtype float32 float32 Do the division here; float64 is safe but rarely needed. Never float16.
Storage type DDL vector(d) vector for recall-critical; halfvec only after a recall A/B holds Fixed at column creation — changing it later forces a table rewrite.
Distance opclass index DDL vector_cosine_ops vector_ip_ops (data is pre-normalized) Inner product is cheaper than cosine only when every stored vector is unit-length.
Post-cast tolerance float ‖v‖₂ ∈ [0.999, 1.001] Widen slightly for halfvec; fp16 quantization nudges the norm off exactly 1.0.
assert tolerance float 1e-4 1e-4 (vector), 1e-2 (halfvec) Validation atol; too tight and legitimate halfvec rows fail the gate.

Verification

Confirm the rows actually landed on the unit hypersphere. Run the Python assertion inside the ingestion loop, and spot-check in SQL that pgvector’s own norm agrees.

PYTHON
def assert_unit_norms(vectors: np.ndarray, tolerance: float = 1e-4) -> None:
    norms = np.linalg.norm(vectors, axis=1)
    if not np.allclose(norms, 1.0, atol=tolerance):
        raise ValueError(
            f"Non-unit vectors detected. Max deviation: "
            f"{np.max(np.abs(norms - 1.0))}"
        )
SQL
-- any row whose stored norm strays from 1.0 signals a normalization miss
SELECT doc_id, chunk_index, l2_norm(embedding) AS norm
FROM doc_chunks
WHERE abs(l2_norm(embedding) - 1.0) > 1e-3
LIMIT 20;

An empty result set means every persisted vector is unit-length and the <#> operator is safe to use as a cosine proxy.

Troubleshooting

  • invalid input syntax for type vector on insert. An unhandled zero-magnitude vector produced NaN/Inf upstream. Confirm the epsilon clamp is applied (np.maximum(norms, epsilon)) before the division, and log the offending doc_id — a spike usually traces to empty chunks or a failed inference call, not the database.
  • Recall quietly drops after switching to halfvec. Normalization ran after the fp16 cast, so quantization error is baked into the unit vector. Re-order to normalize in float32 first and cast at serialization (step 2), then re-check the norm with the widened halfvec tolerance.
  • <#> returns nonsense rankings. Either the stored vectors are not actually unit-length (run the SQL verification above), or the probe vector was not normalized before being sent. Normalize the query embedding with the same routine as ingestion.
  • Norms drift slowly across a backfill. Track a rolling average of l2_norm(embedding) per model version; a sudden shift signals a tokenizer change or an inference regression, not a bug in this code. Store model and normalized_at alongside each row so you can isolate the batch, a practice detailed in metadata mapping and schema design.
  • Index build is slow or memory-bound at scale. Unnormalized magnitude variance inflates the HNSW graph. Verify normalization first, then size the storage footprint with pgvector storage overhead analysis before blaming m or ef_construction.