Freelance project
Resume Screener
- Problem
- Reading a large stack of PDF resumes against one job description is slow, and keyword filters miss candidates who describe the same skills in other words.
- Approach
- Gemini parses each PDF into structured fields, and every resume gets a transparent score: 0.4 skills match + 0.3 experience + 0.3 semantic similarity from MiniLM embeddings. Batches run asynchronously with live progress over a WebSocket.
- Result
- A FastAPI service that screens a whole batch in one go, shows progress as it works, and paces its Gemini calls with a rate limiter.
- My role
- Freelance AI engineer (2024–2025). Built the system end to end for a client: backend, scoring, LLM integration and UI.
- Stack
- Python
- FastAPI
- PyMuPDF
- Gemini
- Pydantic
- sentence-transformers (MiniLM)
- asyncio
- WebSockets
- Links
Code private (client work)
01
Architecture
- Web UI: the recruiter enters a job description and uploads a set of resume PDFs.
- FastAPI: the backend is organised as layered services, with parsing, scoring and matching kept apart so each can be changed on its own.
- Batch matcher: resumes are processed asynchronously, 10 at a time, so a large batch neither runs one by one nor floods the LLM API.
- PyMuPDF extracts the text from each PDF.
- Gemini parser: Gemini turns the raw text into structured fields such as skills and experience.
- Rate limiter: every Gemini call passes through a limiter, so a big batch stays inside the API quota instead of failing halfway.
- Scorer: each resume gets 0.4 × skills match + 0.3 × experience match + 0.3 × embedding similarity. The parts are kept, so a recruiter can see why a candidate ranks where they do.
- MiniLM: the resume and the job description are split into chunks and embedded with a small MiniLM sentence-transformer model; their similarity is the semantic part of the score.
- WebSocket: progress for each resume is pushed to the browser as it finishes, so the recruiter sees results arrive instead of waiting on one long request.
02
Key decisions
A fixed, readable score instead of asking the LLM to rank
The LLM only extracts fields; the ranking comes from a formula with fixed weights (0.4 / 0.3 / 0.3). The same resume always gets the same score, and the parts explain the result. The cost is that the weights are a judgement call, not learned from hiring outcomes.
Bounded concurrency with a rate limiter
Running 10 resumes at a time keeps the batch moving without waiting on each LLM call in turn, and the limiter keeps Gemini calls under quota so a batch does not fail with rate-limit errors. The cost is that throughput has a hard ceiling set by the API quota, however many resumes are queued.
Chunked embeddings, not one vector per resume
Embedding chunks lets a strong match in one section count, instead of being averaged away by the rest of the resume. The cost is more vectors to compute per resume, and a choice of how chunk similarities add up to one number.
03
Results
- Screens a whole batch of PDF resumes against a job description in one run, with progress pushed live to the browser.
- Every score breaks down into skills, experience and semantic parts that a recruiter can check.
- Gemini calls are paced by a rate limiter and bounded concurrency, so large batches do not hit the API quota all at once.
04
What I'd do next
- Collect recruiter decisions on screened batches and use them to check, then tune, the 0.4 / 0.3 / 0.3 weights.
- Validate the Gemini output against a schema and retry or flag resumes whose parse is incomplete.
- Cache parses by file hash, so a resume uploaded for a second job is not parsed again.