DEV Community

Cover image for Hybrid Search for AI Retrieval in Node: When Keywords Beat Embeddings
Gabriel Anhaia
Gabriel Anhaia

Posted on

Hybrid Search for AI Retrieval in Node: When Keywords Beat Embeddings


A user pastes ERR_CONN_POOL_EXHAUSTED into your AI assistant. Your corpus
has exactly one page about it. Vector search returns ten pages about
connection pooling in general, and that page is not among them.

Embeddings are trained to capture meaning. A rare literal token has almost no
meaning to capture — the model has seen it a handful of times, so its vector
is close to noise, and the nearest neighbours are pages that are topically
similar rather than the one containing the string.

Keyword search finds it instantly. This is the case for running both.

Where each one wins

Dense retrieval wins on paraphrase: "my app is slow after deploy" finding a
page titled "Diagnosing latency regressions." No shared words, same meaning.

Keyword retrieval wins on identifiers: error codes, config keys, function
names, SKUs, version strings, people's names, anything your users copy and
paste. It also wins on negation and on very short queries, where an embedding
has little to work with.

Most real query logs contain plenty of both, which is why picking one is
leaving recall on the table.

Both indexes in Postgres

You do not need a second system.

ALTER TABLE chunks ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', text)) STORED;

CREATE INDEX chunks_tsv   ON chunks USING gin (tsv);
CREATE INDEX chunks_embed ON chunks USING hnsw (embedding vector_cosine_ops);
Enter fullscreen mode Exit fullscreen mode

A generated column means the lexemes cannot drift from the text — no trigger
to forget, no backfill after an edit.

One caveat worth knowing: the english config stems words, which is usually
good and occasionally not. ERR_CONN_POOL_EXHAUSTED survives as a single
token because it contains no spaces, but hyphenated identifiers get split. If
your domain is identifier-heavy, add a simple configuration column
alongside.

Run them as two queries, not one

The instinct is one clever SQL statement. Resist it — separate queries are
easier to tune, to cache, and to reason about when one side misbehaves.

type Hit = { id: string; text: string; rank: number };

async function dense(q: string, scope: Scope, k: number): Promise<Hit[]> {
  const rows = await db.$queryRaw<{ id: string; text: string }[]>`
    SELECT id, text FROM chunks
    WHERE tenant_id = ${scope.tenantId} AND deleted_at IS NULL
    ORDER BY embedding <=> ${await embed(q)}::vector
    LIMIT ${k}`;
  return rows.map((r, i) => ({ ...r, rank: i + 1 }));
}

async function lexical(q: string, scope: Scope, k: number): Promise<Hit[]> {
  const rows = await db.$queryRaw<{ id: string; text: string }[]>`
    SELECT id, text FROM chunks
    WHERE tenant_id = ${scope.tenantId} AND deleted_at IS NULL
      AND tsv @@ websearch_to_tsquery('english', ${q})
    ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', ${q})) DESC
    LIMIT ${k}`;
  return rows.map((r, i) => ({ ...r, rank: i + 1 }));
}
Enter fullscreen mode Exit fullscreen mode

websearch_to_tsquery rather than plainto_tsquery: it accepts quoted
phrases and -exclusions the way users already expect, and it does not throw
on punctuation that would break the stricter parsers.

Both run concurrently:

const [d, l] = await Promise.all([
  dense(q, scope, 50),
  lexical(q, scope, 50),
]);
Enter fullscreen mode Exit fullscreen mode

Fuse on rank, not on score

The tempting move is to normalise both scores and add them. Do not — cosine
distance and ts_rank_cd are on unrelated scales, and their distributions
change with query length and corpus size. Any weighting you tune today is
wrong next month.

Reciprocal Rank Fusion sidesteps it by using only position:

export function rrf(lists: Hit[][], k = 60): Scored[] {
  const acc = new Map<string, { hit: Hit; score: number }>();
  for (const list of lists) {
    for (const h of list) {
      const prev = acc.get(h.id);
      const add = 1 / (k + h.rank);
      if (prev) prev.score += add;
      else acc.set(h.id, { hit: h, score: add });
    }
  }
  return [...acc.values()]
    .sort((a, b) => b.score - a.score)
    .map(({ hit, score }) => ({ ...hit, score }));
}
Enter fullscreen mode Exit fullscreen mode

Two properties make this the right default. It needs no calibration — nothing
to tune per corpus. And a document ranked reasonably by both retrievers
beats one ranked first by only one, which is exactly the behaviour you want:
agreement is evidence.

k = 60 is the value from the original RRF work and it is a fine starting
point. Larger flattens the contribution of top ranks; smaller sharpens it.

Two ranked lists fused by position, where agreement across both outranks a<br>
single first<br>
place.

The whole retriever

export async function retrieve(q: string, scope: Scope, k = 8) {
  const [d, l] = await Promise.all([
    dense(q, scope, 50),
    lexical(q, scope, 50),
  ]);

  metrics.increment("rag.lexical_only",
    l.filter((x) => !d.some((y) => y.id === x.id)).length);

  const fused = rrf([d, l]).slice(0, k * 3);
  return (await rerank(q, fused)).slice(0, k);
}
Enter fullscreen mode Exit fullscreen mode

The lexical_only metric is worth more than it looks. It counts chunks the
keyword side found that the vector side missed entirely — the ERR_CONN_POOL
case. If that number is consistently zero, hybrid is not earning its keep for
your corpus and you can drop it. If it is high, you were losing those answers
before.

Reranking after fusion is what turns a merged candidate pool into an ordering
that reflects answering rather than matching.

When lexical returns nothing

A query with no matching lexemes returns an empty list, and RRF handles that
fine — it just becomes dense-only. That is the correct behaviour and needs no
special case.

The opposite is worth guarding: a query of only stopwords ("how do I")
produces an empty tsquery, and on some configurations @@ against an empty
query matches nothing rather than everything. Both are acceptable; what you do
not want is an exception.

if (!q.replace(/\W+/g, " ").trim()) return [];
Enter fullscreen mode Exit fullscreen mode

Measure before and after

Twenty real questions with the id of the chunk that answers each. Run
dense-only, then hybrid.

const recallAt = (results: Hit[][], expected: string[], n: number) =>
  expected.filter((id, i) =>
    results[i].slice(0, n).some((r) => r.id === id)).length / expected.length;
Enter fullscreen mode Exit fullscreen mode

Report recall@10 for both. In my experience of query logs the gain is
concentrated entirely in identifier-style queries — paraphrase queries barely
move, so if your users mostly ask conceptual questions, hybrid may not be
worth the second index. The measurement tells you which product you have.

Recall gains concentrated in identifier queries, with paraphrase queries<br>
largely<br>
unchanged.

Cheap wins around it

Two things help before you reach for anything more elaborate.

Index the heading path and the title into the same tsvector with a weight.
Postgres supports weighted lexemes, and a title match is a stronger signal
than a body match:

setweight(to_tsvector('english', coalesce(title,'')), 'A') ||
setweight(to_tsvector('english', text), 'B')
Enter fullscreen mode Exit fullscreen mode

Keep the raw query for lexical, and the cleaned query for dense. Stripping
punctuation helps embeddings and destroys the exact tokens keyword search
needs. Running one preprocessing step for both is a common quiet mistake.


If this was useful

AI That Reads covers retrieval as a
system — dense and lexical together, fusion, reranking, and the labelled set
that tells you whether any of it helped.

AI That Reads — RAG in TypeScript

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)