AI DevelopmentTutorial

Hybrid Search and Reranking Inside Postgres: Measure Recall, Don’t Guess

October 14, 2026
14 min read
Two streams of light merging into one ranked list inside a database cylinder
Share:

Every RAG team hits the same wall. Dense vectors find the paraphrase but miss the exact error code, SKU or function name. Keyword search nails the exact token and returns nothing when the user words the question differently. Each retriever fails in a different place, and users notice both failures.

In this tutorial I build the fix inside the Postgres you already run: a lexical leg (built-in tsvector, or BM25 through pg_textsearch / pg_search), a pgvector leg, Reciprocal Rank Fusion with k=60 in one SQL statement, a cross-encoder rerank, and a small harness that measures recall@k before and after. Honest scope: the SQL and API calls follow the official docs linked in Sources, and I did not benchmark anything for this article — the numbers are yours to generate.

1. The Problem: Vectors Miss Exact Terms, Keywords Miss Meaning

Ask a dense retriever for ’error E1042 on checkout’ and it returns pages about payment failures in general: semantically close, practically useless. Ask a keyword index ’why was my card declined?’ when the document says ’transaction rejected by the issuer’ and it returns nothing. Dense search generalises meaning and blurs identifiers; lexical search nails identifiers and has no idea about paraphrase. Real corpora — docs, tickets, code, contracts — contain both kinds of query every day.

The Most Common Mistake

Bolting a reranker (or a bigger embedding model) onto a retrieval stack nobody has measured. Without a labelled query set you cannot tell whether the change helped, whether the misses happen in retrieval or in ordering, or what it costs in latency. Build the measurement first: it is about 60 lines of Python and it is section 6 of this post.

The fix has three moving parts, and all of them fit inside Postgres: two cheap retrievers that fail differently, a rank-based fusion that needs no score calibration, and an expensive cross-encoder that only ever sees a short candidate list. I deliberately skip pgvector basics and chunking here — the vector databases guide and the chunking guide in the related articles cover them — and start where they stop.

2. Minimum Concepts Before Touching Code

Six ideas cover almost everything below. Get these straight and the SQL and Python read like plain English.

🔤

Lexical leg

exact terms and identifiers

Postgres full-text search (tsvector + GIN + ts_rank) or a BM25 index from an extension. It wins on error codes, SKUs, function names and rare words, and it is blind to paraphrase.

🧭

Dense leg

meaning, not spelling

pgvector nearest-neighbour search over embeddings with an HNSW index. It wins on paraphrase and cross-lingual matches, and it blurs exact identifiers.

🔀

Reciprocal Rank Fusion

score = sum of 1 / (k + rank)

Each list contributes by position, not by score, so cosine distance and BM25 never need to share a scale. The original 2009 paper fixed k = 60 in a pilot, found it near-optimal and the choice not critical.

🎯

Cross-encoder reranker

reads query and document together

An embedding model encodes query and document separately; a cross-encoder scores each (query, document) pair jointly. More accurate, far more expensive per pair, so it only runs on the fused top-N.

📦

Candidate depth

the hard ceiling

A reranker can only reorder what retrieval returned. The per-leg LIMIT caps your recall: ParadeDB’s docs suggest 100 to 200 candidates per leg for a top-10 answer as a starting point.

📏

recall@k and MRR

the only honest scoreboard

recall@k is the share of relevant documents found in the top k. MRR is the mean of 1 / rank of the first relevant hit. Measure both before and after every change.

The mental model to keep

Retrieve wide and cheap, rank narrow and expensive. Two fast legs maximise the chance that the right chunk is somewhere in the list; the reranker spends its budget only where it matters. If candidate recall is low, no reranker can save you. If it is high and the top results are still wrong, that is exactly the reranker’s job.

3. Pick the Lexical Leg: tsvector, pg_textsearch or pg_search

