DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three search rankers in one codebase, and none of them should be shared

There are three text-ranking implementations in our app. A reviewer's instinct on seeing that is to consolidate, and for a while mine was too. They are separate on purpose, and working out why they resisted merging taught me more about search than the ranking code itself did.

They differ because they rank different things against different expectations about what the user is doing.

Ranker one: a small static index, so require every token

The dashboard search and the provider picker both rank a fixed list of assessment providers. The index is tiny, entirely in memory, and shipped with the page.

for (const token of tokens) {
  if (!lowerHaystack.includes(token)) return -1;
}

let score = 0;
if (lowerTitle === rawQuery) score += 100;
else if (lowerTitle.startsWith(rawQuery)) score += 60;
Enter fullscreen mode Exit fullscreen mode

Plain substring matching, no fuzziness, and an entry is excluded outright if any query token is missing.

That last rule is the whole design. Requiring every token means a multi-word query narrows the list, which is what people expect when they type a provider name followed by a test name. A fuzzy matcher over a list this short does the opposite: type more words, get more results, feel insane.

The index itself is built rather than written:

haystack: [label, id, id.replace(/-/g, ' '), description, ...games.map((g) => g.name)]
  .join(' ')
  .toLowerCase(),
Enter fullscreen mode Exit fullscreen mode

The full prose description goes into the haystack, which sounds lazy and is the most valuable part. Candidates arrive typing the word from their invitation email, which is usually an alias rather than the name we display. Those aliases are already written in each provider's description, so folding the description in makes them findable with no second list to maintain. The id is included both hyphenated and de-hyphenated, so "arctic shores" and "arcticshores" both land.

Ranker two: free text over a taxonomy, so be generous

The onboarding question "what role are you applying for?" is a different problem. There is no invitation email to copy from. People type what they call their job, in their words, with typos, in a flow that has no skip button. A dead-end search here loses the user.

So this one is generous, and it is tiered so that generosity never outranks certainty:

if (term === query) score = 1000;
else if (term.startsWith(query)) score = 800;
else {
  const wordIndex = term.split(' ').findIndex((word) => word.startsWith(query));
  if (wordIndex >= 0) score = 700 - Math.min(wordIndex, 6) * 15;
  else if (term.includes(query)) score = 600;
  else if (queryTokens.length > 1 && queryTokens.every((t) => term.includes(t))) score = 500;
  else {
    const ratio = subsequenceRatio(query, term);
    if (ratio >= 0.55) score = 200 + Math.round(ratio * 100);
  }
}
Enter fullscreen mode Exit fullscreen mode

An exact term beats a prefix, a prefix beats a mid-word substring, and every literal match beats every fuzzy one. Intent the user clearly expressed always outranks a coincidence.

Two details in there were found by using it rather than by designing it.

The word-index penalty. Without - Math.min(wordIndex, 6) * 15, the query "technology" scored identically on "Technology Consulting" and on a fashion role that happened to carry "garment technology" as an alias. A match on the first word of a name is usually the thing the user meant; a match on the fourth word usually is not.

The subsequence floor. Fuzzy matching here is a bounded-gap subsequence test, which catches typos and abbreviations ("invstment", "civeng"):

const MAX_GAP = 6;
for (const char of query) {
  const found = text.indexOf(char, textIndex);
  if (found === -1) return 0;
  if (firstIndex === -1) firstIndex = found;
  else if (found - textIndex > MAX_GAP) return 0;
  textIndex = found + 1;
}
const span = textIndex - firstIndex;
return span > 0 ? query.length / span : 0;
Enter fullscreen mode Exit fullscreen mode

The gap bound is what stops "sea" matching "supply chain analyst" through three characters scattered across twenty. Without it, subsequence matching makes almost every entry match almost every query, which is the reason so much fuzzy search feels random rather than forgiving. The 0.55 tightness floor is the second half of the same guard.

There is also a plain map from colloquial job titles to taxonomy terms ("train driver", "lorry driver", "firefighter"), expanded before matching. Those are words a candidate genuinely uses that appear nowhere in a formal role list, and the alternative, sprinkling keywords across dozens of role entries, spreads one decision across a whole file.

Ranker three: the directory that is derived rather than written

The employer directory is a third case, and its interesting property is not its ranking at all. It is that the list is derived from two lists the app already ships: employers with a sourced assessment guide, and employers named on each provider's page.

Re-typing those names for onboarding would create a third list to keep in sync, and the first employer added to a guide would be silently missing from onboarding, with nothing failing to say so. Merging and de-duplicating the two existing sources means adding an employer anywhere makes it selectable everywhere.

The ids are the existing guide slugs, so a stored answer joins straight back to the guide and the provider list with no mapping table.

That is a different kind of correctness from ranking, and it is the one that actually determines whether the search feels complete.

Why unification would lose

Put those three side by side and the shared abstraction would need: optional token-requirement, optional fuzziness, a configurable tier table, an optional word-position penalty, an optional alias-expansion map, and a pluggable index builder.

At that point you have not written a ranking function. You have written a small configuration language, and each of the three call sites is now expressed as a config blob that is harder to read than the twenty lines it replaced. Worse, tuning one surface means editing shared code, so every tweak to the role search becomes a risk to the provider picker.

The deciding question is not "is the code similar?" but "when this changes, do they change together?" These three change for entirely unrelated reasons: the provider picker changes when providers are added, the role search changes when a real user reports a dead-end query, and the employer directory changes when a content page is written. Nothing ever changes all three at once.

So the file says so, in a comment, because the next reviewer will have the same instinct I did:

Note this is one of three ranking implementations in the codebase. They are deliberately separate because they rank different things against different expectations, not because the logic was copied.

An explicit "this duplication is intended, here is the test for whether that is still true" is worth more than either merging them or leaving three files to look accidental.

You can try ranker one after signing in at https://cogniprep.app/games: type a test name, a provider alias or a half-remembered game and watch a multi-word query narrow rather than widen. That is the token requirement doing its job.

Top comments (0)