← Alla inlägg
2026-09-02
SEARCH
4 min

Ranks, not scores: hybrid search in a job tracker

Keyword search forgives typos, embeddings find synonyms. Fusing them means refusing to compare their scores, and giving vector search a way to say no.

JobTrack exists to answer one question before you send an application: have I been here before? That turns out to be two search problems at once.

The first is spelling. You type pyhton devloper late in the evening, and the record says Python Developer. The second is meaning. You search for server-side developer, and the application you are looking for is titled Backend Engineer. Not one word in common.

Two retrievers

Keyword search handles the first. MiniSearch gives typo-tolerant BM25 over the stored applications, in process. Embeddings handle the second: all-MiniLM-L6-v2, run locally through transformers.js with no API key, turns each record into a vector, and cosine similarity finds neighbors by meaning.

Each is bad at the other's job. BM25 has never heard that backend and server-side mean the same thing. The embedding model has no idea that pyhton is a typo; it sees an unfamiliar token and does its best. So both run on every query, and the real question is how to merge two ranked lists.

Why not add the scores

The tempting move is to normalize both scores to between 0 and 1 and take a weighted sum. It does not survive contact with what the scores mean. BM25 is unbounded and depends on the query and the collection: a long query produces big numbers, a short one small ones. Cosine similarity lives in [-1, 1], and for this model nearly everything relevant lands in a narrow band near the top. Min-max normalizing each list just stretches whatever range that particular query happened to produce, so the same record can score 0.9 on one search and 0.4 on the next with nothing about it having changed.

Any conversion between the two would be invented, and wrong in ways nobody can see. So JobTrack does not convert. It uses Reciprocal Rank Fusion: each list contributes 1 / (k + rank) for every record it returned, and the sums decide the order. A record near the top of both lists wins. A record at the top of one and missing from the other still does well. The only thing being compared is position, and position is something both retrievers can report honestly.

Vector search cannot say no

Keyword search has a natural empty result: nothing matched. Vector search has none. Every vector has a nearest neighbor, so a search for astronaut in a table of software jobs returns the whole table, ranked, with complete confidence.

Fed into the fusion, that noise earns real points. So the semantic half has a similarity floor, and anything below it is dropped before fusion. The floor is a tuned constant, not a theorem, but without one the semantic retriever turns every search into a list of everything.

Normalize before you compare

The duplicate check that runs while you fill in the form leans on the same parts. Before anything is compared, company names are normalized: lowercased, accents removed, legal suffixes stripped. Spotify AB, spotify and Spotify, Inc. are one employer. In the sample data, 40 applications come out as 32 companies.

Titles at the same company are then compared both ways: a Dice coefficient of 0.8 or more on the wording, or a cosine of 0.75 or more on the meaning. The check ends in one of four verdicts: the same job, a similar job, a company you have applied to before, or genuinely new. Only the first asks for confirmation before saving. Software Engineer in 2025 and Senior Software Engineer in 2026 at the same company are usually two real applications, and the tool should point that out rather than decide for you.

The first minute

The embedding model is about 25 MB and downloads once per machine. Until it has arrived, search is keyword-only, and the interface says so while everything else works as normal. Holding the whole app back until the model was ready would have been simpler to write and worse to use.

Skriven av Martin Dahl. Om du vill diskutera något av det här, lämna ett meddelande.