Logo
Back to blog
9 min read

Building a RAG-Powered Portfolio with Django and pgvector

How I built an AI assistant that answers questions about my CV using retrieval-augmented generation.

DjangoLLMpgvectorNext.jsRAG

Every developer portfolio looks the same. A hero section, a skills grid, a list of projects, a contact form. A recruiter lands on yours, spends 30 seconds scanning, and moves on.

I wanted something different. Instead of making visitors read my CV, I wanted them to talk to it.

This is the story of how I built an AI assistant embedded in my portfolio — one that answers natural language questions about my experience, skills, and background, grounded entirely in my actual CV. No hallucinations, no generic answers. Just a system that knows what I've done and explains it clearly.


The idea: RAG in plain English

Before diving into code, let me explain the core concept because it's what every decision in this project flows from.

A naive approach would be to paste my entire CV into a prompt and ask the LLM to answer questions. That works — until your CV grows, the context window fills up, costs spike, and the model starts confusing details.

RAG (Retrieval-Augmented Generation) solves this differently:

  1. Break your CV into small chunks and store them as vector embeddings
  2. When a question comes in, find the chunks most semantically similar to it
  3. Send only those relevant chunks to the LLM as context
  4. The LLM answers using only what you gave it

The result is accurate, cheap, and scales cleanly. The LLM never has to read the whole document — only the parts that matter for each specific question.


Why these technologies, not others

This is the question I care most about. Not what I used — but why.

Django over FastAPI or Node.js

I've worked with Django professionally. I know its patterns, its ORM, its admin panel. When you're building something that needs to ship and actually work — not just look good in a README — familiarity matters.

Django also gave me something valuable out of the box: the admin panel. I wanted to monitor every visitor query — what they asked, what the system answered, how long it took. Django admin gave me that visibility without writing a single line of dashboard code.

FastAPI would have been faster to write. Node.js would have been more fashionable. But Django let me focus on the RAG logic instead of rebuilding infrastructure I already had.

pgvector over Pinecone, Weaviate, or Qdrant

The dedicated vector databases are impressive. Pinecone is fully managed. Weaviate has a beautiful API. But they all have the same problem for a portfolio project: they're separate services with their own pricing, their own auth, their own operational overhead.

I was already using PostgreSQL. pgvector is a PostgreSQL extension — one line to install, one migration to enable, zero new services to manage. My vector search lives in the same database as my visitor logs, my portfolio chunks, everything. One connection string. One backup. One thing to understand.

The performance difference at my scale — a CV with 9 chunks — is irrelevant. pgvector handles millions of vectors. I have nine.

There's also a deeper reason. pgvector forces you to understand what's actually happening: you're storing float arrays in a table column and running cosine similarity queries. No magic. No black box. When something breaks, you know exactly where to look.

Groq over OpenAI or Anthropic

Simple: Groq's free tier gives 14,400 requests per day with Llama 3. My portfolio doesn't need GPT-4 intelligence — it needs to accurately describe my work experience from provided context. Llama 3 on Groq does that well, fast (300+ tokens/second), and free.

I use Groq twice per query: once for the guardrail classifier (5 tokens — is this question relevant?), and once for the actual answer generation. The entire pipeline costs me nothing.

HuggingFace Inference API for embeddings

sentence-transformers/all-MiniLM-L6-v2 is the right model for this task — it produces 384-dimensional embeddings optimized for semantic similarity, it's fast, and it's free via HuggingFace's Inference API.

I briefly considered running it locally. Then I remembered that running a model locally means every developer who clones this repo needs 80MB of model weights and a compatible environment. The API call takes 200ms. The developer experience is worth 200ms.


The architecture

Here's how a visitor query flows through the system:

Visitor: "What backend technologies does Imad know?"
         ↓
1. Guardrail check (Groq, 5 tokens)
   → Is this about the portfolio? YES → continue
   → Is this off-topic? NO → polite rejection

2. Embed the query (HuggingFace API)
   → "What backend technologies does Imad know?"
   → [0.023, -0.041, 0.187, ...] (384 floats)

3. Vector search (pgvector)
   → Cosine similarity against all CV chunks
   → Returns top 6 most relevant chunks