Postgres gives you three realistic options for the keyword side, and the right one depends less on features than on what your hosting lets you install. I checked the facts below against each project’s own docs in October 2026; these projects move fast, so verify before you pin a version.

🧱

Built-in tsvector

works on every Postgres

to_tsvector, a GIN index and ts_rank / ts_rank_cd. No extension, no restart. The Postgres docs are explicit that the ranking functions ’do not use any global information’, so this is not BM25: no corpus-level IDF. Still the right first baseline.

🐯

pg_textsearch

BM25 · PostgreSQL License

Tiger Data’s extension: a bm25 index plus the <@> operator. Its README lists v1.5.0 as the latest version, v1.0.0 as the first GA release, and supports Postgres 17 and 18. It must be in shared_preload_libraries, so your provider has to ship it.

🧬

pg_search (ParadeDB)

BM25 and more · AGPL-3.0

ParadeDB’s extension, v0.26.1 on GitHub as of October 8, 2026. The community edition is AGPL-3.0. Since 0.25.0 the index is created with USING paradedb (bm25 stays as an alias) and pgvector is a prerequisite.

What is realistic on managed Postgres

Here is what I could confirm. Tiger’s docs call pg_textsearch production-ready on Tiger Cloud, and a Tiger Data press release dated September 21, 2026 says it is in public preview on Google Cloud AlloyDB and Cloud SQL (a vendor claim: check Google’s own docs). ParadeDB documents self-hosted clusters, ParadeDB Cloud and BYOC, and Neon’s docs say Neon retired pg_search on September 21, 2026 and points to its own tsvector-based BM25 index, lakebase_text. For RDS, Supabase and everything else I could not confirm anything, so ask the database itself before you design around an extension.

Ask your database what it can actually install

SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('vector', 'pg_textsearch', 'pg_search');

My default for 2026

Start with built-in tsvector, measure with the harness in section 6, and move to BM25 only if the lexical leg turns out to be the weak link. Keep the lexical leg inside one CTE so swapping it is a ten-line change, not a migration.

4. Step 1 — Schema and the Fused Query (One SQL Statement)

One table holds the text, the embedding and a generated tsvector. Both indexes are standard: GIN for the lexical leg, HNSW with cosine ops for the dense leg. The 384 dimensions match the sentence-transformers model used in the Python steps; change it to match yours.

SQL — table and indexes

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id        bigserial PRIMARY KEY,
    doc_id    text NOT NULL,
    content   text NOT NULL,
    embedding vector(384),
    tsv       tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
);

CREATE INDEX chunks_tsv_idx ON chunks USING GIN (tsv);
CREATE INDEX chunks_emb_idx ON chunks USING hnsw (embedding vector_cosine_ops);

The fusion lives in one statement, adapted from the RRF example in pgvector’s own Python repo. Each leg is an ordered LIMIT subquery, so the index does the work; ROW_NUMBER turns position into rank; a FULL OUTER JOIN sums weight / (k + rank) with COALESCE for chunks that only one leg returned. The %(name)s placeholders are psycopg named parameters. I use k = 60 and equal weights as starting points, not as conclusions.

SQL — hybrid.sql (psycopg named parameters)

WITH semantic AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY dist, id) AS rank
    FROM (
        SELECT id, embedding <=> %(embedding)s AS dist
        FROM chunks
        ORDER BY embedding <=> %(embedding)s
        LIMIT %(n)s
    ) s
),
lexical AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, id) AS rank
    FROM (
        SELECT id, ts_rank_cd(tsv, q) AS score
        FROM chunks, websearch_to_tsquery('english', %(query)s) AS q
        WHERE tsv @@ q
        ORDER BY score DESC, id
        LIMIT %(n)s
    ) l
),
fused AS (
    SELECT COALESCE(s.id, l.id) AS id,
           COALESCE(%(w_dense)s / (%(k)s + s.rank), 0.0)
         + COALESCE(%(w_lex)s   / (%(k)s + l.rank), 0.0) AS score
    FROM semantic s
    FULL OUTER JOIN lexical l ON s.id = l.id
)
SELECT c.id, c.doc_id, c.content, f.score
FROM fused f
JOIN chunks c ON c.id = f.id
ORDER BY f.score DESC, c.id
LIMIT %(limit)s;

