← back to the pad

Semantic Job Matching Platform

Matching people to jobs on meaning, not keywords

DjangoPostgres + pgvectorCeleryTailwindDockerAWS

Built
2025 – ongoing
My role
Front end and LMS integration — one of five contributors
Stack
Django, PostgreSQL + pgvector, Celery/Redis, Tailwind, Docker, AWS ECS, Terraform
Code
Private, under NDA — happy to talk about the engineering

Problem

Matching a person to a job on résumé keywords is a poor approximation of whether they can actually do the work. Keyword overlap misreads a gap in a work history as an absence of skill, and it can’t see that experience from a different industry transfers directly. Two people with nearly identical keywords can be completely different candidates.

The people doing the placing know all of this, and compensate with time and memory — both finite. Past a certain number of jobseekers, good candidates stop reaching the right employers. Not because anyone judged badly, but because nobody had the hours to look properly.

Solution

The platform scores person-to-job compatibility from 0 to 100 using semantic similarity rather than keyword overlap — embeddings stored in Postgres via pgvector, with weighting that adapts to the kind of candidate being scored (career changer, transitioning, domain expert). It scores training courses against jobs on the same basis, so a low match arrives with a suggestion for closing the gap instead of just a number.

Travel time between candidate and posting is factored in through the Google Routes API, and it earns its place: a role that scores well on paper but sits ninety minutes each way by bus is not, in practice, a match. When travel time can’t be calculated the scoring degrades gracefully rather than failing the whole comparison.

My work has been the layer the people using it sit in front of:

Worth noting

The LTI work was the most stubborn part, and none of the difficulty is in the spec. Launching inside a learning system means running in a third-party iframe, which means the browser’s cookie rules are working against you by default — the session that authenticates perfectly in a tab silently fails to exist once it’s embedded. Getting that right took more attempts than I’d like to admit, and the branch names still show it.