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
pgvectorextension 0.5+ installed (CREATE EXTENSION vector;). Use 0.7+ if you intend to storehalfvec. numpy >= 1.24for the batched L2 norm, andpsycopg3.1+ orasyncpg0.28+ to bind the vector to the column.- Embeddings available as
float32(orfloat64) arrays — the full-precision output of the model, not a pre-quantizedfp16cast. - 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, orhalfvec(d)once a recall A/B holds — the trade-off is covered in vector data type selection.
Why unit-length vectors matter
Cosine similarity between vectors
When both vectors are unit-normalized (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.
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 / norms2. 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:
- Generate embeddings in
float32/float64. - Normalize to unit length using
float32arithmetic (step 1). - Cast to
float16(halfvec) only here, as you serialize for the wire. - Re-check the magnitude after the cast (step 4 below).
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.
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.
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.
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))}"
)-- 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 vectoron insert. An unhandled zero-magnitude vector producedNaN/Infupstream. Confirm theepsilonclamp is applied (np.maximum(norms, epsilon)) before the division, and log the offendingdoc_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 thefp16cast, so quantization error is baked into the unit vector. Re-order to normalize infloat32first and cast at serialization (step 2), then re-check the norm with the widenedhalfvectolerance. <#>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)permodelversion; a sudden shift signals a tokenizer change or an inference regression, not a bug in this code. Storemodelandnormalized_atalongside 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
moref_construction.
Related
- Type Casting & Vector Normalization — the precision-and-conversion stage this step belongs to
- Vector data type selection — choosing
vectorvshalfvecvssparsevecbefore you cast - Cosine vs L2 distance metrics — whether normalization is even required for your metric
- Handling metadata drift during vector ingestion — keeping model version and norm provenance consistent per row
- Up: Type Casting & Vector Normalization