Two details that matter

First, ordered LIMIT inside and rank outside: the HNSW or GIN index drives the inner query and ranks are assigned over at most n rows. Second, the weights: ParadeDB’s docs recommend starting equal and tuning against a labelled query set rather than by intuition (their own example lowers the vector weight to 0.7). Add a deterministic tie-breaker such as id wherever a LIMIT can cut through tied scores.

Why the lexical leg returns less than you expect

websearch_to_tsquery joins plain, unquoted terms with & (AND), so a long natural-language question only matches chunks that contain every non-stopword term. A common idiom is to relax it to OR with replace(websearch_to_tsquery('english', q)::text, '&', '|')::tsquery. That idiom is not from the Postgres docs, so test it with the harness. ParadeDB’s ||| operator, by contrast, is documented as matching one or more of the query terms.

When the harness says the keyword side is the weak link, swap only the lexical CTE. This is the pg_textsearch version, following its README: create the bm25 index with a text_config, then order by the <@> operator, which returns negative BM25 scores, so ascending order puts the best match first.

SQL — BM25 lexical leg with pg_textsearch

-- after adding pg_textsearch to shared_preload_libraries and restarting
CREATE EXTENSION pg_textsearch;

CREATE INDEX chunks_bm25_idx ON chunks
    USING bm25 (content) WITH (text_config = 'english');

-- replaces the "lexical" CTE in hybrid.sql
lexical AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY score, id) AS rank
    FROM (
        SELECT id, content <@> to_bm25query(%(query)s, 'chunks_bm25_idx') AS score
        FROM chunks
        ORDER BY content <@> to_bm25query(%(query)s, 'chunks_bm25_idx')
        LIMIT %(n)s
    ) l
),

Sanity-check each leg on its own

Run the lexical leg alone with a nonsense term (how many rows come back?) and with a rare identifier (is the right chunk in the top 3?). The pg_textsearch README documents negative scores, but not that empty-match edge case, so I will not assume it. Also read its limitations: BM25 statistics include rows hidden by row-level security, statistics are local to each partition, and combining a boolean filter with BM25 ranking is not yet a single index scan.

5. Step 2 — Cross-Encoder Rerank (Local or Hosted)

Fetch the top-N from SQL, score every (query, chunk) pair with a cross-encoder, sort by that score. The rerank function takes the rows the SQL returned, so you can swap models without touching retrieval. The local version below uses BAAI/bge-reranker-v2-m3 through FlagEmbedding, following its model card.

Python — retrieval.py (hybrid SQL + local rerank)

from pathlib import Path

import psycopg
from FlagEmbedding import FlagReranker
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer

conn = psycopg.connect("dbname=hybrid_demo", autocommit=True)
register_vector(conn)

embedder = SentenceTransformer("multi-qa-MiniLM-L6-cos-v1")  # 384 dims, as in the schema
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

HYBRID_SQL = Path("hybrid.sql").read_text()


def hybrid(query, n=50, k=60, limit=50, w_dense=1.0, w_lex=1.0):
    params = {
        "embedding": embedder.encode(query), "query": query,
        "n": n, "k": k, "limit": limit, "w_dense": w_dense, "w_lex": w_lex,
    }
    return conn.execute(HYBRID_SQL, params).fetchall()  # (id, doc_id, content, score)


def rerank(query, rows, top_k=10):
    pairs = [[query, content] for _, _, content, _ in rows]
    scores = reranker.compute_score(pairs, normalize=True)  # sigmoid -> 0..1
    if isinstance(scores, float):  # a single pair returns a scalar
        scores = [scores]
    order = sorted(range(len(rows)), key=lambda i: scores[i], reverse=True)
    return [rows[i] for i in order[:top_k]]

