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

  1. Create a Postgres (pgvector) collection, set vector_index_strategy to HNSW, and index a non-trivial number of rows.
  2. Run a cosine similarity search through the AI search stack.
  3. Capture the SQL (Postgres log_min_duration_statement) and EXPLAIN ANALYZE it — observe Seq Scan despite the ..._embedding_idx HNSW index existing.
  4. Change the ORDER BY as above and re-run — observe Index Scan.
Command icon 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

nikro created an issue. See original summary.

nikro’s picture

Assigned: Unassigned » nikro

Picking it up, duh.

arianraeesi’s picture

nikro’s picture

What 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 distance similarity value is unchanged, so this is not an API change — only the ORDER BY differs.

Why

Ordering by (1 - distance) DESC hid 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 DESC became ORDER BY subquery.real_distance ASC. Nothing else changed.

How to test it (no database knowledge required)

  1. Get real content. Download the sample CNN news CSV (~1,000 articles) from this public dataset and import the rows as Article nodes (headline → title, content → body). Any content type with a few thousand nodes is fine.
  2. Set up the vector database, no index yet. Create an AI Search server using the Postgres (pgvector) provider, set the metric to Cosine and Vector index strategy = None. Add a Search API index over your Articles (title + body) with chunking enabled, then index the content. Chunking turns ~1,000 articles into several thousand vector rows.
  3. Run some searches and time them. Open the Vector DB explorer at /admin/config/ai/explorers/vector_db_generator and 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.
  4. Turn the index on and repeat. Go back to the AI Search server settings, change Vector index strategy to HNSW, and save (this builds the index). Run the exact same 2–3 queries again and note the times.
  5. Compare. With this patch the HNSW run should be dramatically faster than the no-index run and return the same, sensible results — nothing broken. Without the patch, switching to HNSW makes no difference, because the index is never used.

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).

nikro’s picture

Assigned: nikro » Unassigned
Status: Active » Needs review
abhisekmazumdar’s picture

Assigned: Unassigned » abhisekmazumdar
abhisekmazumdar’s picture

Assigned: abhisekmazumdar » Unassigned
Status: Needs review » Reviewed & tested by the community

I 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.

arianraeesi’s picture

arianraeesi’s picture

Issue tags: +2026Sprint15

marcus_johansson made their first commit to this issue’s fork.

marcus_johansson’s picture

Status: Reviewed & tested by the community » Fixed

Getting merged, thanks

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.