Problem
Recruiters were reading every resume by hand. Keyword filters were the only automation available, and keyword filters fail exactly where it matters: a "React engineer" never matches a CV that says "built SPAs in Next.js". Screening a single role meant hours of linear reading, and good candidates were being rejected by string matching.
Solution
A recruitment platform that reads job descriptions and resumes as meaning rather than text. Documents are parsed, chunked and embedded into a vector store; candidate matching is a retrieval problem, not a filter problem. On top of retrieval, an LLM produces a structured analysis per candidate — strengths, gaps, and evidence quoted from the resume — so a recruiter reviews reasoning instead of raw documents.
Key engineering decisions
- 01Retrieval before generation, alwaysThe LLM never sees a full resume corpus. Vector search narrows to a small candidate set first, and only that set is expanded into a prompt. This caps token cost per request at a predictable ceiling and keeps the model grounded in retrieved text instead of improvising.
- 02A separate Python service instead of Node bindingsThe AI work belongs where its ecosystem lives. Isolating it behind an HTTP boundary means the model layer can be redeployed, rate-limited and scaled independently of the CRUD services — and a slow embedding job cannot starve the Node event loop.
- 03Embedding as an idempotent background jobUploads return immediately with a document ID. Parsing and embedding run through a queue, keyed by content hash, so a retry never produces duplicate vectors and a failed batch resumes rather than restarts.
- 04Structured output, not free textCandidate analysis is requested as a strict schema and validated on arrival. Anything that fails validation is retried once and then degraded to retrieval-only results — the UI shows matches without commentary rather than showing something malformed.
Challenges
- Resumes are hostile documentsTwo-column PDFs, tables, images of text, and inconsistent section headers. Naive extraction interleaves columns and produces nonsense chunks. Layout-aware extraction with a per-format fallback chain fixed the majority; anything below a confidence threshold is flagged for manual review rather than silently indexed badly.
- Semantic match is not the same as a good matchPure cosine similarity happily ranks a senior architect and a junior with the same vocabulary as near-identical. Retrieval had to be combined with hard structural filters — years of experience, location, work authorisation — applied before ranking, not after.
Trade-offs
- A managed LLM API over A self-hosted open-weights modelQuality per unit of engineering time was decisive at this stage. The generation layer sits behind an interface, so moving it in-house later is a swap, not a rewrite.
- Qdrant as a dedicated vector store over pgvector inside the existing PostgreSQLPayload filtering combined with ANN search at this corpus size is where pgvector starts to strain. The cost is one more system to operate.
Outcome
Screening moved from linear reading to reviewing a ranked, explained shortlist. Matching survives vocabulary mismatch — the failure mode keyword filters could never fix. The AI layer is replaceable: retrieval, ranking and generation are three separate seams.