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 3072Matryoshka-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:
- Measure recall@10 on your golden set at the model default.
- Halve the dimension and re-measure.
- 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_dtypeofint8cuts vector storage 4x andbinarycuts it 32x. Measure recall before adopting either. - pgvector indexes
vectorcolumns only up to 2,000 dimensions andhalfvecup to 4,000. A 3072-dimension OpenAI vector cannot take a plain HNSW index; index ahalfveccast 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/embedreturns L2-normalized vectors. sentence-transformersreturns unnormalized vectors unless you passnormalize_embeddings=Truetoencode().- 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
| Store | After normalizing |
|---|---|
| pgvector | <#> is negative inner product (vector_ip_ops); <=> is cosine distance (vector_cosine_ops). Rankings match |
| Pinecone | create the index with metric="dotproduct" |
| ChromaDB | the default space is l2; set configuration={"hnsw": {"space": "ip"}} (or "cosine") at collection creation |
| Faiss | IndexFlatIP or METRIC_INNER_PRODUCT |