How I Added Hybrid Search to My Site (Keyword + Semantic, Merged)
My search bar used to run keyword search and only fall back to semantic search when that found nothing. Now it runs both on every query and merges them into one ranked list.
Ingredients
- Claude Code — terminal-based AI that wrote the SQL and the test harness ($200/yr)
- Supabase with pgvector — the Postgres database that already held the site’s embeddings (free tier)
- Postgres full-text search — built into Postgres, no extension needed (free)
- PGlite — a full Postgres that runs inside a Node script, used to test everything on my laptop before touching the real database (free)
Where Search Stood Before
In April I added semantic search to the site’s search bar. That post covers the difference between keyword search and semantic search, so here’s the short version: keyword search finds the words you typed, and semantic search finds what you meant. Keyword search is great with names (“Schwab,” “cron”) and blind to meaning. Semantic search understands that a schnauzer is a dog, but it’s shaky on names and acronyms it never learned.
I didn’t pick one. I stacked them: keyword matching ran first, instantly, in the browser, and only if it found nothing did the site ask for semantic results. That worked well enough that I stopped thinking about it, until I looked closely at what the keyword step was actually doing.
Why “One, Then the Other” Wasn’t Enough
- Letters, not words. The keyword step looked for the typed letters anywhere in a title or description. “rag” matched the word “storage” in my Gmail post, so a search for RAG put an email post above the post about my RAG chatbot. “ai” matched “email” and “failed.”
- No ranking. Keyword matches came back in whatever order they sat in the index. Search “oauth” and the Gmail post came first, above the post with “OAuth” in its title.
- All or nothing. One keyword hit meant semantic search never ran at all. A single weak match could hide five strong ones.
How Hybrid Search Works: Rank Twice, Then Merge
Hybrid search runs both searches on every query and merges the two ranked lists. The tricky part is the merge. Keyword scores come out on one scale and semantic similarity on another. You can’t just add them; it would be like adding a GPA to an SAT score.
The standard fix is Reciprocal Rank Fusion (RRF), and it’s almost insultingly simple: ignore the scores and use only each result’s position. A result at position r on a list earns 1 / (60 + r). Add up what each result earned from both lists, sort, done. The 60 is the convention from the original RRF paper; it keeps the gap between #1 and #2 from being enormous.
Semantic search alone put the push alerts post first for “oauth.” The Schwab post ranked near the top on both lists, so it wins the merge.
That’s the whole idea: results that both methods agree on rise to the top, and a result that only one method finds still gets a vote.
🔧 Developer section: the Postgres side
- A generated full-text column on the table that already holds the embeddings, built from the title (weighted higher) and the description. Because it’s generated, the existing script that writes embeddings keeps it current with no changes.
- A GIN index on that column, the standard index for full-text search.
- One SQL function with three steps: a keyword list ranked with
ts_rank_cd, a semantic list ranked by cosine distance, and afull outer jointhat adds up the two RRF shares. The outer join matters: a result on only one list still counts. - Each list pulls more candidates than the final result count, so something ranked lower on one side can still climb into the results on the strength of the other.
- The keyword side joins the query’s words with OR, not AND. Postgres’s default parser requires every word to match, which is too strict for a small site;
ts_rank_cdalready ranks pages that match more words higher. - The search bar still shows instant matches from the local index while you type, then swaps in the hybrid ranking a moment later. If the hybrid function is ever unavailable, the server quietly falls back to semantic-only results, so search never goes blank.
Testing It Before Touching the Real Database
I didn’t want to learn whether this worked by shipping it. So I copied the site’s real search data, every page with its real embedding, into PGlite, a full Postgres that runs inside a Node script. Same SQL, same pgvector, same embedding model for the queries. Then I ran 27 queries through three setups: the old cascade, semantic only, and hybrid.
The test caught a real bug on its first run. “firewall” scored zero on the keyword side even though a post’s description contained the word. The cause: the query was reduced to its root word (“firewall” → “firewal”), then handed to a function that reduced it again (“firewal” → “firew”), which matched nothing. A one-line fix I would never have spotted by eye.
| Query | Old cascade — top result | Hybrid — top result |
|---|---|---|
| “rag” | Gmail client post (for the “rag” in “storage”) | The Ask Goose RAG chatbot post |
| “oauth” | Gmail MCP post | Schwab OAuth post |
| “self-hosted” | Gmail MCP post (first page containing the phrase) | The push alerts post (second on both lists) |
| “parking” | The 3D model follow-up | The original parking tracker post |
| “ai” | Ask Goose, then posts matching the “ai” inside words like “email” | AI content pipeline, Ask Goose, TL;DR AI summaries |
| “the” | Six unrelated pages | One weak semantic match (About) |
Of 27 test queries, the top result changed on 7. On the other 20, hybrid agreed with the old search, which is what you want from a change like this.
Seven out of 27 isn’t dramatic, and I think that’s the honest result. On a small site, most queries have one obvious answer and any reasonable search finds it. Hybrid search earns its keep on the edges: short acronyms, names the embedding model doesn’t know, and queries where the old search found something and so never looked for the better thing.
What Hybrid Search Didn’t Fix
It isn’t always better. For “linux server security,” semantic search alone put my home-server security guide first. Hybrid put the headless Linux setup post first, because “Linux” and “server” are in its title, and the security guide second. Both are reasonable answers, but it’s a real case where the keyword vote pulled a slightly worse result up.
“pm” still returns nothing. Neither does “llm,” on the website of a product manager who writes mostly about LLMs. Keyword search can’t find “pm” because my pages say “product manager,” and semantic search can’t connect two letters to that phrase. Merging two empty lists still gives you an empty list. The fix there isn’t a better algorithm; it’s a small synonym list, or descriptions that use the words people actually type.
One shared word gets a full vote. Because RRF only looks at position, a post that matches a single query word can ride that into the results. “game theory” now shows my cron automation post third, because its description mentions game bots. It sits below both Numerator results, but it’s the trade-off: the same property that rescues “oauth” also lets in the occasional stray.
Before shipping a search change, run a fixed list of queries against a copy of your real data, old versus new. It turns “feels better” into “changed 7 of 27, here’s which ones,” and in my case it caught a bug that would have quietly broken keyword search for any word that gets shortened twice.
Don’t. If your content has names, product terms or acronyms (almost all content does), semantic search alone will miss some of them. If your readers describe things in their own words, keyword search alone will miss those. Postgres can do both in one query, and the merge is a single line of arithmetic.