The Job Finder That Reads Every Listing
A European job search built on a snapshot of a public board, where a pipeline of heuristics and local open-source language models reads each posting in two dozen languages and turns it into structured signals — role, experience, visa sponsorship, tech stack — before a custom search engine ever ranks it.
For the last couple of months I have been building a European job search on top of a snapshot of a public board — something like thirteen thousand listings, frozen at a single moment in time — and the part of the project I most want a reader to see is not the search box or the tidy result list but the quieter machinery sitting behind them, the pipeline that reads every single posting in full before any of it is ever ranked or shown. A job board, left to itself, is a wall of prose written by thousands of different people in a dozen different languages, and the engineering that makes such a wall genuinely searchable turns out to be far less about indexing strings than about understanding what each posting is actually asking for.
The jobs you can actually apply to
Picture the person this is built for, because every decision in the project flows from them: someone looking to move to Europe for work, often from a developing country and often with limited time and energy, who opens a board of thirteen thousand listings and needs, with as little ceremony as possible, the small handful of roles they could realistically apply to tomorrow. The trouble is that the obstacles standing between that person and those roles are almost never written into the structured fields a job board lets you filter on; they are buried in the description text, in whatever language the employer happened to write it in. A posting might be written entirely in German for a candidate who does not speak it, or quietly demand five years of experience the candidate has not yet earned, or refuse visa sponsorship in a single sentence three paragraphs down, or be built around a technology stack the candidate has never once touched — four mismatches that decide everything, and not one of which is reliably available as a filter, because not one of them lives anywhere but in the prose.
Reading every listing before ranking it
So the heart of the project is a Python pipeline whose entire job is to read each posting and convert that prose into structured signals a search engine can actually use, and the design principle underneath it is one I kept returning to: lean on cheap, deterministic heuristics for the cases that are clear, and reserve a language model for the genuinely ambiguous minority that heuristics quietly get wrong. The clear cases really are clear — a title that says "Backend Engineer", a requirements list that names "Python, PostgreSQL, AWS" under its own header, a German posting that insists on verhandlungssicheres Deutsch (business-fluent German) — and for those a well-built regex pass is faster, more reproducible, and frankly more honest than any model would be. It is the remaining slice that needs judgment, the posting where English is wünschenswert (desirable) rather than required, or where a residency requirement hides halfway down a paragraph about benefits, and that slice is precisely where the models earn their keep.
And the models that handle those hard cases all run locally, on my own machine: every one of them is an open-weights model — Llama 3.1 and Gemma 4 — served through Ollama rather than called through a paid API, which means the pipeline can read the entire corpus as many times over as a calibration pass demands without a budget meter ticking in the background, and it means the same code runs identically whether or not there is a network in front of it. What comes out the other end, for a single posting, is a compact structured row that existed nowhere in the source:
one German-language posting →
{
languages_required: ["de", "en"],
experience_years: 3,
role: "Backend Engineering",
tech_stack: ["Java", "Spring Boot", "PostgreSQL"],
sponsorship: "none_stated",
relocation: "provided"
}
What makes a row like that worth the effort is that nearly every field had to be inferred across a language barrier rather than copied from a field that already existed, and the pipeline does exactly this across the roughly two dozen languages the corpus actually contains — German, French, Spanish, Italian, Dutch, Polish, Czech, Hungarian, and a long multilingual tail behind them — applying the same heuristics-first, model-for-the-hard-cases discipline to language detection, years-of-experience extraction, role classification, tech-stack tagging, and the visa-and-relocation signals that matter most to the very person the whole thing is being built for.
Calibrated against hand-labelled truth, not vibes
The temptation with this kind of extraction is to glance at a few outputs, decide they look about right, and ship, and the discipline I held myself to instead was to measure every extractor against a set of postings I had labelled by hand, drawn deliberately from outside the set I used to tune it — because grading a thing on the same examples you tuned it on is the oldest way there is of lying to yourself about how well it works. The numbers that came out of that exercise are the ones I am proudest of, precisely because they are real rather than flattering: language detection lands at a hundred percent precision and ninety-seven percent recall against its held-out labels; the years-of-experience extractor, after a cleanup pass, reaches a hundred percent precision and ninety-three percent recall; the visa, sponsorship, and relocation signals catch every genuinely positive case in the held-out set, a clean hundred percent recall on the one axis where a miss would actually cost the candidate a job they could have had; and the tech-stack tagger sits at roughly three-quarters precision while getting its required-versus-nice-to-have distinction right about eighty-two percent of the time.
The role taxonomy was the most interesting piece of machine learning in the project and also the one I am most careful never to oversell. Rather than impose a set of categories from the top down, I embedded the job titles with a multilingual embedding model called bge-m3 and clustered them, letting the natural job families fall out of the data into a taxonomy of twenty-two top-level functions that I then went through and validated by hand. Classifying a brand-new title into that taxonomy is genuinely hard, and so I treat the model's guess for exactly what it is — a soft signal that helps rank and group the results rather than a hard filter that hides anything — because a classifier that is right about a role two times out of three has no business silently deciding what a job-seeker is never allowed to see.
The app, the search engine, and one read-only file
All of that signal would sit inert without something to deliver it, and the front half of the project is a Laravel 13 application with a Vue and Inertia frontend, built on top of a search engine I had to write rather than install. The free-text search runs on SQLite's full-text engine, FTS5, but settling on a single tokenizer turned out to be a trap: the substring-friendly trigram tokenizer is wonderful for German compound words, surfacing Postbote (mail carrier) when you type "bote" and Kubernetes when you type "kube", and it is quietly catastrophic for short names, returning a page full of Digital Marketing roles the moment a candidate searches for "Git" because the letters g-i-t happen to live inside the word "digital". The fix was to run a second, word-level index alongside the first and blend their rankings, so that whole-word matches rise cleanly to the top while the substring bridges remain available underneath them rather than drowning everything above them:
q = "Git"
before: 0 of the top 5 results were real (Digital Marketing, Chief Digital Officer …)
after: 5 of 5 were real Git / version-control roles
Sitting on top of retrieval is a composite relevance scorer that weighs the free-text match against recency, description quality, role and language and tech-stack fit, and a small bonus for the canonical entry of a duplicate cluster — duplicates being a very real problem on a board where one logistics template can recur, byte for byte, across hundreds of cities, which is why a deduplication pass collapses roughly forty percent of the corpus before the candidate ever lays eyes on it. Calibrating that scorer against a hand-labelled set of queries lifted its ranking quality, measured as NDCG at ten, from 0.69 to 0.83 on a held-out set I had sealed away during the tuning. The whole thing then ships to Fly.io as a single read-only SQLite file, a little over a gigabyte, baked directly into the deploy image, which holds the running cost to roughly six dollars a month and makes a data refresh nothing more exotic than another deploy.
What the project demonstrates
Stepping back from the individual parts, what this project is meant to show is a single arc owned end to end: a multilingual data-and-machine-learning pipeline in Python that orchestrates local open-source language models to turn unstructured prose into calibrated, measurable signal, and a full-stack Laravel and Vue application with a custom hybrid search engine that turns that signal into something a real person can actually use, deployed and running in production for the price of a sandwich. The same rule runs through every layer of the project: I never trust a model just because it sounds confident.
Applied-AI engineering, done honestly, is far less about clever prompting than it is about knowing exactly how often you are wrong — and being willing to hand the hard cases to a model only once you have proven, against labels you wrote by hand, that it has earned the trust.
The job finder is live at jobs.ubeyd.dev if you would like to apply your own filters and watch a noisy board collapse down to the roles you could genuinely apply to.
Related: The 504 That Was Actually Working — a deeper dive on one challenge from this project, the deploy-day gateway timeout where nothing was actually broken and the fix was a cache that can never be wrong.
Part of a short series of write-ups on the things I've built. See also The Restaurant Floor That Rearranged Itself, a reservation system that moved its own tables, and The Savings Plan That Rewrites Itself, a savings app that rewrites its own plan.