The pgvector index ceiling is 2000 dimensions and the column type ceiling is 16000, so a vector(3072) column ingests ten million rows without complaint and then fails at CREATE INDEX with column cannot have more than 2000 dimensions for hnsw index. The check lives in InitBuildState in hnswbuild.c and reads the column type modifier at build time; nothing on the insert path consults it. The number is not arbitrary: at v0.8.6, vector 2000 x 4 + 8, halfvec 4000 x 2 + 8, bit 64000 / 8 and sparsevec 1000 x 8 + 16 all land between 8,000 and 8,016 bytes against an 8,192-byte page, so all four ceilings are one index-tuple budget divided by bytes per element. It has not moved since 0.4.0 on 2023-01-11, across twenty-one releases and four new minor series, and two pull requests that simply raised the constant were closed unmerged the day they were opened. Casting to halfvec costs at most 0.3 recall points across twelve published measurement pairs and shrinks the index 1.45x to 3.00x. A binary_quantize bit index is far riskier than its compression suggests: on gist-960 it returns 0.00 percent recall at every ef_search value tested, reranked and unreranked. Start by running the atttypmod and extversion queries against your own table, because the fix you can use depends on both.
A vector(3072) column is legal. It accepts a bulk COPY of ten million rows, answers distance queries, survives every integration test that does not assert on a query plan, and then fails the moment you try to make it fast. The pgvector 2000 dimension limit is an index limit, not a storage limit, and it fires at CREATE INDEX, which comes after the data is loaded rather than before.
Two error strings describe this failure, and they come from two different files. HNSW raises column cannot have more than 2000 dimensions for hnsw index. IVFFlat raises column cannot have more than 2000 dimensions for ivfflat index. Switching index type is not a workaround, because both constants are 2000.
The two ceilings that matter are far apart. At tag v0.8.6, the column type stops at 16000 dimensions and the index stops at 2000. That gap is why the failure arrives late and at the worst possible time. If your problem is that an index exists and is slow rather than that it refuses to build, the parameters and the memory math are in our guide to tuning pgvector HNSW for 10 million rows; this post is about the case where CREATE INDEX will not run at all.
The column took ten million rows and then refused an index
Here is the whole failure, start to finish.
CREATE TABLE documents (
id bigserial PRIMARY KEY,
content text,
embedding vector(3072)
);
-- ten million rows land without complaint
COPY documents (content, embedding) FROM STDIN WITH (FORMAT BINARY);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- ERROR: column cannot have more than 2000 dimensions for hnsw indexThe asymmetry is visible in the source. At v0.8.6 the check lives in InitBuildState in hnswbuild.c, which starts at line 684. Line 694 assigns TupleDescAttr(index->rd_att, 0)->atttypmod to the build state, so the width comes from the column's own type modifier and is read once, at index build. Line 708 compares that width against the maximum for the indexed type and line 711 raises the error. InitBuildState is called from the serial build path and from the parallel build path, and from nowhere else. No insert, update or copy path reads the maximum at all. The column ingests; the index refuses.
One detail in the message is easy to misread. The number printed is the ceiling, not your width. The format string substitutes the type's maximum, so a 3072-dimension column reports 2000 and a 4096-dimension column also reports 2000. The error tells you the limit and never tells you what you sent.
IVFFlat fails with a different string, raised from a different file.
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000); -- ERROR: column cannot have more than 2000 dimensions for ivfflat index
ivfbuild.c line 360 carries the identical condition against the IVFFlat type table, and line 363 prints its own wording. lists is a reloption bounded at 1 to 32768, parsed before the build ever starts, so a legal value like 1000 gets you to the dimension check and no further.
Three dimension ceilings and one sparsity ceiling: what v0.8.6 enforces
The README publishes four numbers for HNSW. Three of them are dimension ceilings and the fourth is something else entirely.
Read the last column, because it is the point of the table. Storage per value is documented in the README: 4 * dimensions + 8 bytes for a vector, 2 * dimensions + 8 for a half vector, 8 * non-zero elements + 16 for a sparse vector. Run each ceiling through its own formula. 2000 x 4 + 8 is 8,008. 4000 x 2 + 8 is 8,008. 64000 bits divided by 8 is 8,000 bytes of payload. 1000 x 8 + 16 is 8,016. A Postgres page is 8,192 bytes. Every ceiling is the same index-tuple budget divided by bytes per element, which is also why 4000 and 64000 are not independent constants: they are written in the source as HNSW_MAX_DIM * 2 and HNSW_MAX_DIM * 32, and 2 and 32 are exactly the ratios of four bytes to two bytes and of four bytes to one bit.
The sparsevec row is a different kind of ceiling, and the README's single list of four numbers flattens the distinction. sparsevec.h line 11 defines SPARSEVEC_MAX_DIM as 1000000000, and that billion is what hnswutils.c line 1425 installs as the sparsevec maximum. There is no dimension ceiling on a sparsevec index. The 1000 is a non-zero-element cap, checked in a different function on a different code path, raising sparsevec cannot have more than 1000 non-zero elements for hnsw index. A dense 3072-dimension embedding has roughly 3072 non-zero elements, so it is rejected by that check with room to spare. sparsevec is for genuinely sparse vectors and is not a route around the dense ceiling.
IVFFlat carries only three of these. ivfutils.c defines maximum dimensions at three places, for vector, halfvec and bit; there is no ivfflat_sparsevec_support function, and sparsevec does not appear in ivfbuild.c at all.
| Indexed type | HNSW ceiling | IVFFlat ceiling | Where the number comes from | Bytes at the ceiling |
|---|---|---|---|---|
vector | 2000 dimensions | 2000 dimensions | HNSW_MAX_DIM at hnsw.h line 33, IVFFLAT_MAX_DIM at ivfflat.h line 37 | 8,008 |
halfvec | 4000 dimensions | 4000 dimensions | HNSW_MAX_DIM * 2 at hnswutils.c line 1399 | 8,008 |
bit | 64000 dimensions | 64000 dimensions | HNSW_MAX_DIM * 32 at hnswutils.c line 1412 | 8,000 |
sparsevec | 1000 non-zero elements | not supported | HNSW_MAX_NNZ at hnsw.h line 34, enforced in SparsevecCheckValue at hnswutils.c line 1366 | 8,016 |
Why the ceiling has not moved since January 2023
The CHANGELOG for 0.4.0, released 2023-01-11, carries two separate lines: max dimensions for vector raised from 1024 to 16000, and max dimensions for index raised from 1024 to 2000. The storage ceiling landed at 16000 and the index ceiling at 2000, and the index ceiling has not moved since. Counting release headings in the CHANGELOG at v0.8.6 gives twenty-one tagged releases since, opening four new minor series.
That is not neglect. The project has stated the reason on the record: the constraint is the default PostgreSQL page size of 8 KB, which is not adjustable, and which bounds how many four-byte floats fit on a page. The obvious dodge is closed off on the same thread. Rebuilding Postgres with a larger block size does not help, because it increases the page size without increasing the index tuple size, which is fixed at 8K.
Two pull requests took the direct route and simply raised the constant. One in January 2024 proposed 2000 to 4096; one in June 2025 proposed 2000 to 4000. Both were closed unmerged the same day they were opened, the second answered with the observation that the tuple would exceed the page boundary and the instruction to cast the indexed vector to halfvec instead. Two issues asking why the limit is 2000, in March 2025 and October 2025, were closed the day they were opened and pointed at the earlier thread and the README FAQ. Treat 2000 as a fixed property of the engine, not as a bug with a pending fix.
What that means for model choice is short. Anything at 1536 dimensions or below indexes directly as vector. The 2560 and 3072 widths need a halfvec cast. 4096 clears neither, because the halfvec index ceiling is 4000 and Qwen3-Embedding-8B emits 4096, missing it by 96 dimensions. For widths by model, use the full model-to-dimension table rather than reasoning from memory.
Fix one: cast to halfvec, and what the measurement says it costs
The halfvec route is an expression index. The index stores half-precision copies of the values; the column keeps its full-precision data.
CREATE INDEX ON documents
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
-- the query MUST repeat the identical cast or the planner will not use the index
SELECT id
FROM documents
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 10;The catch is operational rather than numerical. The cast in the ORDER BY has to match the cast in the index exactly, including the declared width. Mismatch it, or forget it, and the planner cannot match the expression, so the query falls back to a sequential scan and returns correct results at sequential-scan cost. There is no error. The only signal is the plan, which is why the first thing to check after building this index is that EXPLAIN actually names it.
On quality, the published evidence is favourable and narrow. A quantization benchmark published in April 2024, run against the then-upcoming pgvector 0.7.0 on PostgreSQL 16.2 with data on local NVMe, swept HNSW at m 16 across five ef_construction values and eight ef_search values, and reported three corpora. The tables below are at ef_construction 256.
Full vector versus halfvec recall, HNSW at m 16, ef_construction 256. Both caveats are binding: every corpus here is 1536 dimensions or below, that is, under the ceiling this post is about, and the run was on 0.7.0 rather than v0.8.6. No public pgvector recall measurement at 3072 dimensions was reachable in any form.
Across those twelve pairs the largest gap is 0.3 points, and halfvec is ahead in two of them. Latency is equal or better at every point shown above. Index size falls 1.45x on sift-128 (782 MB to 538 MB), 2.00x on dbpedia (7,734 MB to 3,867 MB) and 3.00x on gist-960 (7,678 MB to 2,559 MB), with builds 1.14x to 2.31x faster. At these widths halfvec is close to free, and it is the route the project itself recommends first.
Build it without locking writes, and use the same parameters the numbers above were produced at, so the table describes your index rather than someone else's.
CREATE INDEX CONCURRENTLY documents_embedding_halfvec_idx
ON documents USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)
WITH (m = 16, ef_construction = 256);
-- later, if the index degrades
REINDEX INDEX CONCURRENTLY documents_embedding_halfvec_idx;
VACUUM documents;| Corpus (dimensions) | ef_search | vector recall | halfvec recall | p99 latency, vector to halfvec |
|---|---|---|---|---|
| sift-128-euclidean (128) | 10 | 77.7% | 77.5% | |
| sift-128-euclidean (128) | 40 | 95.4% | 95.4% | |
| sift-128-euclidean (128) | 200 | 99.8% | 99.8% | |
| sift-128-euclidean (128) | 800 | 100.0% | 100.0% | |
| gist-960-euclidean (960) | 10 | 50.4% | 50.1% | |
| gist-960-euclidean (960) | 40 | 78.0% | 78.1% | |
| gist-960-euclidean (960) | 200 | 96.0% | 96.0% | |
| gist-960-euclidean (960) | 800 | 99.6% | 99.6% | |
| dbpedia-openai-1000k-angular (1536) | 10 | 85.1% | 85.2% | 1.40 ms to 1.21 ms |
| dbpedia-openai-1000k-angular (1536) | 40 | 96.8% | 96.8% | 2.70 ms to 2.61 ms |
| dbpedia-openai-1000k-angular (1536) | 200 | 99.6% | 99.6% | 9.01 ms to 8.59 ms |
| dbpedia-openai-1000k-angular (1536) | 800 | 99.9% | 99.9% | 30.50 ms to 28.49 ms |
Fix two: binary_quantize to a bit index, and the corpus where it returns nothing
A bit index reaches 64000 dimensions, which clears every embedding width in circulation. It is also the route that can fail silently and completely, so the rerank is part of the pattern rather than an optimization on top of it. The inner query orders by Hamming distance on the quantized bits and takes a candidate set bounded by hnsw.ef_search; the outer query re-orders those candidates by the true distance on the original vectors.
CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)::bit(3072)) bit_hamming_ops);
SET hnsw.ef_search = 200;
SELECT i.id
FROM (
SELECT id, embedding <=> $1 AS distance
FROM documents
ORDER BY binary_quantize(embedding)::bit(3072) <~> binary_quantize($1::vector)
LIMIT 200 -- bound by hnsw.ef_search
) i
ORDER BY i.distance
LIMIT 10;The explicit ::bit(3072) is mandatory. Both of the following fail, with two different errors, and neither of them is the 2000-dimension error.
-- missing the ::bit(3072)
CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)) bit_hamming_ops);
-- ERROR: column does not have dimensions
-- bit varying is rejected outright
CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)::varbit(3072)) bit_hamming_ops);
-- ERROR: type not supported for hnsw indexbinary_quantize returns a bit string with no type modifier, so the build state reads an atttypmod of -1 and hnswbuild.c line 703 fires before the dimension test at line 708. The varbit rejection sits higher still, at lines 696 to 700, because the build requires fixed dimensions.
Now the measurement, which is the reason to read this section rather than the README.
Binary quantization recall, with and without reranking, same run and same caveats as above: 0.7.0, ef_construction 256, every corpus at 1536 dimensions or below.
Three corpora, three outcomes. Reranking rescued dbpedia completely, taking it from 66.8 percent to 91.6 at ef_search 40 and to 99.0 at 200, at 1.34x and 1.42x the queries per second of the full-precision index. It moved sift-128 from useless to still useless, 15.69 percent at the highest ef_search tested. It moved gist-960 not at all: 0.00 percent in both columns at all four settings, which the source describes as returning garbage results.
That zero is the finding, and it is harsher than the compression numbers on their own would suggest. The explanation offered is bit diversity in the vectors rather than width, and gist-960 is the wider of the two corpora that failed, so you cannot predict this from dimension count. Note also that the compression is real in exactly the failing case: the gist-960 bit index is 18.96x smaller than the full index, 405 MB against 7,678 MB, and dbpedia is 16.35x smaller. A configuration that is small, fast and wrong will not announce itself. Measure recall against exact search on your own corpus before this index serves a single production query.
Two operating limits are worth stating. hnsw.ef_search is capped at 1000 by HNSW_MAX_EF_SEARCH in hnsw.h line 62, so 800 is near the top of what the sweep could test. And binary_quantize, halfvec, sparsevec and subvector all arrived in 0.7.0, released 2024-04-29. On 0.6.x none of the index-side fixes in this post exist.
| Corpus (dimensions) | ef_search | vector recall | bit, no rerank | bit, reranked |
|---|---|---|---|---|
| sift-128-euclidean (128) | 10 | 77.7% | 2.18% | 2.31% |
| sift-128-euclidean (128) | 40 | 95.4% | 2.42% | 4.19% |
| sift-128-euclidean (128) | 200 | 99.8% | 2.52% | 8.88% |
| sift-128-euclidean (128) | 800 | 100.0% | 2.52% | 15.69% |
| gist-960-euclidean (960) | 10 | 50.4% | 0.00% | 0.00% |
| gist-960-euclidean (960) | 40 | 78.0% | 0.00% | 0.00% |
| gist-960-euclidean (960) | 200 | 96.0% | 0.00% | 0.00% |
| gist-960-euclidean (960) | 800 | 99.6% | 0.00% | 0.00% |
| dbpedia-openai-1000k-angular (1536) | 10 | 85.1% | 60.1% | 60.1% |
| dbpedia-openai-1000k-angular (1536) | 40 | 96.8% | 66.8% | 91.6% |
| dbpedia-openai-1000k-angular (1536) | 200 | 99.6% | 68.3% | 99.0% |
| dbpedia-openai-1000k-angular (1536) | 800 | 99.9% | 68.6% | 99.8% |
Fix three: truncate to get under the ceiling, and the re-embed you have to schedule
Truncation is normally argued as a storage decision. Here it is an index-admissibility decision: you are cutting to 1024 not to save bytes but to make CREATE INDEX succeed at all, and the byte saving is a side effect.
-- 3072 will not index; the first 1024 dimensions will
CREATE INDEX ON documents
USING hnsw ((subvector(embedding, 1, 1024)::vector(1024)) vector_cosine_ops);
SELECT id
FROM documents
ORDER BY subvector(embedding, 1, 1024)::vector(1024)
<=> subvector($1::vector, 1, 1024)
LIMIT 10;A truncated slice from a Matryoshka-trained model has to be re-normalized before it is compared, and models not trained that way degrade unpredictably when sliced; both the failure mode and the shape of the truncation curve are covered in the full model-to-dimension table and are not worth repeating here.
The cost that belongs in this section is the other half of the fix. Truncating in an expression index buys admissibility, not quality: the index ranks on a third of the signal, and the query above returns that ranking directly. Getting quality back means re-embedding the corpus at a width you chose deliberately, and that is a scheduling problem before it is an engineering one. It is GPU hours, a window during which the live index and the corpus disagree about what a vector means, a model-version record that has to survive the change log, and a decision about whether the old embeddings are retained or destroyed. Our post on when a re-embed is actually warranted covers the trigger conditions. What nobody has published for pgvector is a comparison of truncate-and-reindex against a halfvec cast at a fixed index budget, so this choice is currently made on cost shape rather than on measured retrieval quality.
Fix four: no index at all, and when a sequential distance scan is the honest answer
Skipping the index entirely is a legitimate option, and at 3072 dimensions it is the default state whether you chose it or not. This is a different question from the one where a metadata filter leaves you with a handful of candidate rows and the planner should abandon the index; that case, and the recall collapse that comes with it, is covered in when a metadata filter collapses recall. Here nothing is filtered. The corpus is simply too wide to index.
The arithmetic decides it. A vector(3072) row costs 4 x 3072 + 8 bytes, or 12,296 bytes. Ten million rows is roughly 123 GB of vector payload before any index exists. Since a 12,296-byte value exceeds the 8,192-byte page, and the vector type has used external storage since 0.6.0, the column is TOASTed, so every distance comparison pulls the value out of the TOAST relation first. A sequential scan reads all of it, per query.
Do not take a latency number on faith here, including from this post. Price it on your own hardware and read the buffer counts rather than the timing.
\set qvec '[0.0123, ... your own query embedding ... ]' BEGIN; SET LOCAL enable_indexscan = off; -- use exact search EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM documents ORDER BY embedding <=> :'qvec'::vector(3072) LIMIT 10; COMMIT;
The honest boundary is somewhere well below the scale that motivates this post. At a few hundred thousand rows with a warm cache and a low query rate, an exact scan is defensible and has the pleasant property of exactly 100 percent recall. At ten million rows and 123 GB of TOASTed payload, it is not a plan, it is what happens when nobody chose one.
The decision table when every vector stays in one audited Postgres instance
Deployment location does not change which constant fires. 2000 is 2000 in a managed cloud instance, in a private VPC and in an air-gapped rack. What it changes is the price of each escape route, and it reorders them. The four fixes above are the four rows that keep the data where it already sits; the fifth row is the option that does not.
One answer to "pgvector will not index 3072" is to move the vectors into a dedicated vector database. Inside a regulated perimeter that is the most expensive row in the table, and the cost has nothing to do with licensing. The embeddings are the regulated artifact, not a derived cache, so a second store is a second copy of the sensitive corpus, on a second backup and retention schedule, behind a second authentication surface with its own defaults to argue about. What that hardening actually involves is the subject of our post on hardening a self-hosted vector database. And it is a second erasure procedure that has to be evidenced separately at the next audit, which is harder than it sounds once soft deletes and index graphs are involved; see what a vector database erasure actually clears.
Against that, the first two rows are expression indexes on a table that is already in scope. Same instance, same row-level security, same backup, same DBA change control, same DELETE semantics, no new credential and no new subprocessor. That is why they rank first here even though a dedicated engine would index 3072 natively. The third row keeps the perimeter too, and converts the fix from a licence line item into scheduled GPU hours on hardware already inside the room.
Two things sharpen specifically on-premise. First, a private deployment cannot call a hosted embedding API, so the model has to be an open-weight checkpoint, and the widest one on our own published table emits 4096 dimensions and clears neither the 2000 vector ceiling nor the 4000 halfvec one. The private stack meets the harder version of this problem, not the easier one. Second, evidence. Every recall figure in this post was measured at 1536 dimensions or below, on pgvector 0.7.0, on corpora that are not yours. Under a model-risk or validation regime, a proxy measurement is background reading, not your validation artifact.
So the concrete thing to do this week is small and produces that artifact. Read the two facts that decide everything.
SELECT extversion FROM pg_extension WHERE extname = 'vector';
SELECT a.attname,
format_type(a.atttypid, a.atttypmod) AS declared_type,
a.atttypmod AS dimensions
FROM pg_attribute a
WHERE a.attrelid = 'documents'::regclass
AND a.attname = 'embedding';The second query returns the exact number hnswbuild.c compares against 2000, and the first tells you whether the halfvec, binary_quantize and subvector routes exist in your build at all. Then run the exact-search block from the previous section against a sample of your own queries, record the results as ground truth, and compare each candidate index against it. That is a one-instance job with no egress, and it produces a table you can hand to a reviewer. The wider question of how to score those comparisons is covered in our post on measuring whether retrieval actually works and across the RAG systems pillar.
| Option | Widths it admits | What the measurement supports | Stays inside the audited instance | What it costs you |
|---|---|---|---|---|
Cast to halfvec in an expression index | up to 4000 | Within 0.3 recall points of full precision across twelve pairs, at 1536 dimensions and below | Yes | One CREATE INDEX CONCURRENTLY and an identical cast repeated in every query |
binary_quantize to a bit index, reranked | up to 64000 | 99.0% on dbpedia at ef_search 200, and 0.00% on gist-960 at every setting | Yes | Rerank is mandatory and you must measure recall on your own corpus first |
Truncate with subvector, then re-embed | any width you choose | No pgvector measurement at all | Yes | A full re-embed as scheduled GPU time, plus a window where index and corpus disagree |
| No index, exact scan | any width | Not applicable | Yes | Latency linear in rows, and a TOAST read of roughly 12.3 KB per row at 3072 dimensions |
| Move the vectors to a second datastore | any width | Out of scope for this post | No | A new system inside the audit boundary, with its own auth surface, backup schedule and erasure procedure |
FAQ
Quick answers to the questions this post tends to raise.



