
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_scoreover 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 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.


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).
