Most writing about hybrid search in Postgres is stale in ways that will cost you either recall or an outage. Three things changed recently enough that nearly every tutorial, and nearly every model trained before this summer, is wrong about them.
pgvector is on 0.8.6, released July 29, 2026. There were four patch releases in June and July. One fixes HNSW index corruption during vacuum; a second fixes errors thrown during HNSW vacuum and concurrent inserts. If you are running HNSW on a table that gets vacuumed, which is all of them, 0.8.4 is your minimum safe version.
No major cloud provider offers a BM25 extension. Not RDS, not Aurora, not Cloud SQL, not Azure Flexible Server. Neon deprecated pg_search on March 19, 2026 and it is unavailable to new projects. Supabase does not list it. If your architecture needs real BM25 in Postgres, you are choosing self-hosted, Tiger Cloud, or ParadeDB BYOC, and that is an infrastructure decision rather than a library decision.
And there is now a second serious BM25 extension. Tiger Data's pg_textsearch hit 1.0 on April 3, 2026, under the permissive PostgreSQL license rather than AGPL.
Let me walk the whole stack, because the interesting parts are not the parts people write about.
The version situation, and why it is an operational problem
Here is the 2026 pgvector release train:
| Version | Date | What it fixed |
|---|---|---|
| 0.8.6 | 2026-07-29 | Buffer overflow in IVFFlat build on 32-bit; sparsevec cast; IVFFlat scan memory with nested loops |
| 0.8.5 | 2026-07-08 | IVFFlat build memory on small tables |
| 0.8.4 | 2026-06-30 | hnsw graph not repaired on vacuum; insert errors during HNSW vacuum; IVFFlat builds exceeding maintenance_work_mem |
| 0.8.3 | 2026-06-17 | Possible index corruption with HNSW vacuuming; Hamming/Jaccard regression on PG18 |
| 0.8.2 | 2026-02-25 | CVE-2026-3172, buffer overflow in parallel HNSW build |
Every 2026 release has been durability and memory-safety hardening. No new features. That is not stagnation, it is an extension growing up.
The corruption bug deserves a beat. HNSW is a graph. Vacuum has to repair edges pointing at dead tuples while concurrent inserts are mutating the same graph. That is genuinely hard concurrent-data-structure work, and pgvector did not have it fully right until 0.8.3 in June 2026.
CVE-2026-3172 is the other one to know: a buffer overflow in parallel HNSW index build affecting 0.6.0 through 0.8.1, CVSS 8.1, allowing a database user to leak data from other relations or crash the server. Fixed in 0.8.2.
Now compare against what you can actually run:
| Provider | pgvector | BM25 |
|---|---|---|
| AWS RDS / Aurora | 0.8.2 | none |
| Azure Flexible Server | 0.8.2 | none |
| Google Cloud SQL | 0.8.0 | none |
| Neon | 0.8.1 (PG18) | pg_search deprecated 2026-03-19 |
| Supabase | vector | none listed |
| Tiger Cloud | pgvector + pgvectorscale | pg_textsearch 1.1.0 |
| Self-hosted / ParadeDB | anything | pg_search 0.25.0 |
None of the big three have the vacuum fixes. Cloud SQL and Neon report versions inside the CVE range. I want to be careful here: managed providers routinely backport security patches without bumping the reported extension version, and I could not confirm whether Google or Neon have done so. State the version, not the vulnerability, and ask your provider.
The broader lesson from Neon's deprecation is worth internalizing. Building on a managed provider's third-party extension is a dependency on a commercial partnership, not on software. Partnerships end.
Filtering is the hard part, and it was broken until 0.8.0
This is the single most consequential thing to understand about vector search in Postgres, and it is the thing people discover in production.
With approximate indexes, filtering is applied after the index is scanned. The pgvector README gives the concrete example: a condition matching 10% of rows may yield only about 4 results with default HNSW settings when you asked for more. The index returns ef_search candidates, then your WHERE clause deletes most of them. That is not gradual degradation, it is a recall cliff.
AWS published the cleanest controlled measurement of this: Aurora PostgreSQL, 10M products, 384-dimension embeddings, nine categories, filters matching 2% of rows.
Their recall table shows pgvector 0.7.4 at 10% recall on category-filtered search and 1% on complex filtered search, against 100% for 0.8.0 with iterative scanning. In AWS's own prose: "For highly selective queries (products in a specific category), pgvector 0.7.4 returned only a fraction of requested results."
One percent recall. The query returned results. They looked plausible. They were wrong 99 times out of 100.
Iterative scans, added in 0.8.0, are the general fix:
SET LOCAL hnsw.iterative_scan = relaxed_order; -- or strict_order
SET LOCAL hnsw.max_scan_tuples = 20000; -- default
SET LOCAL hnsw.scan_mem_multiplier = 1; -- × work_memAWS found relaxed_order outperformed strict_order for most query types while still returning complete result sets, with ef_search = 200 as the best overall balance. Their p99 numbers on the same hardware, 0.7.4 to 0.8.0: top-10 went from 123.3 ms to 13.1 ms, the complex filter from 127.4 ms to 70.7 ms, and a 10,000-row result from 913.4 ms to 160.3 ms.
If you need strict distance ordering out of a relaxed scan, the documented trick is a materialized CTE with a + 0 to defeat sort elision:
WITH relaxed AS MATERIALIZED (
SELECT id, embedding <-> $1 AS distance
FROM items WHERE category_id = 123
ORDER BY distance LIMIT 5
)
SELECT * FROM relaxed ORDER BY distance + 0;The full strategy ladder, in order of how much you should reach for each:
- B-tree on the filter column, so the planner can pick a non-vector path when selectivity is high.
- Iterative scans, the general-purpose answer.
- Partial indexes, when there are only a handful of distinct filter values. This is true pre-filtering with perfect recall inside the partition, and it does not scale past a few dozen values.
- Partitioning by list, when there are many distinct values.
The multi-tenant trap
Here is the part I have not seen written clearly anywhere, and it bites SaaS teams specifically.
Row-level security is a post-filter from the index's perspective. A shared HNSW index plus RLS reproduces exactly the recall cliff above. PARTITION BY LIST(tenant_id) with per-partition HNSW indexes is the recall-correct pattern. RLS is the security-correct pattern. You generally need both, and your partition count becomes a planning constraint.
There is also a case for skipping ANN entirely. If you have a few thousand vectors per tenant, a B-tree on tenant_id plus an exact scan gives you 100% recall and is faster than the approximate path. Approximate indexes are a response to scale you may not have per tenant.
Postgres full text search is not BM25, and the reason matters
Native Postgres FTS gives you ts_rank and ts_rank_cd. Neither is BM25. There is no document-length-normalized, IDF-weighted, saturating term frequency. No k1, no b. Corpus-level IDF is not available to the ranking function at all.
ts_rank_cd is cover density ranking, from Clarke, Cormack and Tudhope's 1999 work, and it accounts for term proximity. It requires positional information and silently returns 0 on stripped tsvectors, which is a fun afternoon.
The deeper problem is in the docs, stated plainly:
"Ranking can be expensive since it requires consulting the tsvector of each matching document, which can be I/O bound and therefore slow. Unfortunately, it is almost impossible to avoid since practical queries often result in large numbers of matches."
There is no top-k early termination in native Postgres FTS. No WAND, no block-max, no dynamic pruning. Every matching document gets scored, every time.
That sentence is the entire business case for the BM25 extensions. Neon benchmarked pg_search against native FTS on 10M rows: a top-N ranked query took 81 ms versus 38,797 ms. That is 479 times, and it is not because Tantivy is magic. It is because native FTS scores everything and pg_search does not.
Worth knowing the hard limits too: lexemes under 2 KB, tsvector under 1 MB, position values capped at 16,383 with at most 256 positions per lexeme. Past roughly 16K tokens in a document, positions are clamped at 16,383, so everything after that shares a position and proximity ranking degrades to noise. Chunk before that point, which you should be doing anyway.
And use GIN. The docs say it directly: GIN indexes are the preferred text search index type. GiST is lossy, with a fixed-length signature that produces false matches requiring heap rechecks. GIN's historical weakness was slow updates, mitigated by fastupdate and the pending list, and PostgreSQL 18 added parallel GIN builds.
If you can run an extension
ParadeDB pg_search, currently 0.25.0 (July 28, 2026), AGPL licensed. Note that the syntax changed substantially in 0.20.0 in November 2025 when the v2 API became the default, so anything you copy from an older tutorial is stale. Text field casts are now ::pdb.simple('stemmer=english'), operators are ||| and &&&, scoring is pdb.score(id). Their 0.20.0 release charts single-row updates going from about 120/s to 2,016/s via mutable segments and background merging. ParadeDB's prose calls that "more than two orders of magnitude"; the chart shows about 16x. Worth knowing before you repeat either number.
Tiger Data pg_textsearch, 1.0 in April 2026, PostgreSQL licensed, LSM-tree inspired with Block-Max WAND dynamic pruning. Requires PG17 or 18 and shared_preload_libraries. Their vendor benchmark against ParadeDB v0.21.6 on 138 million MS MARCO passages is worth reading for its shape rather than its headline (and note that pg_search is now five minor versions further along, including the mutable-segment work, so the comparison is stale in ParadeDB's favor): p50 latency of 5.11 ms versus 59.83 ms on single-lexeme queries, and 177.95 ms versus 190.47 ms at 8+ lexemes. The advantage is concentrated on short queries where WAND pruning pays, and it collapses to 1.1x on long ones. ParadeDB also builds the index roughly twice as fast.
pg_textsearch has real limitations that disqualify it for some applications: no phrase queries because positions are not stored, OR-only semantics with AND and NOT still planned, no fuzzy matching, and one text column per index.
VectorChord-bm25 markets itself as 3x faster than Elasticsearch. That 3x is a geometric mean across datasets, and the top-1000 QPS pair they actually publish is 112 against 49, or 2.26x. At top-10, which is what most RAG systems actually run, Elasticsearch with concatenated fields reaches 341 QPS against VectorChord's 271.91, making VectorChord about 25% slower. I mention it because that is exactly the kind of benchmark framing you should learn to check, including in this post.
RRF, and the constant nobody questions
Reciprocal Rank Fusion comes from Cormack, Clarke and Büttcher at SIGIR 2009:
RRFscore(d) = Σ 1 / (k + r(d))
Summed over each ranking r, where r(d) is the document's rank in that ranking.
Everyone uses k = 60. Here is the paper's own explanation of where that came from, verbatim:
"k = 60 was fixed during a pilot investigation and not altered during subsequent validation."
It is not a derived optimum. It is a value someone picked during a pilot on TREC data in 2009 and never revisited, which the entire industry then hardcoded into production search.
The paper's results are also less triumphant than the citation pattern suggests. RRF beat Condorcet and the best individual system on TREC Robust, TREC 3 and TREC 5. On TREC 9 it scored .2830 MAP against .3519 for the best individual system, though the authors note that system was human-in-the-loop. Fusion is not free.
RRF is not parameter-free
The best modern analysis is Bruch, Gai and Ingber's "An Analysis of Fusion Functions for Hybrid Retrieval" (arXiv 2210.11934, ACM TOIS 2023). Two findings matter.
First, convex combination with proper normalization beats RRF. Their NDCG@1000:
| Dataset | Lexical | Semantic | Convex + TM norm | RRF (k=60) |
|---|---|---|---|---|
| MS MARCO | 0.309 | 0.441 | 0.454 | 0.425 |
| NFCorpus | 0.268 | 0.296 | 0.327 | 0.312 |
| HotpotQA | 0.682 | 0.520 | 0.699 | 0.675 |
| FEVER | 0.689 | 0.558 | 0.744 | 0.721 |
Second, and this is the one that contradicts folklore: NDCG swings wildly as a function of RRF parameters. Tuning RRF on MS MARCO to separate k values per retriever raised NDCG from 0.425 to 0.451, nearly matching convex combination. Those same parameters dropped HotpotQA to 0.621 against 0.693. So RRF is either untuned and worse, or tuned and brittle. It is not parameter-free, it is parameter-ignored.
Their contribution to the normalization problem is worth stealing. Empirical min-max normalization is computed over the retrieved candidate set, which makes your scores depend on how many candidates you retrieved. That is a query-dependent, non-stationary transform, and it is why naive score fusion behaves erratically. Their fix is theoretical min-max: use the theoretical infimum instead of the empirical minimum. For BM25 that is 0; for cosine similarity it is -1.
My practical read: RRF's real advantage is operational. It needs no score statistics, no per-query normalization pass, and no training data, which makes it a clean single-SQL-statement primitive with no moving parts. Convex combination with theoretical normalization is measurably better when you can afford to tune alpha on in-domain data. And both are dominated by adding a reranker.
Does hybrid actually beat vector-only? Yes, and less than reranking does
The best recent measurement is "From BM25 to Corrective RAG" (arXiv 2604.01733, April 2026), running ten retrieval methods on T2-RAGBench: 23,088 queries over 7,318 financial documents.
| Method | Recall@5 | MRR@3 |
|---|---|---|
| Dense (vector only) | 0.587 | 0.351 |
| BM25 (sparse only) | 0.644 | 0.411 |
| Hybrid RRF | 0.695 | 0.433 |
| Hybrid RRF + Cohere Rerank | 0.816 | 0.605 |
Two things in that table are more interesting than the headline.
BM25 alone beat dense alone by 5.7 points on Recall@5, and by 6.0 points on MRR@3. On documents full of exact identifiers, tickers, and numbers, lexical retrieval simply wins. That is the strongest available rebuttal to "just use embeddings," and it generalizes to any corpus where users search for SKUs, error codes, case numbers, or names.
The reranker contributed more than the fusion did. Fusion added 5.1 points over BM25 alone. Reranking added 12.1 points on top of fusion, and moved MRR@3 by 39.7% relative. If you have budget for exactly one addition to a vector-only pipeline, the evidence says reranker before fusion.
This is single-domain, so do not port the exact magnitudes to open-domain search. But the ordering is a useful prior, and it matches what I have seen when I have actually measured a retrieval stack instead of guessing at it.
Reranking, and the pricing detail that will surprise you
Current models worth knowing: jina-reranker-v3 (0.6B, open weights, 131K context, reported BEIR nDCG@10 of 61.94), mxbai-rerank-large-v2 (1.5B, Apache 2.0), bge-reranker-v2-m3 (0.6B, open weights), Cohere Rerank 4 Pro and 4 Fast, and Voyage rerank-2.5 (32K context, reported +7.94% over Cohere v3.5 across 93 retrieval datasets).
Two architectural notes. jina-reranker-v3 is listwise rather than a cross-encoder: it puts the query and all candidates into one context window and ranks them jointly, which is how a 0.6B model beats a 4B one. And Voyage's rerank-2.5 is instruction-following, reporting +8.13% on domain-specific data from instructions alone.
Now the pricing detail. Cohere's definition:
"A single search unit is defined as one query with up to 100 documents to be ranked."
And their pricing FAQ adds that any document exceeding 500 tokens, counting the query length, is automatically split into chunks, with each chunk counting as an individual document toward the total ranked.
A 2,000-token document costs four documents, not one. Reranking 100 long chunks can silently cost four search units. If you are budgeting reranking at "one search unit per query," and your chunks are 1,500 tokens, your actual bill is triple your estimate. (I would verify current per-search pricing directly with Cohere, incidentally: their pricing page now shows only dedicated Model Vault pricing, and the third-party listings I found conflict with Cohere's historical list price.)
Voyage prices differently and states the formula outright: query tokens times number of documents, plus the sum of tokens in all documents.
Cross-encoder cost is O(k) forward passes in the number of candidates, which is why top-k for reranking is conventionally 20 to 100 and essentially never 1,000. For hosted rerankers it is also a serial network round trip added directly to your p99.
Reranking is not worth it when first-stage recall@k is already near ceiling (you are buying ordering, not coverage), when your p99 budget is under 100 ms and the retrieval is single-hop, when documents are long enough that chunk-splitting multiplies your bill invisibly, or when the corpus is small enough that exact search plus good fusion reaches the same top-10.
Working SQL
Here is a production shape. Read the comments; several of these lines exist because of a specific failure mode.
CREATE EXTENSION IF NOT EXISTS vector; -- 0.8.6
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
content text NOT NULL,
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
embedding vector(1536),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX documents_tsv_idx ON documents USING gin (content_tsv);
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7; -- default is 2
CREATE INDEX documents_embedding_idx ON documents
USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX documents_tenant_idx ON documents (tenant_id);-- SET LOCAL, not SET. See the PgBouncer note below.
SET LOCAL hnsw.ef_search = 100; -- default 40; must be >= your LIMIT
SET LOCAL hnsw.iterative_scan = relaxed_order; -- essential with any WHERE clause
SET LOCAL hnsw.max_scan_tuples = 20000;
WITH semantic_search AS (
SELECT id,
RANK() OVER (ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536)) AS rank
FROM documents
WHERE tenant_id = $3
ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536)
LIMIT 60
),
keyword_search AS (
SELECT id,
RANK() OVER (ORDER BY ts_rank_cd(content_tsv, q) DESC) AS rank
FROM documents, websearch_to_tsquery('english', $2) q
WHERE content_tsv @@ q AND tenant_id = $3
ORDER BY ts_rank_cd(content_tsv, q) DESC
LIMIT 60
)
SELECT d.id, d.content,
COALESCE(1.0 / (60 + s.rank), 0.0) +
COALESCE(1.0 / (60 + k.rank), 0.0) AS rrf_score
FROM documents d
LEFT JOIN semantic_search s ON s.id = d.id
LEFT JOIN keyword_search k ON k.id = d.id
WHERE s.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT 20; -- hand these 20 to a rerankerThe details that break people:
Use a full outer join, or the equivalent. An inner join between the two CTEs silently restricts results to documents found by both retrievers, which discards exactly the documents hybrid search exists to find. I have seen this in vendor documentation. If your "hybrid search" returns fewer results than either retriever alone, this is why.
COALESCE both terms to 0.0, so a document missing from one ranking contributes zero instead of NULL-poisoning the sum.
Use RANK(), not ROW_NUMBER(), so ties get the same reciprocal contribution.
The ::halfvec cast must appear identically in the index expression and the ORDER BY, or the planner will not use the index. This is the number one cause of "why isn't my HNSW index being used."
Directions are mixed. RRF scores are higher-is-better. Every pgvector distance operator is lower-is-better. And <#> returns the negative inner product, because Postgres index scans only order ascending. If you are comparing raw <#> output to cosine similarity and confused about the sign, that is why. pg_textsearch's <@> also returns negative scores.
Under PgBouncer in transaction pooling mode, plain SET is unsafe. hnsw.ef_search and hnsw.iterative_scan are session-level GUCs. In transaction pooling they either leak across clients or get discarded, and either way your tuning is not doing what you think. Use SET LOCAL inside a transaction, or set_config('hnsw.ef_search', '200', true).
Over-retrieve, then fuse. 60 per retriever feeding a final 20 is a reasonable starting shape. Supabase's Matryoshka work used a first pass of eight times the final top-k; three times is a sensible floor.
Use a stored generated tsvector column, not an expression index on to_tsvector(...). The expression form recomputes tokenization on every row that needs a recheck.
Prefer websearch_to_tsquery to plainto_tsquery for user input. It handles quoted phrases, OR, and - negation, and it does not throw on malformed input.
Operational notes worth having in advance
Keep vectors in a narrow side table joined by id. Every HNSW insert traverses and mutates the graph, and a non-HOT update to a row rewrites the tuple and forces index maintenance even when you only touched a non-vector column. Separating the vector column means updates to mutable business fields never touch the graph.
Order your embedding migrations correctly. When you change embedding models, add a new column rather than mutating in place (dimensions usually differ anyway), backfill in batches while the old column keeps serving, and only then build the new HNSW index concurrently. Backfilling into an already-indexed column pays per-row graph insertion; building after backfill uses the parallel bulk path. The difference is routinely an order of magnitude.
REINDEX INDEX CONCURRENTLY is the rebuild primitive. It doubles peak disk and takes a brief exclusive lock only at swap. Building under a new name and renaming in a transaction gives you more control over timing.
Watch quantization as a first-class option. The mixedbread and Hugging Face measurements on a 1024-dimension model remain the clearest numbers: int8 gives 4x memory reduction at roughly 97% performance retained, binary gives 32x at 92.5%, and binary plus a full-precision rescoring pass gives 32x at 96.45%. In pgvector that is an HNSW index on binary_quantize(embedding)::bit(N) with bit_hamming_ops for the shortlist, then an exact reorder on the stored vector. Their cost illustration: 250M embeddings at 1024 dimensions, from $3,623/month to $113.25/month.
Know when to leave. The rough thresholds practitioners converge on: under 1M vectors almost anything works; at 10M, quantization and partitioning become mandatory; at 100M you are looking at 600+ GB before index overhead and should be evaluating alternatives; past 1B, pure Postgres is usually not cost-effective. The other trigger is needing sub-20ms p99 on high-dimensional search under heavy concurrency.
But be honest about why you would stay. The cost argument for Postgres is weaker than it was two years ago, because dedicated vector services moved to serverless pricing. The operational argument is stronger and does not decay: one system, transactional consistency between your rows and your vectors, joins to business data, and no dual-write to keep in sync. That is usually the real reason, and it is a good one.
Related: Retrieval, Measured: where RAG actually breaks and idempotency as a design discipline.
Sources
- pgvector README and CHANGELOG
- pgvector 0.8.6 changelog (PGXN)
- CVE-2026-3172
- Supercharging vector search with pgvector 0.8.0 on Aurora (AWS)
- Self-managed multi-tenant vector search on Aurora (AWS)
- PostgreSQL text search controls
- PostgreSQL text search limitations
- Postgres full-text search vs Elasticsearch (Neon)
- Neon pg_search deprecation
- ParadeDB 0.20.0 release
- pg_textsearch v1.0 announcement
- pg_textsearch technical writeup (Tiger Data)
- Reciprocal Rank Fusion (Cormack et al., SIGIR 2009)
- An Analysis of Fusion Functions for Hybrid Retrieval (arXiv 2210.11934)
- From BM25 to Corrective RAG (arXiv 2604.01733)
- Embedding quantization (Hugging Face / mixedbread)
- Matryoshka embeddings in pgvector (Supabase)
- jina-reranker-v3 (arXiv 2509.25085)
- mxbai-rerank-v2
- Voyage rerank-2.5
- pgvector-python hybrid search example