Summary
The module creates a correct HNSW index for cosine collections (CREATE INDEX ... USING hnsw (embedding vector_cosine_ops), via vector_index_strategy), but the CosineSimilarity branch of PostgresPgvectorClient::vectorSearch() builds a query that cannot use that index. Every cosine similarity search falls back to a full sequential scan whose cost grows linearly with collection size and degrades badly under concurrency.
The generated query (cosine branch)
SELECT (1 - subquery.real_distance) AS distance, <fields> FROM ( SELECT embedding <=> '[...]' AS real_distance, <fields> FROM "collection" <filters> ) AS subquery ORDER BY distance DESC LIMIT k OFFSET n;
Why it cannot use the index
pgvector only uses an HNSW/IVFFlat index when the query is ORDER BY embedding <=> '[...]' ASC LIMIT k applied directly to the table. Here the distance is computed in a derived subquery, transformed to 1 - distance, and ordered DESC in the outer query. The planner does not rewrite (1 - d) DESC into d ASC, so the ANN index is invisible and Postgres does a Seq Scan + top-N sort over the entire collection — even though a matching vector_cosine_ops index exists.
Evidence
Collection: 295,317 rows, embedding vector(1024), cosine (<=>), with the module's HNSW index present.
| ORDER BY | Plan | Time |
|---|---|---|
(1 - distance) DESC (current) |
Parallel Seq Scan + top-N sort |
~2,000 ms (to ~22,000 ms under load) |
real_distance ASC (proposed) |
Index Scan using ..._embedding_idx |
~4 ms |
Root cause
The cosine branch is special-cased to return a similarity (1 - distance) and sort DESC. That transformation hides the index. The EuclideanDistance and InnerProduct branches in the same method are already correct — they ORDER BY the raw operator ascending and are index-friendly.
Proposed fix (one clause)
Keep returning (1 - real_distance) AS distance for API compatibility, but order by the raw inner distance ascending. Ordering by the inner alias is sufficient (verified: it produces an Index Scan, identical result rows and identical returned similarity values).
// Before (lines ~507 and ~515) ... ) as {$alias} ORDER BY distance DESC LIMIT {$limit} OFFSET {$offset}; // After ... ) as {$alias} ORDER BY {$alias}.real_distance ASC LIMIT {$limit} OFFSET {$offset};
Related
Follow-up to #3576225, which added the HNSW/IVFFlat index strategy. That issue reported "HNSW and IVFFlat are faster than sequential scan," but did not verify via EXPLAIN that the cosine vectorSearch() query actually uses the index — which, per this report, it does not.
Steps to reproduce
- Create a Postgres (pgvector) collection, set
vector_index_strategyto HNSW, and index a non-trivial number of rows. - Run a cosine similarity search through the AI search stack.
- Capture the SQL (Postgres
log_min_duration_statement) andEXPLAIN ANALYZEit — observeSeq Scandespite the..._embedding_idxHNSW index existing. - Change the ORDER BY as above and re-run — observe
Index Scan.
Issue fork ai_vdb_provider_postgres-3609274
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
nikro commentedPicking it up, duh.
Comment #3
arianraeesi commentedComment #5
nikro commentedWhat I fixed
The cosine branch of
PostgresPgvectorClient::vectorSearch()now orders results by the raw vector distance ascending instead of by the derived(1 - distance)value descending. The returned(1 - real_distance) AS distancesimilarity value is unchanged, so this is not an API change — only theORDER BYdiffers.Why
Ordering by
(1 - distance) DESChid the vector column from the query planner, so pgvector could never use the HNSW/IVFFlat index and every cosine search had to compare the query against every single row in the collection. Ordering by the raw distance ascending is the exact same ordering (as one value goes up the other goes down), but it lets pgvector use the index. It also remains correct when no index exists, so the change is safe either way.How
One clause, in both cosine queries (with and without filters):
ORDER BY distance DESCbecameORDER BY subquery.real_distance ASC. Nothing else changed.How to test it (no database knowledge required)
/admin/config/ai/explorers/vector_db_generatorand run 2–3 queries (for example: cooking, crime, smoking). To see how long each query takes, open your browser dev tools (F12) → Network tab, run the search, and read the request's Time column in milliseconds. Note the numbers.For reference, on a collection of ~13,000 chunked rows this took the cosine query from roughly 325 ms (no index) down to about 2 ms (HNSW), returning the same result rows.
AI disclosure - obviously AI was used to design the fix, implement and test things as well, however it's manually rolled-out and tested on local environment with my participation and oversight (and lots of back-and-forward).
Comment #6
nikro commentedComment #7
abhisekmazumdarComment #8
abhisekmazumdarI tested this against a real Postgres + pgvector database. Before the patch, the cosine search did a full table scan and took about 250ms on 50,000 rows. After the patch, it used the index and took under 1ms, with the same results either way. The fix works as described.
One thing worth asking but not a blocker here: the search query has no tie-breaker when two rows land on the exact same distance. This is a pre-existing gap, not something this patch introduces. It can show up with duplicate or near-duplicate content chunks, and it means result order near the page boundary isn't fully predictable. A simple fix would be to also sort by the row's own ID when distances tie. I can open a follow-up issue for that, or it could get folded into this one, whichever you prefer.
Everything else checks out, no regression to the other search modes, passes coding standards.
Comment #9
arianraeesi commentedComment #10
arianraeesi commentedComment #13
marcus_johansson commentedGetting merged, thanks