Skip to main content

CV Search Engine

Hakan Ates
Author
Hakan Ates
I build full-stack applications and LLM-powered tools.
A self-hosted semantic search engine for CV/profile data. It replaced manual PDF skimming and keyword-only lookup with hybrid vector + keyword search, and the whole pipeline runs on local infrastructure so candidate data never leaves the network.
Search UI
The search view: a natural-language query, study-level and industry filters, and an expanded candidate profile. Recreated with synthetic data; the production instance is under NDA.

Highlights
#

  • Built and shipped an internal semantic search engine for CV data (FastAPI, Elasticsearch, sentence-transformers) that replaced manual PDF skimming and keyword-only lookup.
  • Designed the hybrid retrieval layer by hand as a single Elasticsearch bool query: cosine script_score over 768-dim embeddings plus weighted keyword match, tunable per request.
  • Kept the entire stack local for privacy, since CV data could not leave the infrastructure: sentence-transformer embeddings for search, and small local LLMs (Gemma 3, Phi-4) in the companion extraction pipeline.
  • Handled messy real-world input at ingestion: per-PDF heuristics decide when a scanned CV needs OCR, and multipass extraction fits the small context windows of local models.

Architecture / How it works
#

The full pipeline inside local infrastructure: OCR routing, multipass local-LLM extraction, local embeddings and hybrid search — no path to cloud AI

The whole pipeline inside the boundary: per-PDF heuristics decide which CVs need OCR, local LLMs extract in multiple passes to fit their small context windows, and the search service embeds and queries entirely locally. The red edge is the point — there is no path by which candidate data reaches cloud AI.

Portfolio Case Study
#

Problem. The team had a growing pile of candidate CVs as PDFs. Finding “someone senior with microeconomics background in Rome” meant opening files one by one or hoping an exact keyword appeared. The CVs are personal data, so a cloud AI service was off the table from the start.

Approach. The system is two cooperating parts. An extraction pipeline turns raw PDFs into structured JSON using only local models, and this project indexes that JSON into Elasticsearch and serves hybrid search through a FastAPI backend with a small Italian-language web UI.

The retrieval layer is hand-built. I started from LangChain’s ElasticsearchStore but its retriever couldn’t deliver the combination I needed: weighted semantic-plus-keyword scoring, facet filters running in the ES filter context, offset pagination, and the full original CV document coming back with each hit. So the query is assembled directly as an ES bool query, with cosine similarity as a script_score clause and keyword match as a second should clause, each carrying a caller-tunable boost. The API exposes those weights, so a search can slide between pure semantic and pure keyword per request.

Privacy drove the model choices. Embeddings come from a local all-mpnet-base-v2 model rather than an API, and the extraction stage runs on small self-hosted LLMs. The cost of that choice showed up elsewhere, which is where most of the real work went.

What was hard. The main issue was ingestion, and deciding what to ingest using the local AI. Some CVs had no real text, only rasterized pages, so we had to know when to use OCR and when not to. That became a set of PyMuPDF heuristics per PDF (average characters per page, share of pages with text, share of pages dominated by a full-page image) that routes scanned documents through Docling with forced EasyOCR and leaves clean ones on the fast path. The local models brought a second constraint: small context windows. Extraction had to become multipass in order not to kill the small context of Gemma 3 and Phi-4, feeding the document through in stages instead of one prompt.

On the search side, the ES mapping took iteration: dense vectors, keyword facet fields, and the complete original CV stored as a non-indexed object in metadata so the UI gets full profiles back without a second lookup.

Outcome. Shipped internally and used by the team, replacing manual skimming and exact-match search with a pipeline that is completely local. The service runs as two Docker Compose containers (API and Elasticsearch, with healthcheck-gated startup), carries JWT cookie authentication with separate admin and user roles, and ships with a Postman collection covering the API.

Limits and non-goals. Search starts from structured JSON; PDF-to-JSON extraction is a separate upstream pipeline by design. Auth is minimal: two fixed accounts from environment config, no user management, because it is an internal tool. There is no candidate-tracking or ATS functionality, just search and retrieval. The UI is Italian-first, and the codebase has no automated test suite — it was built and shipped in a short focused window.

Screenshots
#

All screenshots are faithful recreations of the UI with synthetic candidate data; real deployment screenshots can’t be shared under NDA.

Candidate detail
An expanded result: personal data with the authenticated PDF view, education with level tags, and experience entries with skill chips.
Login page
The login gate. JWT cookie auth sits in front of every search and PDF route.

Stack
#

Python, FastAPI, Elasticsearch 8, LangChain, sentence-transformers (all-mpnet-base-v2), Hugging Face embeddings, JWT (python-jose, passlib/bcrypt), Docker Compose, Tailwind, jQuery/Select2. Upstream extraction: PyMuPDF, Docling, EasyOCR, local LLMs (Gemma 3, Phi-4 with LMStudio).