Which model? Both open options below are Apache-2.0 on their Hugging Face cards, and both hosted APIs expose the same shape: send a query and a list of texts, get back indexes and relevance scores. Treat the sizes and limits as the docs state them today and re-check before you commit.

🧠

bge-reranker-v2-m3

Apache-2.0 · 0.6B · multilingual

Local model. Raw output is a logit; normalize=True maps it to 0–1 with a sigmoid. The card’s own transformers example truncates inputs at 512 tokens, so keep chunks around that size.

🐉

Qwen3-Reranker 0.6B / 4B / 8B

Apache-2.0 · 32k context · 100+ languages

Local, instruction-aware: you can pass a task instruction. Needs transformers 4.51.0 or newer. Bigger sizes cost more per pair, so start with 0.6B and let the harness justify going up.

🟣

Cohere Rerank

hosted · POST /v2/rerank

Docs list rerank-v4.0-pro, rerank-v4.0-fast and rerank-v3.5. Parameters include top_n and max_tokens_per_doc (default 4096); the docs recommend at most 1,000 documents per request.

⛵

Voyage rerank

hosted · 32k context

Current models are rerank-3 and rerank-3-lite; rerank-2.5 and rerank-2.5-lite are listed as older, and they are the ones the docs describe as accepting instructions. Up to 1,000 documents, and total tokens (query tokens x documents + document tokens) must stay under 600K.

Python — drop-in alternatives (same signature)

# Local: Qwen3-Reranker through sentence-transformers (needs transformers>=4.51.0)
from sentence_transformers import CrossEncoder

qwen = CrossEncoder("Qwen/Qwen3-Reranker-0.6B")

def rerank_qwen(query, rows, top_k=10):
    scores = qwen.predict([(query, r[2]) for r in rows])
    order = sorted(range(len(rows)), key=lambda i: scores[i], reverse=True)
    return [rows[i] for i in order[:top_k]]


# Hosted: Cohere Rerank v2 API (the client reads the API key from the environment)
import cohere

co = cohere.ClientV2()

def rerank_cohere(query, rows, top_k=10):
    resp = co.rerank(
        model="rerank-v4.0-pro",  # or rerank-v4.0-fast / rerank-v3.5
        query=query,
        documents=[r[2] for r in rows],
        top_n=top_k,
    )
    return [rows[r.index] for r in resp.results]


# Hosted: Voyage rerank (uses the VOYAGE_API_KEY environment variable)
import voyageai

vo = voyageai.Client()

def rerank_voyage(query, rows, top_k=10):
    out = vo.rerank(query, [r[2] for r in rows], model="rerank-3", top_k=top_k)
    return [rows[r.index] for r in out.results]

Local vs hosted: decide with data, not with taste

Hosted rerankers need no GPU, but your chunks leave your infrastructure, so check each vendor’s data terms first and always wrap the call in a timeout with a fallback to the fused order. Local Apache-2.0 models keep data in-house and give you the latency profile, at the price of running them. The work per query grows with the number of candidates and their length either way (Voyage even defines its token budget as query tokens x documents + document tokens), which is one more reason to rerank 30 to 100 chunks, not 1,000.

6. Step 3 — Measure recall@k Before and After

Without this step everything above is vibes. Write 50 to 100 real queries (from search logs, support tickets, or the people who know the corpus), and for each one list the doc_ids that actually answer it. The chunking guide in the related articles shows how to build such a set; here it is just a JSONL file.

gold.jsonl — one labelled query per line (format)

{"query": "error E1042 on checkout", "relevant": ["doc-0172"]}
{"query": "how do I rotate refresh tokens?", "relevant": ["doc-0031", "doc-0032"]}

Python — eval.py: four strategies, same queries

import json
import statistics
import time

from retrieval import conn, embedder, hybrid, rerank

