DEV Community

Cover image for Ranking in TypeScript RAG: Why Cosine Similarity Alone Fails
Gabriel Anhaia
Gabriel Anhaia

Posted on

Ranking in TypeScript RAG: Why Cosine Similarity Alone Fails


Ask a support assistant "how do I rotate an API key?" and watch
what comes back from a plain vector search. Ten chunks, all of them
genuinely about API keys. The changelog entry announcing key
rotation. The pricing page mentioning key limits. The security
overview describing why rotation matters.

The step-by-step rotation procedure is at rank eleven.

Nothing is broken. Cosine similarity measured topical relatedness and
returned the ten most topically related chunks. It was never asked
which chunk answers the question, because a single embedding
comparison cannot express that difference.

What the geometry actually gives you

An embedding compresses a chunk into one point. Cosine distance
between two points measures how close their overall meaning is.

That is a genuinely useful signal and a lossy one. Compressed away:
whether the chunk is instructional or descriptive, whether it is
current or superseded, whether it names a specific entity in the
query, and whether it contains a procedure at all.

A query is short and interrogative. A good answer chunk is longer and
declarative. They can be semantically close while being different
kinds of text — and "different kind of text" is exactly what
distinguishes an answer from a mention.

So the first stage should not try to be precise. It should be
recall-oriented: fetch generously, then spend effort deciding
which of those actually answer.

Over-fetch, then rerank

export type Candidate = {
  id: string;
  text: string;
  vectorScore: number;
  updatedAt: Date;
  docType: "guide" | "reference" | "changelog" | "marketing";
};

export async function retrieve(q: string, k = 8) {
  const candidates = await vectorSearch(q, { limit: k * 8 });
  const reranked = await rerank(q, candidates);
  return reranked.slice(0, k);
}
Enter fullscreen mode Exit fullscreen mode

k * 8 is the whole trick at the first stage. The rotation procedure
sitting at rank eleven is now inside the candidate set, and a second
stage that reads text rather than comparing points can promote it.

The multiplier is a real dial. Too small and the right answer never
enters the pool. Too large and reranking gets expensive. Between five
and ten times k is a sane starting range; measure it against
labelled queries rather than picking once.

A cross-encoder is a different computation

Bi-encoders — what your vector search uses — embed the query and the
document separately and compare. The two never meet.

A cross-encoder feeds the pair together and scores the relationship
directly. It can attend to the fact that the query says "how do I"
and this chunk begins "To rotate a key:". That interaction is
precisely what the separate embeddings threw away.

export async function crossEncode(
  query: string,
  cands: Candidate[],
): Promise<Scored[]> {
  const scores = await reranker.rank({
    query,
    documents: cands.map((c) => c.text),
  });
  return cands.map((c, i) => ({ ...c, relevance: scores[i] }));
}
Enter fullscreen mode Exit fullscreen mode

It is more expensive per pair, which is why it runs over sixty
candidates rather than the whole corpus. That is the shape of the
whole pattern: cheap and broad, then expensive and narrow.

If you have no reranker available, an LLM does the same job with a
constrained output:

const Rated = z.object({
  ratings: z.array(z.object({
    id: z.string(),
    answers: z.number().min(0).max(3),
  })),
});

async function llmRerank(query: string, cands: Candidate[]) {
  const res = await client.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 1024,
    system:
      "Rate how directly each passage ANSWERS the question. " +
      "3 = contains the answer. 2 = partial. " +
      "1 = related topic only. 0 = irrelevant.",
    messages: [{ role: "user", content: render(query, cands) }],
  });
  return Rated.parse(JSON.parse(textOf(res.content)));
}
Enter fullscreen mode Exit fullscreen mode

The rubric wording carries the weight. "Answers" rather than
"relevant" is the distinction the vector stage could not make, and
stating it explicitly is most of what you are buying.

A wide cheap first stage feeding a narrow expensive rerank stage.

Compose the rest as pure functions

Relevance is not the only thing you know. Recency and document type
are structured fields sitting right there on the row, and folding
them in as pure functions keeps them testable.

export type Signal = (c: Scored, q: string) => number;

export const recency: Signal = (c) => {
  const days = (Date.now() - c.updatedAt.getTime()) / 86_400_000;
  return Math.exp(-days / 365);          // ~0.37 at one year
};

export const authority: Signal = (c) =>
  ({ guide: 1, reference: 0.9, changelog: 0.5, marketing: 0.2 })[
    c.docType
  ];

export const exactTerm: Signal = (c, q) => {
  const terms = q.toLowerCase().match(/\b[a-z_]{4,}\b/g) ?? [];
  const hits = terms.filter((t) => c.text.toLowerCase().includes(t));
  return terms.length ? hits.length / terms.length : 0;
};
Enter fullscreen mode Exit fullscreen mode

Each is an ordinary function of a candidate. Each can be unit tested
without a model, a database, or a network call — which matters,
because ranking logic is otherwise the least testable part of a RAG
system.

Combine with explicit weights:

const WEIGHTS = [
  [relevanceSignal, 0.6],
  [exactTerm, 0.2],
  [recency, 0.1],
  [authority, 0.1],
] as const;

export function finalScore(c: Scored, q: string): number {
  return WEIGHTS.reduce((sum, [f, w]) => sum + f(c, q) * w, 0);
}
Enter fullscreen mode Exit fullscreen mode

Weights in one array, visible, adjustable, diffable. The alternative
— scoring spread across a long function with magic numbers inline —
is the version nobody dares change later.

exactTerm earns its place more often than people expect. Embeddings
are weak on rare literal tokens: an error code, a config key, a
version string. If the user typed ERR_KEY_ROTATION_FAILED, a chunk
containing that exact string is almost certainly the right chunk, and
the vector stage has no strong opinion about it.

Normalise before you mix

Scores from different sources are on different scales. Cosine
similarity sits roughly in a narrow band near the top; a reranker may
emit logits; your own signals are already in [0, 1].

Adding them raw means whichever has the widest range silently
dominates.

function normalise(xs: number[]): number[] {
  const lo = Math.min(...xs);
  const hi = Math.max(...xs);
  if (hi === lo) return xs.map(() => 0.5);
  return xs.map((x) => (x - lo) / (hi - lo));
}
Enter fullscreen mode Exit fullscreen mode

Min-max within the candidate set, per query. Not global — the point
is relative ordering among these candidates, and a global scale makes
an easy query with ten good answers look the same as a hard one with
none.

Raw scores on incompatible scales versus per-query normalised scores before weighting.

Measure before you tune

Thirty real queries with the id of the chunk that answers each. That
is enough to see movement.

it("ranks the answering chunk in the top 3", async () => {
  let hits = 0;
  for (const { query, answerId } of GOLDEN) {
    const top = (await retrieve(query, 3)).map((c) => c.id);
    if (top.includes(answerId)) hits++;
  }
  expect(hits / GOLDEN.length).toBeGreaterThan(BASELINE);
});
Enter fullscreen mode Exit fullscreen mode

Track it as a number that has to go up. Without it, weight tuning is
someone changing 0.6 to 0.7 because a query they personally tried
got better, and quietly making thirty others worse.

The framing that helps

Retrieval is two questions, not one. What is this about — cheap,
approximate, wide. Does this answer the question — expensive,
precise, narrow.

Cosine similarity answers the first well and the second not at all.
Most RAG systems that feel almost-right are missing the second stage
entirely.


If this was useful

AI That Reads covers
retrieval quality end to end — candidate generation, reranking, score
composition, and building the small labelled set that tells you
whether a change helped.

AI That Reads — RAG in TypeScript

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

Top comments (0)