4. Prompt assembly
   → System prompt + retrieved chunks + query

5. LLM generation (Groq, Llama 3)
   → Grounded answer from context only

6. Log to database
   → Question, answer, chunks used, response time, IP

The whole pipeline runs in under 2 seconds on a cold start.


The hardest parts

1. The two-column PDF problem

My CV is a two-column layout. PyMuPDF reads PDFs line by line, which means a naive extraction jumbles both columns together — experience bullet points mixed with education text mid-sentence.

The fix was clipping: extract each column independently by defining rectangles based on page width, then concatenate them in 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_text

The 0.62 split ratio came from inspecting my specific CV layout. If you're adapting this, measure your own column boundary.

2. The guardrail tuning problem

The first version of the guardrail rejected "who is Imad?" — a perfectly valid portfolio question. It also rejected "Bonjour" — a French greeting from a visitor.

The issue is that a zero-shot LLM classifier doesn't know the context of your portfolio unless you explicitly tell it. I iterated on the system prompt several times, adding explicit examples of what's relevant:

Relevant: who is this person, personal introduction, work experience,
contact info, email address, how to reach Imad, about the developer

The lesson: guardrail prompts need the same care as generation prompts. Being vague gets you false rejections.

3. The HuggingFace API change

I originally called the HuggingFace inference API directly with requests.post. It returned a 404. The endpoint had changed.

The fix was switching to the official huggingface_hub client and using feature_extraction instead of the old endpoint:

from huggingface_hub import InferenceClient
 
client = InferenceClient(provider="hf-inference", api_key=HF_TOKEN)
 
def embed(text: str) -> list[float]:
    result = client.feature_extraction(
        text,
        model="sentence-transformers/all-MiniLM-L6-v2",
    )
    # flatten nested list [[v1, v2, ...]] → [v1, v2, ...]
    return result[0] if isinstance(result[0], list) else result

The nested list response is a gotcha — the API returns [[...]] not [...]. Without the flatten, pgvector rejects the vector dimension.

4. Retrieval quality

Early on, a question like "what is Imad's latest experience?" would retrieve the internship (2023) instead of the current role (2024). The embeddings don't understand chronology — they understand semantics.

Two fixes:

  • Increased TOP_K from 4 to 6 — with only 9 chunks, being generous costs nothing and ensures all experience chunks are retrieved
  • Added an explicit instruction in the system prompt to prioritize roles marked "Present" when recency is implied

What I would do differently

If I started over, I would separate the indexing pipeline from the Django app entirely. Right now index_portfolio is a management command that runs on every deploy. It works, but it couples two concerns: the application serving queries, and the offline job processing the CV.

In a more mature version, the indexing would be a separate script triggered manually or by a webhook when the CV changes — not something that runs on every server start. It makes deploys slower than they need to be and creates an unnecessary dependency between the CV file and the running service.

I'd also think more carefully about chunking strategy from the start. Splitting by CV section (experience, skills, education) is simple but coarse. A smarter approach would chunk at the individual achievement level — each bullet point becomes its own embedded unit. That would make retrieval much more precise for specific questions.


The result

The system works well. Questions like "what frameworks does Imad know?", "where has he worked?", "how can I contact him?" all return accurate, natural-sounding answers grounded in my actual CV.

Off-topic questions — "what's the capital of France?", "write me a Python script" — get politely redirected. Greetings in English, French, and Arabic get a warm welcome.

Every query is logged: the full question text, the answer, which chunks were retrieved, the visitor's IP, and the response time in milliseconds. Over time, that log tells me exactly what recruiters and visitors actually want to know about my background — which is more useful than any analytics dashboard.


The bigger lesson

The most important decision in this project wasn't which LLM to use or which vector database to pick. It was the decision to keep the stack boring.

PostgreSQL for everything. Django for the API. Free tiers for external services. The complexity budget went entirely into the RAG logic — the part that actually creates value.

Every technology choice in this project has a clear answer to the question: why this, not something else? That's the standard I try to hold myself to. Not "what's popular" or "what looks good in a README" — but "what solves this specific problem with the least friction."

If you're building something similar, start with what you know, add only what you need, and make sure you can explain every choice. The best system design is the one you can defend.