N = 50  # candidates per leg and fused list length
KS = (5, 10, N)  # recall@N is the candidate ceiling

DENSE_SQL = """
SELECT id, doc_id, content, 0.0 AS score FROM chunks
ORDER BY embedding <=> %(embedding)s LIMIT %(n)s
"""
LEXICAL_SQL = """
SELECT id, doc_id, content, ts_rank_cd(tsv, q) AS score
FROM chunks, websearch_to_tsquery('english', %(query)s) AS q
WHERE tsv @@ q ORDER BY score DESC LIMIT %(n)s
"""


def dense(q):
    return conn.execute(DENSE_SQL, {"embedding": embedder.encode(q), "n": N}).fetchall()


def lexical(q):
    return conn.execute(LEXICAL_SQL, {"query": q, "n": N}).fetchall()


STRATEGIES = {
    "dense": dense,
    "lexical": lexical,
    "hybrid RRF": lambda q: hybrid(q, n=N, limit=N),
    "hybrid + rerank": lambda q: rerank(q, hybrid(q, n=N, limit=N), top_k=N),
}


def doc_order(rows):  # chunks -> unique doc_ids in rank order
    seen, out = set(), []
    for _, doc_id, _, _ in rows:
        if doc_id not in seen:
            seen.add(doc_id)
            out.append(doc_id)
    return out


gold = [json.loads(line) for line in open("gold.jsonl", encoding="utf-8")]
hybrid(gold[0]["query"])  # warm-up: model load and caches must not count

for name, fn in STRATEGIES.items():
    recall, rr, lat = {k: 0.0 for k in KS}, 0.0, []
    for ex in gold:
        t0 = time.perf_counter()
        docs = doc_order(fn(ex["query"]))
        lat.append((time.perf_counter() - t0) * 1000)
        rel = set(ex["relevant"])
        for k in KS:
            recall[k] += len(rel & set(docs[:k])) / len(rel)
        first = next((i for i, d in enumerate(docs, 1) if d in rel), None)
        rr += 1.0 / first if first else 0.0
    n = len(gold)
    p95 = statistics.quantiles(lat, n=20)[18]  # needs at least 2 queries
    cols = " ".join(f"recall@{k}={recall[k] / n:.3f}" for k in KS)
    print(f"{name:16} {cols} MRR={rr / n:.3f} p50={statistics.median(lat):.0f}ms p95={p95:.0f}ms")

Run it once now to get your dense-only baseline, then again after each change. This is the output shape you should expect. I am deliberately leaving the numbers as placeholders: any figure I printed here would be invented, and yours depend on your corpus, chunks, embedder and hardware.

Expected output shape (placeholders, not results)

dense            recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
lexical          recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
hybrid RRF       recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
hybrid + rerank  recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms

How to read the table

  • • recall@50 of hybrid RRF is your ceiling. If it is low, fix retrieval (candidate depth, text config, chunking, embedder): the reranker cannot recover what was never fetched.
  • • Hybrid no better than dense? Check the lexical leg on its own and the AND semantics of the tsquery before blaming RRF.
  • • Reranking lifts MRR but doubles p95? That is a trade-off to make explicitly: lower N, use a fast or lite model, or rerank only when the fused scores are close.
  • • Sweep n (20, 50, 100, 200), then k (20, 60, 100), then the weights, one at a time, and keep a log of the runs.

Small gold sets are noisy

With 50 queries a difference of two or three points can be noise. Look at per-query wins and losses, not only the averages, grow the set from real failures, and never tune k and weights on the same queries you use to report the final result.

7. Operating It: Latency, Filters and Index Hygiene

The query path is the easy part. Here is the routine that keeps it honest once real traffic and real data changes arrive:

