How I built karbaschian.com: a résumé you can ask questions
FastAPI, PostgreSQL with pgvector, Next.js and the Claude API: how the site answers only from my résumé, shows which parts it used, tailors itself to a job ad, and stays fast and locked down on one small server.
A PDF résumé answers the questions I thought of. A recruiter usually has different ones: has he worked with PostgreSQL in production? what did the Odoo migration involve? would he fit a support-heavy role? I wanted a site that answers those questions honestly, from my résumé and nothing else, and that can show a hiring manager the parts of my experience that match their job ad.
This post walks through how it works. The code is in a private repository; I am happy to walk through it in an interview.
The stack
- Back end: Python with FastAPI and SQLAlchemy, PostgreSQL 16 with the pgvector extension.
- Front end: Next.js 15 (App Router) with React 19, TypeScript and Tailwind CSS.
- Models: the Claude API (a small, fast model) for answers, and Voyage embeddings for search.
- Running it: Docker Compose on one small Oracle Cloud virtual machine, nginx in front with Let's Encrypt certificates, and GitHub Actions running the tests on every push.
Everything a visitor sees (texts, sections, colours, projects) is edited in a private admin panel, not in code.
Answering only from the résumé
The chat on the home page is retrieval-augmented generation (RAG), kept deliberately small:
- The résumé is split into chunks: one per job, project, skill group and education entry.
- Each chunk is turned into a vector (an embedding) and stored in PostgreSQL with pgvector.
- A question is embedded the same way, and the six closest chunks by cosine distance become the context.
- The model gets strict rules: answer only from the context, never invent employers, dates, numbers or certifications, and say so plainly when the résumé does not cover the question.
The retrieval itself is a single query:
def _context(qvec: list[float], k: int = 6) -> tuple[str, list[dict]]:
with get_session() as s:
rows = s.execute(
select(Chunk).order_by(Chunk.embedding.cosine_distance(qvec)).limit(k)
).scalars().all()
sources = [{"label": f"S{i}", "id": c.id, "kind": c.kind, "title": c.title}
for i, c in enumerate(rows, 1)]
ctx = "\n\n".join(f"[{src['label']}] ({c.kind}) {c.title}\n{c.body}"
for src, c in zip(sources, rows, strict=True))
return ctx, sources
Showing its sources
An answer you cannot check is not worth much, so every answer shows which parts of the résumé it used. Each
context chunk is labelled S1 to S6, and the model must end its answer with a marker listing the labels it
relied on. The back end strips that marker, keeps only labels that really were in the context, and sends the
matching sources in the final event of the streamed response. The front end hides the marker while the text
streams in and then shows the sources as small "based on" chips under the answer.
Questions are logged (without personal data about the visitor), and answers where the model had to say "I don't have that information" are flagged. That list tells me what the résumé is missing.
A page tailored to each job ad
A visitor, or I, can paste a job ad. The model turns it into a small structured profile (role, must-have and nice-to-have skills, what the team seems to value). Then each résumé chunk is scored against it:
score = 0.7 × cosine similarity + 0.3 × keyword overlap with the must-have and nice-to-have lists
The result is a shareable page at /for/<name> that reorders and highlights the matching experience. Nothing on
it is rewritten: the page only changes order and emphasis, plus one short "why this fits" paragraph that is
limited to facts from the résumé. Each page has its own link-preview image, is kept out of search engines, and
counts its views so I can see when an application was opened.
The admin panel
The admin panel is a separate part of the Next.js app that talks to a JSON API:
- Content: every section of the résumé, with validation (for example, every link must be
https://) and a version history where any earlier save can be restored. - Design and texts: colours, section order and labels.
- Analytics without cookies: a visitor is a salted hash of the day, IP address and browser, so the same person counts once per day and nobody can be followed across days. No third-party scripts.
- Questions: what people asked, and which answers hit a gap.
- Search: Google Search Console numbers (searches, clicks, positions) pulled with a read-only service account.
Security choices
- A Content Security Policy with a fresh nonce on every request and
'strict-dynamic', so only the site's own scripts run. - The admin session is a signed, HttpOnly, SameSite=Strict cookie that expires after 12 hours; every write also needs a custom header, which a cross-site form cannot send.
- Two-step sign-in for the admin panel: a code from an authenticator app after the password, with one-time recovery codes.
- nginx rate-limits the model-backed endpoints and the sign-in, and sends HSTS and the usual security headers.
- Secrets live only in an environment file on the server; uploads are checked by type and size.
Making it fast
The first Lighthouse run on mobile scored 64 for performance. The fixes were all ordinary ones:
- Self-hosted fonts through
next/font, instead of a request to an external font service. - Lazy-loaded animation code: the animation library's features load after the page is interactive.
- A properly sized photo: the back end resizes the uploaded photo to a few fixed widths, converts it to WebP
and caches it, and the page picks the right size with
srcset. - Fewer endless animations, which keep the browser busy for nothing.
In a Lighthouse run against a local production build, performance went from 64 to 96, with accessibility at 96, and best practices and SEO at 100.
Tests and deploys
The back-end tests run against a real PostgreSQL with pgvector, with fake embedding and chat providers so they are fast and free. They check the rules that matter most: nothing invented reaches the page, citations only point to real sources, links are safe. The front end is type-checked and built on every push.
Deploying is one script on the server: pull the code, rebuild the containers, reload nginx, and re-embed the résumé so the chat is always answering from the latest version.
What I would do next
- A small, fixed set of test questions with expected answers, run on every change, to catch a prompt edit that makes answers worse.
- Showing a short quote from each cited source, not just its title.
If you are hiring for Python, Django or Odoo work and want to see how I approach a problem end to end, this site is a fair sample. You can ask it about me on the home page.