pgvector Different Embedding Dimensions: Schema and SQL
To support pgvector different embedding dimensions, use one table per model and dimension, each with a typed vector(N) column and its own HNSW index. Untyped columns work only with partial expression indexes.
- A vector(N) column enforces exactly N dimensions, while an untyped vector column accepts any length but cannot be indexed without a cast.
- HNSW and IVFFlat index at most 2,000 dimensions for vector, so 3072-dim embeddings need halfvec (up to 4,000) or a shortened size.
- Zero-padding leaves cosine, L2 and inner product unchanged but wastes storage and never makes two models comparable.
- Switching models always means re-embedding every row, and queries must use the same model as the stored documents.
- Shortening text-embedding-3 vectors via the dimensions parameter returns a normalised vector, but manual truncation needs L2 re-normalisation.
Which schema should you choose for multiple embedding dimensions?
The core rule: a column typed vector(N) accepts exactly N dimensions. An untyped vector column accepts any length per row, but distance operators raise an error when the two sides differ, and an index needs a fixed dimension.
Everything else follows from those two facts. Pick the option by how many models you need to serve at once and whether any dimension exceeds the 2,000-dimension index limit.
| Situation | Best option | Index | Trade-off |
|---|---|---|---|
| One model per table, any number of models | A: table per model/dimension | HNSW on typed column | Simplest and fastest; more tables to manage |
| Many models, one table wanted | B: untyped vector + partial expression indexes | One partial index per model | Queries must repeat the exact cast and WHERE clause |
| Small fixed set of sizes (e.g. 512 and 1536) | C: one typed column per size | One index per column | NULLs and a column per size |
| 3072 dims at full size | Cast to halfvec | HNSW on halfvec, up to 4,000 dims | Slight precision loss, half the storage |
| Switching models | Migration, not mixing | Build new index concurrently | Re-embed everything |
Option A: one table per model and dimension
This is the recommended default. Each table has a typed column, so Postgres rejects wrong-sized inserts, and the HNSW index is built on a plain column with no cast to remember.
Keep chunk text in a separate table and reference it by foreign key, so the source is never duplicated.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE embeddings_1536 (
chunk_id bigint PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
model text NOT NULL DEFAULT 'text-embedding-3-small',
embedding vector(1536) NOT NULL
);
CREATE INDEX ON embeddings_1536 USING hnsw (embedding vector_cosine_ops);
-- 3072 dims exceeds the 2,000 limit for vector indexes, so index a halfvec cast
CREATE TABLE embeddings_3072 (
chunk_id bigint PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
model text NOT NULL DEFAULT 'text-embedding-3-large',
embedding vector(3072) NOT NULL
);
CREATE INDEX ON embeddings_3072
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
-- Query: the expression must match the index expression
SELECT chunk_id
FROM embeddings_3072
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 10; Option B and C: one table with several sizes
Option B stores an untyped vector plus model and dimensions columns. A check constraint keeps the metadata honest, and a partial expression index per model gives the planner a fixed-dimension index to use.
The planner only uses the index when the query repeats the same cast and the same WHERE predicate. If either differs, you get a sequential scan, not an error.
CREATE TABLE embeddings (
chunk_id bigint NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,
model text NOT NULL,
dimensions int NOT NULL,
embedding vector NOT NULL,
PRIMARY KEY (chunk_id, model),
CHECK (vector_dims(embedding) = dimensions)
);
CREATE INDEX ON embeddings
USING hnsw ((embedding::vector(1536)) vector_cosine_ops)
WHERE model = 'text-embedding-3-small';
CREATE INDEX ON embeddings
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)
WHERE model = 'text-embedding-3-large';
-- Index-friendly query for the 1536 model
SELECT chunk_id
FROM embeddings
WHERE model = 'text-embedding-3-small'
ORDER BY embedding::vector(1536) <=> $1::vector(1536)
LIMIT 10;Why do I get "different vector dimensions 1536 and 3072"?
The distance operators require both operands to have the same length. The error appears when a query vector from one model meets stored vectors from another, or when an untyped column holds mixed sizes and the scan reaches a row that does not match.
A typical cause is a stored function declared with a fixed vector(1536) parameter being called with a 3072-dim vector. The fix is to make the dimension part of the design: a function per model, or one function that filters by model and casts to the matching size.
Always filter by model before the distance operator touches a row, so mismatched rows are never compared.
CREATE FUNCTION search_small(q vector, k int DEFAULT 10)
RETURNS TABLE (chunk_id bigint, distance double precision)
LANGUAGE sql STABLE AS $$
SELECT chunk_id, (embedding::vector(1536) <=> q::vector(1536))::double precision
FROM embeddings
WHERE model = 'text-embedding-3-small'
ORDER BY embedding::vector(1536) <=> q::vector(1536)
LIMIT k;
$$; Does zero-padding vectors work in pgvector?
Mathematically yes. Appending zeros to both vectors adds terms of 0 x 0 to the dot product and 0 to each squared norm, and (0 - 0)^2 = 0 to L2 distance. So cosine, L2 and inner product are unchanged, and padding 1536 to 3072 returns the same ranking.
In practice it is a bad idea. Every padded dimension costs about 4 bytes per vector, scans and index builds slow down, and a padded vector from model A is still not comparable with one from model B.
Padding only helps if you must force one fixed-size column for a single model, which a typed table per model already solves.
import numpy as np
a, b = np.random.rand(1536), np.random.rand(1536)
pa, pb = np.pad(a, (0, 1536)), np.pad(b, (0, 1536))
cos = lambda x, y: x @ y / (np.linalg.norm(x) * np.linalg.norm(y))
assert np.isclose(cos(a, b), cos(pa, pb)) How do I use shortened text-embedding-3 dimensions with pgvector?
text-embedding-3-small defaults to 1536 and -large to 3072. Both accept a dimensions parameter, because they were trained with Matryoshka representation learning, which front-loads information into the leading dimensions.
When you request fewer dimensions from the API, the returned vector is normalised. If you truncate yourself, cut first and then L2 re-normalise, otherwise cosine is fine but inner product scores are distorted. Mixing normalised and unnormalised vectors under <#> gives inconsistent rankings.
Requesting 1024 or 1536 from -large keeps it under the 2,000 limit, so a plain vector index works with no halfvec cast.
from openai import OpenAI
import numpy as np
client = OpenAI()
r = client.embeddings.create(
model="text-embedding-3-large",
input="example chunk",
dimensions=1024, # normalised by the API
)
vec = r.data[0].embedding
# manual truncation: re-normalise afterwards
full = np.array(client.embeddings.create(
model="text-embedding-3-large", input="example chunk").data[0].embedding)
short = full[:1024]
short = short / np.linalg.norm(short) How do I migrate to a new embedding dimension without downtime?
Treat the change as a new model, because it is one. Build the new column or table beside the old one, serve from the old until the new one is complete, then switch.
Record the model name and version on every row so you can always tell what produced a vector.
- Create the new table (for example embeddings_1024) with its typed column, but no index yet.
- Dual-write: embed every new or updated chunk with both models.
- Backfill old chunks in batches of a few hundred, respecting API rate limits and retrying on 429 responses.
- Build the index with CREATE INDEX CONCURRENTLY, which avoids blocking writes. Run it after the backfill so the graph is built once.
- Evaluate recall on a small labelled query set against the old table before switching.
- Switch the query function to the new table, then raise hnsw.ef_search (default 40) if recall is lower than expected: SET hnsw.ef_search = 100;
- After a stable period, stop dual-writing and drop the old table.
Common questions
Can one pgvector column store vectors of different lengths?
Yes, if the column is declared as plain vector with no dimension. Each row may have a different length, but distance operators fail on mismatched pairs and you cannot build an index without casting to a fixed size.
Can pgvector index 3072-dimension embeddings?
Not as vector, because HNSW and IVFFlat cap at 2,000 dimensions. Index a halfvec cast, which supports up to 4,000 dimensions at half the storage, or shorten the embeddings to 1536 or below.
Is it safe to compare embeddings from two models at the same dimension?
No. Each model learns its own vector space, so equal dimensions do not mean equal meaning, and distances between them are meaningless. Re-embed all documents when you switch models.
Which dimension should I use in production?
Pick one. 1536 from text-embedding-3-small, or 512 to 1024 shortened from -large, often keeps most of the quality at lower storage and cost. Measure recall on a small evaluation set before shrinking.
Why is my partial expression index not used?
The query must repeat the exact index expression, including the cast, and a WHERE clause that implies the index predicate. A different cast, a missing model filter, or a parameter the planner cannot match falls back to a sequential scan.