How AI job matching actually works: filters, embeddings, rerank
AI job matching is not one model. It's a three-stage pipeline - hard filters that cut the corpus, vector recall that finds semantic near-misses keyword search drops, and a cross-encoder or LLM rerank on the shortlist. What each stage costs, why the order matters, and why the whole thing collapses if your posting corpus is stale. With working code for the retrieval half.
Dvir Atias
Founder, JobsPipe
“AI job matching” is marketed as a model that reads a resume, reads a job, and returns a score. That is not how any system that works is built. Running a language model over every posting for every candidate is computationally absurd - a million postings times ten thousand candidates is ten billion comparisons, and you would be paying per token for all of them.
Real matching is a three-stage funnel, and each stage exists to make the next one affordable. Understanding the shape tells you where to spend effort and, more usefully, where the whole thing quietly breaks.
Stage 1: hard filters
Before any semantics, cut the corpus with facts. A candidate who cannot legally work in Germany should never be scored against German roles, no matter how well the text matches.
Work authorization, geography, remote eligibility, employment type, and recency are boolean or categorical. They belong in a database query, not a model:
curl https://api.jobspipe.dev/v1/jobs/search \
-H "Authorization: Bearer jp_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"country_code_or": ["DE", "NL"],
"remote": true,
"employment_type_or": ["full_time"],
"posted_at_max_age_days": 21,
"limit": 100
}'This routinely removes the large majority of the corpus for the cost of an index scan. Every downstream stage is cheaper by that factor, which is why the order is not negotiable.
One caution: filters are also where matching quietly becomes discriminatory. Filtering on graduation year or a specific school is a proxy for age and background. Filter on requirements, not proxies.
Stage 2: vector recall
Now the semantic part. Keyword search fails on job data in a specific, predictable way: a posting says “Golang” and the candidate wrote “Go”; the posting wants “RAG pipelines” and the resume says “retrieval-augmented generation.” Neither matches on tokens. Both match on meaning.
Embed both sides into the same vector space, then retrieve by cosine similarity:
from openai import OpenAI
import numpy as np
client = OpenAI()
def embed(texts: list[str]) -> np.ndarray:
r = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return np.array([d.embedding for d in r.data])
# One embedding per posting, computed once at ingest
job_texts = [
f"{j['title']} at {j['company']}. {j['description'][:2000]}"
for j in jobs
]
job_vecs = embed(job_texts)
# One embedding per candidate profile
profile = embed([candidate_summary])[0]
# Cosine similarity; these vectors are already L2-normalized
scores = job_vecs @ profile
top_50 = np.argsort(-scores)[:50]Three things matter more than the model choice here.
Embed the right text. A full job description is mostly boilerplate - benefits, EEO statements, company blurb - and that noise dilutes the signal. Title plus responsibilities plus requirements beats the raw description consistently. Truncating at a couple of thousand characters is usually an improvement, not a compromise.
Recompute on change. A stale embedding for an edited posting silently returns wrong matches forever, and nothing in the system will tell you. Key the embedding to a content hash.
Symmetry is a myth. A resume and a job description are different genres. One describes what someone did, the other what someone should do. Embedding them raw and comparing works less well than normalizing both into a common shape first - extract skills, seniority, and domain from each side, then embed that structured summary.
Stage 3: rerank
Vector similarity is fast and blunt. It knows a backend role and a frontend role are both engineering, and it will happily rank a Kotlin Android job highly for a Kotlin backend developer.
A reranker looks at pairs and scores them properly. Two options, and the choice is mostly about budget:
- Cross-encoder (Cohere Rerank, a bge-reranker model, or similar). Encodes the query and document together so attention runs across both. Milliseconds per pair, cheap, and a large accuracy jump over cosine alone.
- LLM as judge. Prompt a model with the profile and the posting and ask for a score plus a reason. More expensive, slower, and it gives you an explanation - which turns out to matter more than the score itself.
prompt = f"""Rate this job match 0-100 for this candidate.
CANDIDATE
{profile_summary}
JOB
{job_title} at {company}
{requirements}
Score on: skill overlap, seniority fit, domain relevance.
Penalize heavily if the seniority is a mismatch in either direction.
Return JSON: {{"score": int, "reason": str, "gaps": [str]}}"""Run this on the top 20 to 50 from stage two, never the full corpus. That is the entire point of the funnel.
Why explanations beat scores
“87% match” is not actionable. Nobody knows what the missing 13% is, and a wrong 87% destroys trust in every other number you show.
“Strong match on Python and distributed systems. Job asks for Kubernetes, which is not on your profile. Listed as senior; your experience reads mid-level” is something a person can act on. It is also honest about uncertainty in a way a percentage never is. If you are already paying for an LLM rerank, take the explanation - it is the more valuable output.
The failure everyone hits
All of the above is wasted if the corpus is stale. A pipeline that perfectly ranks postings which closed three weeks ago produces confident, useless output - and the user only discovers it after applying.
This is the least glamorous part of job matching and the one that decides whether the product works. Two requirements, both boring:
- Ingest continuously. A weekly batch means the median posting a user sees is days old, and the good ones are gone first.
- Verify closure, do not infer it. “Posted 40 days ago” is not the same as closed, and plenty of roles stay open for months. Recheck the source rather than aging listings out on a timer.
Building that is a different engineering problem from matching, and it is the one that never ends. The JobsPipe API handles the ingest and recheck half - 30+ sources, normalized fields, verified-open status - so the matching stack above sits on a corpus that is actually current. Details on the recheck loop are in expired job postings.
What to build in what order
- Hard filters over a fresh corpus. Ship this alone - it already beats most job boards.
- Vector recall on the filtered set. This is where users first say it feels smart.
- LLM rerank with explanations on the top 20. This is where they trust it.
- Feedback signals - saves, applies, dismissals - fed back into ranking. Only worth it once you have enough traffic for the signal to mean something.
Teams routinely start at step three, get a demo that dazzles on ten handpicked postings, and discover the corpus problem in month four. Start at step one. If you want a working agent loop around this, the agent guide covers the retrieval half end to end.
Build matching on a corpus that is actually current - 30+ sources, free tier included.
Get a free API keyFrequently asked questions
How does AI job matching work?
It is a three-stage funnel, not a single model. Hard filters cut the corpus first using facts like work authorization, geography, and recency. Vector recall then finds semantic near-misses that keyword search drops, such as Golang against Go. Finally a cross-encoder or LLM reranks the top twenty to fifty. Each stage exists to make the next one affordable, which is why the order is not negotiable.
Why should filters run before the AI model in job matching?
Because filtering removes the large majority of the corpus for the cost of an index scan, making every downstream stage cheaper by that factor. Running a language model over every posting for every candidate is computationally absurd - a million postings times ten thousand candidates is ten billion comparisons. Work authorization, geography, remote eligibility, and employment type are categorical facts that belong in a database query.
Why does AI job matching surface jobs that are already closed?
Because the ranking pipeline is only as good as the corpus underneath it, and a stale corpus produces confident, useless output. Two things prevent it. Ingest continuously rather than in weekly batches, since the best roles go first. And verify closure by rechecking the source rather than inferring it from age - plenty of legitimate roles stay open for months.