Lesson 15 / 28

Hybrid Search: Full-Text Plus Vectors

Combine keyword relevance with vector similarity in one query.

Two signals in one ORDER BY

Vector similarity understands meaning; keyword search nails exact terms (names, codes, rare words). Hybrid search uses both. In PostgreSQL you can add a tsvector column (here a generated column) with a full-text index, rank with ts_rank, and combine with vector similarity in the same ORDER BY using a weighted sum, or run both queries and merge the lists with Reciprocal Rank Fusion in the application or in SQL. Dedicated engines usually offer hybrid queries with sparse vectors (BM25-style) or built-in fusion. Choose weights by measuring recall on your labelled questions; scores from the two systems are on different scales, which is why rank-based fusion is popular.

Vector similarity plus a keyword boost, run

I ran this SQL on PostgreSQL 16 with the pgvector extension, version 0.8.6, in a Docker container. For a query vector between "leave" and "travel", the vector scores of documents 1 and 3 are close (0.781 and 0.776). Adding the full-text rank for the word "hotels", weighted by 5, lifts the travel-and-hotels document to first place.

ALTER TABLE docs ADD COLUMN IF NOT EXISTS tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
SELECT id, body,
       round((1 - (embedding <=> '[0.5,0.5,0]'))::numeric, 3) AS vec_sim,
       round(ts_rank(tsv, plainto_tsquery('english', 'hotels'))::numeric, 3) AS keyword_rank
FROM docs
WHERE tenant = 'acme'
ORDER BY (1 - (embedding <=> '[0.5,0.5,0]')) + 5 * ts_rank(tsv, plainto_tsquery('english', 'hotels')) DESC
LIMIT 3;

Output:

 id |       body        | vec_sim | keyword_rank 
----+-------------------+---------+--------------
  3 | travel and hotels |   0.776 |        0.061
  2 | old leave policy  |   0.851 |        0.000
  1 | leave policy 2025 |   0.781 |        0.000
(3 rows)

Reciprocal Rank Fusion, run

I ran this plain-Python (standard library only) example. Document a ranks first in the vector list and third in the keyword list; c is third and first. RRF puts a first and c second, then b, e, d. It uses only ranks, so the incompatible score scales do not matter.

def rrf(*rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            scores[doc] = scores.get(doc, 0) + 1 / (k + rank)
    return [d for d, _ in sorted(scores.items(), key=lambda x: -x[1])]

vector_hits = ["a", "b", "c", "d"]
keyword_hits = ["c", "e", "a"]
print("RRF:", rrf(vector_hits, keyword_hits))

Output:

RRF: ['a', 'c', 'b', 'e', 'd']

Normalise or fuse by rank

Adding raw keyword and vector scores directly is fragile. Normalise them, or use rank-based fusion such as RRF.

Quick check: Why is rank-based fusion popular for hybrid search?

  • Keyword and vector scores are on different scales; ranks are comparable
  • It needs no ranking
  • It removes the need for vectors
  • It makes queries slower on purpose
Answer

Keyword and vector scores are on different scales; ranks are comparable — RRF avoids calibrating incompatible scores.