🔁 Every query (milliseconds)

  1. 1. Give the rerank step a time budget and fall back to the fused order on timeout: retrieval must never depend on the reranker being up
  2. 2. Filtered queries (tenant, language, ACL): pgvector applies filters after the index scan, so a filter matching 10% of rows returns only about 4 of 40 candidates on average; enable iterative scans (hnsw.iterative_scan = relaxed_order) and re-check recall
  3. 3. Log the rank each leg gave the chunk that was finally used: it is the cheapest dataset you will ever get for tuning k and the weights

🧪 Every deploy (CI, minutes)

  1. Recall gate: run eval.py on the gold set and fail the build when recall@10 or MRR drops below the last accepted baseline
  2. Plan check: EXPLAIN both legs and confirm the GIN, HNSW or BM25 index is used; on small tables the planner may choose a sequential scan (pg_textsearch’s README suggests SET enable_seqscan = off to test the index plan)
  3. Model pin: pin the embedder and reranker ids or versions; a silent model change moves every number in the harness

🧹 Every corpus or config change

  1. Re-embed together: a new embedding model means re-embedding every row and rebuilding the HNSW index; never mix vectors from two models in one column
  2. Text config parity: the to_tsvector or text_config used at index time must match the one used at query time; for Spanish content use a Spanish configuration and measure it separately
  3. Index hygiene: with pg_textsearch, create the index after bulk loads and REINDEX in a quiet window if cold-cache latency degrades on update-heavy tables (both from its README); re-run the harness after any re-chunking

8. Common Errors (All of Them Fixable in an Afternoon)

Most hybrid-search disappointments trace back to one of these buckets. The left column is what working stacks do; the right column is what makes retrieval quietly worse.

✅ Do this

  • • Fuse by rank (RRF), never by adding raw cosine distance to a BM25 or ts_rank score
  • • Fetch deep candidate lists per leg and rerank only the fused top-N
  • • Add a deterministic tie-breaker (id) to every ORDER BY ... LIMIT
  • • Use the same chunk text and the same text search configuration for indexing and querying
  • • Deduplicate by document before computing recall and before building the LLM context
  • • Keep the gold set in version control and gate deploys on it

❌ Not this

  • • Averaging a similarity with a BM25 score and calling it hybrid
  • • Adding a reranker before measuring the recall of the candidate list
  • • Reranking 1,000 chunks per query because the API allows it
  • • Assuming plainto_tsquery or websearch_to_tsquery behave like OR on long questions
  • • Comparing strategies on ten hand-picked queries and shipping the winner
  • • Failing the whole request when the hosted reranker times out

Golden rule

Retrieval decides what is possible, fusion decides what is likely, reranking decides what is shown. Measure the first, tune the second, and spend latency on the third only where the harness proves it pays.

Conclusion

You now have the full loop inside Postgres: a lexical leg (built-in tsvector, or BM25 through pg_textsearch or pg_search), a pgvector leg, Reciprocal Rank Fusion with k = 60 in one SQL statement, a cross-encoder rerank that is a one-function swap between local Apache-2.0 models and hosted Cohere or Voyage, and a harness that turns ’it feels better’ into recall@k and MRR.

This week: write 50 labelled queries, run eval.py on today’s dense-only retrieval to get the baseline, then add one thing at a time (lexical leg, fusion, reranker) and keep only what the numbers justify. One last honesty note: I did not run benchmarks for this article, so every claim about quality is something your own harness has to confirm.

Tutorial Cheat Sheet

Retrieve

  • • tsvector first, BM25 if measured
  • • pgvector HNSW, cosine ops
  • • 100–200 candidates per leg

Fuse + rerank

  • • RRF, k = 60, equal weights
  • • cross-encoder on fused top-N
  • • timeout + fallback order

Measure

  • • 50+ labelled queries
  • • recall@k, MRR, p95
  • • gate every deploy on it

Sources

Diego Rodriguez

Diego Rodriguez

Senior Full-Stack & AI Engineer

Diego has 10+ years of experience building production-grade AI-powered applications, from LLM orchestration and RAG pipelines to ML-driven risk detection and algorithmic trading systems.

Learn more about Diego →