Overview

Dimension sets index size and query cost, and the normalization state of the vectors decides whether cosine, dot product, and L2 give the same ranking. Both must be consistent across every vector in an index, including queries. Matryoshka-trained models let you cut the dimension without re-embedding; see embeddings for the models.

Shorten dimensions with the API, then measure recall

Models trained with Matryoshka Representation Learning keep most quality in a leading prefix of the vector. Request the size you want instead of slicing client-side.

vo.embed(texts, model="voyage-4", input_type="document", output_dimension=512)        # 256, 512, 1024, 2048
oai.embeddings.create(model="text-embedding-3-large", input=texts, dimensions=512)    # any value up to 3072

Matryoshka-trained models at 2026-10: OpenAI text-embedding-3-*, both Gemini embedding models (output_dimensionality, 128 to 3072), the Voyage 4 and 3.5 families, qwen3-embedding (32 to 4096), embeddinggemma (512, 256, 128), and Nomic v1.5 (64 to 768). Cohere embed-v4.0 and v5 offer fixed size options (256 up to 1536 or 2048) through the API. Do not slice a model that was not trained for it, and never zero-pad to reach a size.

Use halve-and-verify to pick a size:

  1. Measure recall@10 on your golden set at the model default.
  2. Halve the dimension and re-measure.
  3. Keep halving while the loss stays inside your tolerance (for example 1 point of recall@10); stop at the last size that passed.

Budget storage from the dimension

A float32 vector costs dimension x 4 bytes, so one million vectors take about 1 GB at 256 dimensions, 4 GB at 1024, and 12 GB at 3072, before index overhead.

  • Voyage output_dtype of int8 cuts vector storage 4x and binary cuts it 32x. Measure recall before adopting either.
  • pgvector indexes vector columns only up to 2,000 dimensions and halfvec up to 4,000. A 3072-dimension OpenAI vector cannot take a plain HNSW index; index a halfvec cast or shorten the dimension.
  • Never mix models in one index even at equal dimension; see embeddings.

L2-normalize every vector, then use inner product

On unit vectors, cosine similarity equals dot product and gives the same ranking as Euclidean distance. Normalize at write time and at query time, and never mix normalized and unnormalized vectors in one index.

import numpy as np
 
def l2_normalize(v: np.ndarray) -> np.ndarray:
    n = np.linalg.norm(v, axis=-1, keepdims=True)
    return np.where(n > 0, v / n, v)

Normalize immediately after the embed call so cached vectors are already unit length. Unnormalized dot products mix vector magnitude into the score, and nothing errors; recall just degrades, typically for short queries against long documents.

Re-normalize after truncation

A prefix of a unit vector is no longer unit length. If you slice manually, slice first and normalize second:

short = l2_normalize(full_vector[:512])

APIs differ: Gemini Embedding 2 normalizes truncated vectors for you, while gemini-embedding-001 requires manual normalization for every size except 3072. Check the output norm instead of trusting this list.

Know each source’s default

  • Voyage and OpenAI document their embeddings as unit length. Ollama’s /api/embed returns L2-normalized vectors.
  • sentence-transformers returns unnormalized vectors unless you pass normalize_embeddings=True to encode().
  • Raw Hugging Face mean-pooled outputs are unnormalized.
norms = np.linalg.norm(np.array(embed(sample_texts)), axis=1)
assert np.allclose(norms, 1.0, atol=1e-3), (norms.min(), norms.max())

Run that check on the first batch of every new pipeline and again at index load.

Set the index metric to match

StoreAfter normalizing
pgvector<#> is negative inner product (vector_ip_ops); <=> is cosine distance (vector_cosine_ops). Rankings match
Pineconecreate the index with metric="dotproduct"
ChromaDBthe default space is l2; set configuration={"hnsw": {"space": "ip"}} (or "cosine") at collection creation
FaissIndexFlatIP or METRIC_INNER_PRODUCT