Job title classifier: matching noisy 1C exports against a closed reference list
A deterministic pipeline matches noisy job titles from a 1C export against a closed classifier — no LLM, no embeddings, no external APIs. Source code is open and covered by tests.
- Scale
- 300 records, 56-entry reference list
- Stage
- Open source
- Delivered
- August 2026
TL;DR. Job titles in a 1C export are free text full of typos, abbreviations and slang. A deterministic pipeline — normalisation → fuzzy lexical matching (RapidFuzz) → confidence calibration with every rejection sent to human review by default — maps those strings onto a closed 56-entry reference list without an LLM, embeddings or external APIs. The code is open.
Situation: job titles in 1C don't match the reference list
HR data out of 1C is free text: "concr. pump op.", "eng. prod-tech dept", "bulldozer operator grade 5", "StroyMontazh LLC, rotational". The reference list is closed and holds just 56 canonical entries. Direct string comparison almost never fires, and working through hundreds of records by hand doesn't scale.
Task
- Match noisy strings against the reference list, returning a code, the canonical title and a confidence value.
- Filter out job titles outside the profile without inventing a code where there is no match.
- Do it without an LLM, embeddings or external APIs — a condition of the task.
- Flag records that need human review rather than quietly losing the error.
What we built
A deterministic three-stage pipeline.
Normalisation. Strip company names and grades, expand industry abbreviations, fold synonyms to a canonical form, fix common typos. 300 input strings collapse into 78 unique canonical forms, which turns most comparisons into exact matches.
Fuzzy matching. A weighted RapidFuzz metric (0.5 · token_sort_ratio + 0.5 · ratio) compares the normalised string against each of the 56 reference entries.
Confidence calibration. By default any rejection ("no match") goes to human review regardless of score: a low score is not proof that the job title is outside the profile — it may simply mean normalisation didn't recognise an unusual phrasing of a real one. Thresholds, weights and dictionaries live in config and text files, so retuning for new data needs no code change.
The project is covered by tests and published at github.com/pimenoffd.
Result
- 100% accuracy on the labelled sample.
- Across 300 records, 277 matches were accepted automatically and 23 sent for review (7.7% under the conservative default, dropping to 5.3% at a stricter threshold).
- The full dataset processes in under 0.2 seconds on CPU, with no GPU and no external services.
- Test coverage with pytest, open source.
Key technical decisions
- Lexical matching instead of embeddings. The reference list is closed and holds only 56 entries, and the noise in the data is orthographic and structural rather than semantic. Against that kind of noise, lexical search matches dense semantic search and needs none of the infrastructure — no GPU, no model.
- Every rejection goes to human review by default. A low score at the filtering step is not a verified "outside the profile": the one known miss is statistically indistinguishable from legitimate rejections at this data volume. Tightening the threshold now would mean fitting it to a single example rather than measuring it.
- Thresholds and dictionaries in config, not in code. A separate config file holds thresholds and weights; text files hold the dictionaries of abbreviations, synonyms and out-of-profile vocabulary. Retuning for a new data stream is a file edit, not a deploy.
- A trained model was rejected deliberately. The labelled sample (50 rows) is smaller than the number of classes (56) — training would almost certainly have overfitted and lost the rare classes.
FAQ
Why not embeddings or an LLM?
The noise in this data is typos and abbreviations, not semantic ambiguity. With 56 classes, lexical matching gives the same quality without a GPU, external APIs or inference cost.
What happens to job titles that aren't in the reference list?
The system flags them as no-match and sends them to human review by default. It neither assigns a code just in case nor drops the record silently.
Can I use this with my own reference list?
The code is open on GitHub. Your own list and your own abbreviations need the dictionaries and thresholds tuned — without changing the pipeline logic.
What would come next
The natural continuation is active learning on the records sent for review, where confirmed corrections feed the synonym dictionary; connecting industry-standard classifiers; and calibrating the review threshold statistically as operational data accumulates.
If you have HR data that doesn't line up with your reference list row by row, let's talk it through in 30 minutes.
Stack
- Python
- RapidFuzz
- pytest