C.W.K.
Stream
Lesson 01 of 05 · published

BM25 Isn't Dead — Pair It With Vectors

~22 min · hybrid, bm25, keyword

Level 0Scout
0 XP0/41 lessons0/10 achievements
0/120 XP to next level120 XP to go0% complete

Why pure vector search loses some queries

Embeddings are great at meaning but mediocre at tokens. They will not reliably rank a chunk that contains the exact error code 'ERR_CONNECTION_REFUSED' above a chunk that paraphrases the concept. Product names, SKUs, version numbers, and rare technical terms tend to live in the surface form — exactly what BM25 (a 1994-vintage probabilistic keyword scorer) is built for.

What BM25 actually does

For each query term, BM25 scores a document by term frequency (more occurrences = higher), inverse document frequency (rare terms count more), and a length normalization. The result is a ranking that loves exact matches, punishes verbosity, and ignores meaning. The opposite failure mode of vector search.

The cheapest hybrid

  1. Run vector search → top-N candidates.
  2. Run BM25 → top-N candidates.
  3. Merge ids, take the union, then re-rank by either a fusion score or a cross-encoder.

Two retrievers, one merge step. That alone often beats vector-only retrieval by 10–20% on factoid queries.

Code

BM25 with rank-bm25 (pure Python)·python
from rank_bm25 import BM25Okapi

corpus = [doc.lower().split() for doc in documents]
bm25 = BM25Okapi(corpus)

def bm25_search(query: str, k: int = 10):
    scores = bm25.get_scores(query.lower().split())
    top = scores.argsort()[::-1][:k]
    return [(int(i), float(scores[i])) for i in top]
Hybrid in Postgres (pgvector + tsvector)·sql
-- Add a full-text column once
ALTER TABLE chunks ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('english', text)) STORED;
CREATE INDEX chunks_tsv_idx ON chunks USING gin(tsv);

-- Hybrid query: union both rankings then re-rank
WITH vec AS (
    SELECT id, 1 - (embedding <=> $1) AS vscore
    FROM   chunks
    ORDER BY embedding <=> $1
    LIMIT 50
),
bm AS (
    SELECT id, ts_rank_cd(tsv, plainto_tsquery('english', $2)) AS bscore
    FROM   chunks
    WHERE  tsv @@ plainto_tsquery('english', $2)
    ORDER BY bscore DESC
    LIMIT 50
)
SELECT  c.id,
        COALESCE(vec.vscore, 0) AS vscore,
        COALESCE(bm.bscore,  0) AS bscore,
        c.text
FROM    chunks c
LEFT JOIN vec ON vec.id = c.id
LEFT JOIN bm  ON bm.id  = c.id
WHERE   vec.id IS NOT NULL OR bm.id IS NOT NULL;

External links

Exercise

Build a vector-only retriever and a BM25 retriever over the same 1000 documents. Run 20 queries. For each query note the top-5 from each retriever and the union. Manually mark relevance. Compare hit rate of vector-only vs union — that delta is your hybrid budget.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.