- Book: AI That Reads
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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);
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 }));
}
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),
]);
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 }));
}
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.
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);
}
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 [];
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;
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.
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')
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.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)