Search is one of those features that looks trivial until the first user complaint lands and stays trivial-looking until you stop trying to fix it with a single technique. The pattern we have watched dozens of teams walk through is the same: a wildcard ILIKE, then a Postgres tsvector index, then someone reads about embeddings and rewrites the whole thing around pgvector, then the embeddings miss exact-name lookups and they bolt the tsvector index back on. The answer was always "use both".
This piece walks through how we wired hybrid search on the ApyHub catalog — ranking 200+ services and 1,000+ endpoints using lexical and semantic signals fused at query time. The code is short. The interesting part is the fusion logic and the dial choices around it.
01The two halves, briefly
Lexical search ranks documents by how many of the query tokens match, weighted by term rarity. Postgres ships tsvector + tsquery for this and it has been good enough for fifteen years. It nails exact-name queries, it nails acronym-heavy phrases, and it handles operators at zero extra cost.
Semantic search embeds each document and each query into the same vector space, then ranks by cosine similarity. It catches paraphrases, typos, synonyms, and conceptual matches that share no tokens. Cost: it forgets exact spelling, struggles with rare names, and gives you no operator language for free.
If you only had to pick one For a catalog of named services like ours, lexical alone outperforms semantic alone — exact slugs matter more than paraphrase coverage. Hybrid wins because it stops the failure modes of either technique from being load-bearing.
What the data looks like
Each service row carries an embedding produced by our embedding sidecar at publish time, plus a denormalized search_text column that concatenates the title, slug, tags, and the first paragraph of about_md. The lexical half scans search_text; the semantic half scans the embedding. Both live on the same table so there is no join cost.
create extension if not exists vector;
create extension if not exists pg_trgm;
create table services (
id uuid primary key,
slug text not null unique,
display_name text not null,
about_md text not null default '',
tags text[],
-- 384-dim — MiniLM-L6. Swapping the model
-- to a different dim drops + recreates this column.
embedding vector(384),
search_text text not null default ''
);
create index on services
using hnsw (embedding vector_cosine_ops);
create index on services
using gin (search_text gin_trgm_ops);Two indexes, two tools. HNSW for the vector side gives sub-millisecond neighbor lookups at our scale; the GIN trigram index on search_text handles the lexical side. The search_text column is rebuilt by a trigger on every UPDATE — faster than a GENERATED column because Postgres rejects array_to_string as non-immutable in generated expressions.
02Fusion: reciprocal rank, not score-add
The first temptation is to combine the scores: take the lexical score, the cosine similarity, normalize each to [0,1], and add them. Do not. Cosine similarity and BM25 scores live on incompatible axes — the normalizations are noise, the weights are guesswork, and the result is a hyperparameter nightmare that drifts every time the corpus changes.
Rank-based fusion is the cheapest robust fusion. You stop arguing about score scales the moment you stop using scores.
— Cormack, Clarke, Büttcher · "Reciprocal Rank Fusion"
Reciprocal rank fusion (RRF) is one parameter and one query. For each retrieval method, get the top-K documents in rank order. The fused score for a document is the sum, over methods, of 1 / (k + rank), where k is a constant (canonically 60) that softens the head of the distribution so the #1 result does not dominate too aggressively.
Why 60 The original RRF paper landed on 60 empirically. We tuned k between 30 and 100 on our search-log replays; the curve is flat enough that the choice does not matter much past 40. We left it at 60 to match the literature.
The Go implementation
Eighty-seven lines including imports, comments, and the dispatcher. The first half runs the two halves concurrently; the second half fuses the rankings; everything else is plumbing.
package search
import (
"context"
"sort"
)
// SearchHybrid runs the lexical + semantic halves concurrently,
// fuses their rank lists with RRF, and returns the top N IDs.
// k=60 is the RRF dampener; see the post for why.
func SearchHybrid(ctx context.Context, q string, n int) ([]uuid.UUID, error) {
const k = 60
lexCh := make(chan []uuid.UUID, 1)
semCh := make(chan []uuid.UUID, 1)
errCh := make(chan error, 2)
go func() {
ids, err := lexicalTopK(ctx, q, 50)
if err != nil { errCh <- err; return }
lexCh <- ids
}()
go func() {
vec, err := embed(ctx, q)
if err != nil { errCh <- err; return }
ids, err := semanticTopK(ctx, vec, 50)
if err != nil { errCh <- err; return }
semCh <- ids
}()
var lex, sem []uuid.UUID
for i := 0; i < 2; i++ {
select {
case err := <-errCh:
return nil, err
case l := <-lexCh:
lex = l
case s := <-semCh:
sem = s
}
}
scores := map[uuid.UUID]float64{}
for i, id := range lex {
scores[id] += 1.0 / float64(k+i+1)
}
for i, id := range sem {
scores[id] += 1.0 / float64(k+i+1)
}
ids := make([]uuid.UUID, 0, len(scores))
for id := range scores { ids = append(ids, id) }
sort.Slice(ids, func(i, j int) bool {
return scores[ids[i]] > scores[ids[j]]
})
if len(ids) > n { ids = ids[:n] }
return ids, nil
}That is the entire fusion layer. Each top-K helper is a single SQL statement — SELECT id FROM services WHERE search_text % $1 ORDER BY similarity DESC LIMIT 50 for lexical; SELECT id FROM services ORDER BY embedding <=> $1 LIMIT 50 for semantic. Both hit the indexes we built; both come back in single-digit milliseconds at our scale.
03Where it bends
Hybrid search is not free. Three things to know:
- Embedding latency is in the request path. If the embedding sidecar is slow, every search is slow. We cache the embeddings of the last 256 queries in a process-local LRU; that absorbs the repeat-query case without solving the cold-query case.
- Model migrations are awkward. Swap the embedding model and every row needs re-embedding. We version the model in the
embedding_modelcolumn and run a background re-embedder when the version changes; queries during the migration return mixed-model results until the worker catches up. - RRF does not surface "boost this exact match". If a query exactly matches a slug, you almost always want it ranked first regardless of fusion. We special-case that with a
UNION ALLagainst the slug index before fusion. About twelve lines we did not show above.
Do not fuse three things before measuring two A common trap: adding a third signal (tag-match boost, popularity, recency) on top of lexical+semantic before you have measured lexical+semantic alone. Each additional signal is another dimension your dial-tuning has to cover. Start with two, ship, measure, then add.
Cost and latency in production
Numbers from the last seven days of catalog search traffic. The embedding sidecar is the dominant cost; the postgres calls are noise.
| Stage | p50 | p95 | p99 | Notes |
|---|---|---|---|---|
| Embed query | 14 ms | 48 ms | 112 ms | MiniLM-L6, batched |
| Lexical top-50 | 2.1 ms | 5.4 ms | 9 ms | GIN trigram |
| Semantic top-50 | 1.4 ms | 3.2 ms | 6 ms | HNSW |
| RRF fusion | 0.2 ms | 0.5 ms | 0.8 ms | ~100 candidates |
| Total uncached | 18 ms | 56 ms | 120 ms | — |
| Total cached | 4 ms | 9 ms | 14 ms | LRU hit ~38% |
04What is next
The next moves on our roadmap, in order of how likely we are to actually do them:
- Per-query intent classification — if the query looks like a slug (lowercase, hyphenated, ≤2 tokens), skip the embed call entirely and return lexical-only. Saves the embedding round-trip on the case where it adds nothing.
- Tag boosts as a rank-list, not a score-add — when the query contains a known tag word, add the tag services as a third RRF input. Stays in rank-fusion territory; no score normalization required.
- Cross-encoder rerank on the top 20 — if precision on the head matters more than recall on the tail, an MPNet cross-encoder over the top 20 RRF results lifts precision@5 by ~9 points. Cost: ~40ms p95.
Resist "we should also try X" Once a search ranking works, every team member has a feature that would obviously make it better. Most of them will not, individually, move the needle past the noise floor. Measure-first. Ship-the-measurement-first. The system you have is better than the one you wished for.