---
title: Building hybrid search over the catalog in ninety lines of Go.
url: https://apyverse.com/blog/building-hybrid-search-in-ninety-lines-of-go
author: Aravind Putrevu
published: 2026-06-21T00:12:52.522017Z
tags: [engineering, search, go, pgvector, tutorial]
---

# Building hybrid search over the catalog in ninety lines of Go.

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.

## The 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.

> [!info] 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.

```sql file=internal/store/migrations/000001_init.up.sql
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.

---

## Fusion: 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.

> [!tip] 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.

```go file=internal/search/hybrid.go
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.

## Where it bends

Hybrid search is not free. Three things to know:

1. **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.
2. **Model migrations are awkward.** Swap the embedding model and every row needs re-embedding. We version the model in the `embedding_model` column and run a background re-embedder when the version changes; queries during the migration return mixed-model results until the worker catches up.
3. **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 ALL` against the slug index before fusion. About twelve lines we did not show above.

> [!warn] 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% |

## What 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.

> [!danger] 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.
