Portfolio AI Assistant
An intelligent RAG-powered assistant that answers visitor questions about my background, skills, and experience.
Most portfolios make you do the work. You scroll, you scan, you piece together who someone is from a list of bullet points. I wanted to reverse that — let visitors just ask.
This is the project I'm most proud of technically. Not because it's complex, but because every decision in it has a clear reason behind it.
What it does
A visitor lands on my portfolio and clicks AI Assistant. They type a question — "what companies has Imad worked for?", "does he know Docker?", "how can I reach him?" — and get a natural, accurate answer in under two seconds. The answer comes directly from my CV. Nothing is made up.
Behind the scenes, a full RAG pipeline runs on every query: the question gets embedded into a vector, the most semantically relevant chunks of my CV get retrieved from PostgreSQL, and Llama 3 generates a grounded answer using only that context.
Architecture overview
Visitor query (Next.js)
↓
Guardrail classifier — Groq (5 tokens, ~50ms)
↓ relevant
Embed query — HuggingFace all-MiniLM-L6-v2 (384-dim)
↓
Vector similarity search — pgvector cosine distance (top 6 chunks)
↓
Prompt assembly — system prompt + context + query
↓
Answer generation — Groq Llama 3 (max 500 tokens)
↓
Log to PostgreSQL — question, answer, chunks used, response time
↓
JSON response → Next.js frontend
Two things I want to highlight in this flow:
The guardrail runs first. Before any embedding or retrieval happens, a cheap Groq call (5 tokens, temperature 0) classifies whether the question is relevant to my portfolio. Off-topic questions — "write me a Python script", "what's the capital of France" — get rejected immediately without touching the vector database. This keeps the system focused and the API costs minimal.
Every query is logged. Question, answer, which CV chunks were retrieved, response time in milliseconds, visitor IP. Over time this log tells me exactly what people actually want to know about my background — more useful than any analytics tool.
The CV indexing pipeline
The knowledge base isn't typed by hand. It comes from my actual CV — a two-column PDF.
The first challenge was extraction. PyMuPDF reads PDFs line by line, which means a two-column layout gets jumbled — experience bullet points mixed with education text mid-sentence. The fix was column clipping: extract each column independently using page coordinate rectangles, then concatenate in reading order.
def extract_two_column_text(pdf_path):
doc = fitz.open(pdf_path)
full_text = ""
for page in doc:
width = page.rect.width
sidebar_width = width * 0.62
left_col = fitz.Rect(0, 0, sidebar_width, page.rect.height)
right_col = fitz.Rect(sidebar_width, 0, width, page.rect.height)
full_text += page.get_text("text", clip=left_col)
full_text += page.get_text("text", clip=right_col)
return full_textAfter extraction, a section parser splits the text by CV headings (EXPERIENCE, SKILLS, EDUCATION...) and labels each chunk with its source. The experience section gets further split — one chunk per job role — so a question about a specific company retrieves only that role, not all three jobs at once.
Each chunk then gets embedded via the HuggingFace Inference API and stored in pgvector as a 384-dimensional vector. The full pipeline runs as a Django management command:
python manage.py index_portfolioRe-run it whenever the CV changes. The whole thing takes about 30 seconds.
Tech stack decisions
Django — I chose Django because I know it well and because the admin panel gave me visitor query monitoring for free. No dashboard to build, no custom UI. Every question a visitor asks shows up in a table with status, response time, and retrieved chunks.
pgvector — I was already using PostgreSQL. pgvector is an extension, not a new service. One migration to enable it, one model field to store vectors, one ORM query to search them. Adding Pinecone or Weaviate would have meant a new service, new credentials, new failure mode — for a problem PostgreSQL already solves.
Groq — 14,400 free requests per day, 300+ tokens per second, Llama 3. For a portfolio assistant that answers factual questions from provided context, this is more than enough. I use it twice per query: once for the guardrail (5 tokens), once for generation (up to 500 tokens).
HuggingFace Inference API — all-MiniLM-L6-v2 produces 384-dimensional embeddings optimized for semantic similarity. Running it locally would require 80MB of model weights on every machine. The API call takes ~200ms. The tradeoff is obvious.
What the data looks like
After indexing, the database holds 9 chunks:
| Source | Content preview |
|---|---|
| identity | Name, title, contact info |
| experience | Full Stack Engineer @ Kubicbits |
| experience | Frontend Engineer @ Veylinx |
| experience | Graduate Internship @ Kubicbits |
| education | Master's in Computer and Mobile Systems |
| education | B.S. in Computer Engineering |
| skills | Frontend + Backend + DevOps stack |
| certifications | HackerRank React |
| languages | Arabic, English, French, Spanish |
Each chunk has a 384-float embedding vector. Retrieval is a cosine distance query returning the 6 most similar chunks to the question vector.
Challenges worth mentioning
Retrieval quality for temporal questions. "What is Imad's latest experience?" initially returned the 2023 internship instead of the current 2024 role. Embeddings understand semantics, not chronology. The fix was two-part: increase TOP_K to 6 so all experience chunks are always retrieved, and add an explicit system prompt rule to prioritize roles marked "Present" when recency is implied in the question.
Guardrail false rejections. The first version rejected "who is Imad?" and "Bonjour". The guardrail didn't know these were portfolio-relevant without explicit guidance. Iterating on the classifier system prompt — listing concrete examples of relevant topics — fixed both cases. Greetings in English, French, and Arabic now get a warm welcome instead of a rejection.
The HuggingFace endpoint change. The direct requests.post call to the inference API returned a 404 — the endpoint had changed. Switching to the official huggingface_hub client and feature_extraction method fixed it. The response also comes back as a nested list [[v1, v2, ...]] that needs flattening before pgvector will accept it.
Result
A portfolio that answers questions instead of making visitors read. A system grounded entirely in real data with no hallucinations. Full observability over every visitor interaction. Zero ongoing cost.
And a project where I can explain every technical decision — not just what I used, but why.