Let me show you how I built a custom AI-powered chatbot for my Gatsby portfolio that’s lightweight, smart, and free to run. It can answer questions about me based on the actual content of my portfolio.
Largely inspired by Melvin Prince’s dev post and built on the criteria that it’s fast, efficient, affordable (ideally free), this AI (officially dubbed “EVE” after my adorable Beagle pup 🐶) integrates cleanly with my existing Gatsby portfolio.
Today, I’m sharing exactly how I pulled this off. I’ll break down the steps, what I learned, and what I’d do differently if I were starting from scratch.
Along the way, I got my hands dirty with concepts like vector embeddings, serverless functions, and practical conversational AI that I’d love to share with you all.
If you’re considering adding a chatbot to your site, this post is for you 👌
Why Build a Chatbot for Your Portfolio?
The goal wasn’t just to slap a chatbot on the page because “AI is cool”. I had four main goals in mind:
- Real interaction: Let visitors ask questions instead of just clicking around.
- Personal touch: Tailor responses based on my actual experience.
- Efficiency: Build it without paying hefty monthly fees.
- Maintainability: Keep the tech stack simple and consistent.
Turns out it’s very doable, if you set it up right. Plus, it genuinely makes the browsing experience more personal and interactive. It lets visitors ask specific questions they have without digging through multiple pages.
Plus, building a chatbot is a fantastic way to apply real-world AI concepts to a personal project!
I’ve been an end user of AI since it went commercial and have created a strong base of communicating effectively with models. However, I never ventured into the weeds of it until now.
Trust me when I tell you there’s no way you “understand” AI until you realize how it feeds on information 🤯
Step 1: Getting Your Content Ready
Before touching any code, I needed clean data.
Not that my portfolio content was messy (it was in structured JSON format), mind you. However, I mistakenly processed fragmented content, losing relationships among related material (we’ll resolve this later).
Note ‼️
AI can’t generate good answers from garbage input. Good, clean, and structured content is critical as AI models perform better with concise, organized input. Fragmented or sprawling content results in less coherent answers.
If you need to clean up your data, restructure it, or make any additions or modifications, this is the time to do it.
I “refreshed” my portfolio data by consolidating all the relevant information–projects, skills, bio–into a uniform structure consisting of logical fields grouped as JS objects.
Step 2: Setting Up The Backend With Supabase
Supabase became my backend of choice for two reasons:
- pgvector extension: A PostgreSQL extension that allows you to store vector embeddings directly in your database and perform fast similarity searches on them (you’ll see the importance of this in a bit).
- edge functions: These are lightweight, serverless functions that can run your backend logic close to your users, which really helps improve performance.
Here’s the basic setup:
- Initialize Supabase locally using
npx supabase init
. This creates a supabase directory in the project root with some config files. - Create a project online:
- Sign up at supabase.com
- Create a new project
- Grab your API keys (Settings > API)
- Create your tables and functions: I created three main tables and one search function.
- Tables:
portfolio_content
,chat_sessions
,chat_cache
- RPC function:
match_portfolio_content
(performs similarity search)
- Tables:
The table breakdown is as follows:
- portfolio_content: This table stores content chunks and their corresponding vector embeddings
- chat_sessions: Used to track unique visitors and their chat sessions
- chat_cache: Stores pairs of questions and their generated answers to quickly respond to repeat queries
What is an RPC function in this context?
So, what the heck is an “RPC function”?
RPC (Remote Procedure Call) functions are predefined SQL procedures you can call from your application. In this case, match_portfolio_content
takes an embedding of a user query and quickly finds similar content from the portfolio_content
table based on vector similarity.
Using an RPC speeds up retrieval because Supabase handles the search server-side, eliminating the need to manually scan all rows with every query. I think of it as a function that can act on the data at the retrieval level, eliminating the need to get all the data and perform the search in the application.
-- RPC for matching portfolio content
CREATE OR REPLACE FUNCTION match_portfolio_content(
query_embedding VECTOR(768),
match_threshold FLOAT,
match_count INT
)
RETURNS TABLE (
id INT,
content TEXT,
content_type VARCHAR,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
portfolio_content.id,
portfolio_content.content,
portfolio_content.content_type,
1 - (portfolio_content.embedding <=> query_embedding) AS similarity
FROM portfolio_content
WHERE 1 - (portfolio_content.embedding <=> query_embedding) > match_threshold
ORDER BY similarity DESC
LIMIT match_count;
END;
$$;
Tip: Use clear naming for your columns: id, content, embedding, created_at, etc.
Once everything was set up, I could see my tables in Supabase’s database viewer.
Please note (this is super important): The vector dimension (the size or length of the numerical array representing the embedding) used in the database must exactly match the dimension output by the AI model used for input embeddings.
If they don’t match, your similarity searches will simply fail.
Note ❌
Even models with the same stated dimension size can sometimes have subtle discrepancies making them incompatible. I learned this the hard way.
Step 3: Choosing an AI Model (Gemini vs Hugging Face)
At first, I tried using Hugging Face’s inference API, but I encountered two significant problems:
- Some models were not consistently available through their inference endpoints
- Free-tier rate limits made testing frustrating
So I switched to Google Gemini. It’s stable, fast, and the free tier is generous enough for portfolio-level usage.
The setup involved:
- Creating a Google AI developer account
- Generated an API key
- Set up basic auth in my Edge Function to call Gemini
No regrets. Though don’t get me wrong, I marveled at the @xenova/transformers mistralai/Mistral-7B-Instruct-v0.2 model and was only forced by circumstances to overhaul to Gemini.
And, yes, it was a total overhaul since the models don’t share vector dimensions 🤦♀️
Understanding Vector Embeddings
Before moving further, it’s worth pausing to explain what vector embeddings are.
When you feed text into an AI model, you can ask it to output an embedding: a numerical representation of the semantic meaning of that text. Similar ideas map close together in the resulting vector space. Text with very different meanings will be far apart.
Tip ✨
Think of a vector embedding as a compressed, mathematical way of capturing the semantic meaning of the text.
And you thought you’d never use that advanced calculus, huh?
In this project, we leverage embeddings in two main ways:
- Each piece of portfolio content was converted into a vector
- When a visitor asks a question, the question is also embedded
- I perform a similarity search, finding which pieces of my content are closest to the user’s query
We perform the similarity search using Supabase’s pgvector extension and our match_portfolio_content
RPC function.
The RPC function code looks for stored content embeddings that are mathematically closest to the user’s question embedding. Because this is based on the mathematical similarity of meaning, it allows for semantic search. This means the chatbot can understand different ways a visitor might ask the same question, going beyond simple keyword matching.
Note 🤓
If you’re struggling with the concept, you’re not alone. I’m also still wrapping my head around it and I highly recommend doing more research to expand your horizons!
To recap: using embeddings avoids the need for rigid keyword matching. It enables semantic search, allowing the chatbot to understand different ways of asking the same question.
It’s why establishing proper data relationships is vital in having the model move fluidly through the content.
Step 4: Embedding the Portfolio Content
With structured data and a model ready, the next step is turning that content into vectors and storing them in Supabase.
This involved:
- Writing a Node.js script to load the JSON data (i.e., my portfolio content)
- Recursively traversing folders to gather all portfolio files
- Sending each content chunk to the Gemini embedding API
- Saving the text and the returned vector into the
portfolio_content
table
async function processDataRecursively(data, contentType, parentKey = '') {
if (Array.isArray(data)) {
// Process each item in array
for (let i = 0; i < data.length; i++) {
await processDataRecursively(
data[i],
contentType,
parentKey ? `${parentKey}[${i}]` : `[${i}]`
);
}
} else if (data !== null && typeof data === 'object') {
// Process each property in object
for (const [key, value] of Object.entries(data)) {
await processDataRecursively(
value,
contentType,
parentKey ? `${parentKey}.${key}` : key
);
}
} else if (typeof data === 'string' && data.trim().length > 3) {
// Create embedding for text content with reasonable length
const content = data.trim();
const contextKey = parentKey || 'general';
// Skip short text content within certain keywords
if (
content.length < MIN_LENGTH &&
!/^(ai|ml|ui|ux|js|go)$/i.test(content)
) return;
// Skip if already seen
if (seenTexts.has(content)) return;
seenTexts.add(content);
// Skip if already in DB
const exists = await checkIfContentExists(content, `${contentType}:${contextKey}`);
if (exists) {
console.log(`${contentType}:${contextKey} content already exists`);
return;
};
// Push to queue
console.log(`Pushing ${contentType}:${contextKey} content to queue`);
embeddingQueue.push({ content, content_type: contextKey });
// Create embedding
if (embeddingQueue.length >= BATCH_LIMIT) {
const batch = embeddingQueue.splice(0, BATCH_LIMIT);
const embeddings = await initEmbeddingModel(batch);
for (let i = 0; i < batch.length; i++) {
const { content } = batch[i];
const embedding = embeddings[i].values;
await upsertPortfolioContent({
content,
contentType,
contextKey,
embedding,
});
}
}
}
}
I found helper functions necessary here, especially because my initial content processing resulted in fragmented data. These helpers organize the fragmented content into a structured format for the prompt, creating groupings. (This area is one I’d improve upon next time to avoid the extra work that arose due to fragmented embeddings.)
Sending content to the API one by one can be slow, and you risk hitting API rate limits or timeouts. I certainly ran into rate limits with Gemini mid-process (and my portfolio data is “light”).
My solution was to batch process the API requests by sending several chunks at once, which is much more efficient and reliable. Gemini’s embedContent
method allows for providing an array of text inputs to enable this batch processing approach.
Tip: Batch your API requests if possible. Otherwise, you risk timeouts and rate-limiting.
Remember, the vector dimension (size of each embedding) must exactly match between what the AI model outputs and what Supabase expects for searches to succeed.
Even models with the same dimensions can have discrepancies and be incompatible. While the dimension (the number of elements in the vector) might be the same, the semantic space represented by the embeddings generated by different models can be different.
This is what I encountered when trying to stay with HuggingFace Inference API models (a sort of hybrid approach to spread free token usage across different models).
Step 5: Building the Chat Edge Function
At this point, we’re ready to build the core logic that handles the chat conversation.
Breaking it down, the Supabase Edge Function:
- Receives an incoming chat request with session ID and user query
- Checks if the same query has already been answered (using
chat_cache
) - If a cached answer exists, the function retrieves the answer from the cache and prepares to send it back incredibly fast
- If there’s no cached answer:
- Embed the user query into a vector using the AI model
- Call
match_portfolio_content
to find similar portfolio entries in theportfolio_content
table - Build a context string by stitching relevant entries to provide the AI model with the necessary background information from the portfolio to formulate an answer
- Send the prompt and context to Gemini
- Store the new Q&A pair in
chat_cache
- Streams the response back to the frontend using Server-Sent Events (SSE) to create a “typing” effect for visitors, making the chatbot feel faster and more interactive
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', {
headers: corsHeaders
});
}
try {
const contentType = req.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return new Response(JSON.stringify({
error: "Content-Type must be application/json"
}), {
status: 400,
headers: {
'Content-Type': 'application/json'
}
});
}
const { query, sessionId } = await req.json();
if (!query || typeof query !== 'string') {
return new Response(JSON.stringify({
error: 'Query is required'
}), {
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json'
}
});
}
// Check if we have a cached response
const queryHash = await generateHash(query.toLowerCase().trim());
const { data: cachedResponse } = await supabaseClient.from('chat_cache').select('response').eq('query_hash', queryHash).maybeSingle();
// Initialize encoder
const encoder = new TextEncoder();
if (cachedResponse) {
console.log('Using cached response');
// Update session if we have a sessionId
if (sessionId) {
await updateSession(sessionId, query, cachedResponse.response);
}
// Return cached response stream
const stream = new ReadableStream({
start(controller) {
const payload = {
text: cachedResponse.response,
isCached: true,
sessionId,
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
controller.close();
}
});
return new Response(stream, {
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
// Create embedding for the query
const embedding = await getGenAiEmbeddingModel(query);
if (!embedding || !Array.isArray(embedding) || embedding.length === 0) {
console.log('No embedding generated – check API token or input text');
throw new Error('No embedding generated – check API token or input text');
}
// Search for relevant content
const relevantContent = await smartSearch(query, embedding);
// Extract relevant content for context
let context = '';
if (relevantContent && relevantContent.length > 0) {
context = generateContext(relevantContent);
}
// Generate response using the model
const promptTemplate = generatePrompt(context, query);
const result = await getGenerateModel(promptTemplate);
// Clean up the response
const response = result.trim();
// Cache the response
try {
const { error: cacheError } = await supabaseClient.from('chat_cache').insert({
query_hash: queryHash,
query: query,
response: response
});
if (cacheError) {
console.error('Error caching response:', cacheError);
}
} catch (err) {
console.error('Exception occurred during caching:', err);
}
// Update session if we have a sessionId
if (sessionId) {
await updateSession(sessionId, query, response);
}
// Send the response stream
const stream = new ReadableStream({
async start(controller) {
// More natural chunking - split by sentences or phrases rather than words
const tokens = response.match(/[^.!?]+[.!?]+|\S+/g) || [];
for (const token of tokens) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: token })}\n\n`));
await new Promise((r) => setTimeout(r, 50));
}
controller.close();
}
});
return new Response(stream, {
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
} catch (error) {
console.error(error);
return new Response(JSON.stringify({
error: 'Internal server error'
}), {
status: 500,
headers: {
...corsHeaders,
'Content-Type': 'application/json'
}
});
}
});
Tip 👀
Ensure there’s enough context without overwhelming the AI model. Too much context can lead to slow responses or confusing outputs; too little context can make answers vague.
Small challenges along the way:
- Adjusting the similarity search parameters (like threshold and matches) dynamically depending on the query
- Making sure SSE streaming works smoothly (this involves custom streaming to handle chunks accordingly)
Step 6: Building the Chatbot Frontend
Now for the fun part: making the chatbot appear on my site. This is the part visitors actually see and interact with on the frontend.
I used React (since Gatsby is React-based 💁♀️), MaterialUI, and kept it modular.
Related: Redesigning My Portfolio and Leveling Up with TypeScript + ESLint
Component breakdown:
ChatbotLoader
– Handles display of chat window and transition from active chat interface to floating button.Chatbot
– Main container housing the core logic for fetching and displaying messages. It handles open/close/minimize, session state, and heavily relies on the customuseChatbot
hook.ChatbotMessage
– Displays a single message differentiating between user and bot messages.
Key features of the AI chatbot:
- Dialog interface with a header, the message list, and a text input area
- Header buttons to minimize or close the chat window
- Real-time display of the conversation history, with the bot messages streaming in via SSE
- Smooth animations for opening and closing the chat window
- Floating button (usually in the bottom-right corner) that appears when the chat is minimized
- Functionality to close the chat and optionally reset the conversation history
To keep the site performant, especially since this is an addition to an existing site, I implemented several performance optimizations:
- Lazy Loading: The entire chatbot component is lazy-loaded, meaning the browser doesn’t download or render its code until the visitor clicks the button to open it, preventing impact on the initial page load speed.
- Memoization: I used
React.memo
for the individualChatbotMessage
component to prevent individual messages from re-rendering unless their specific props change, which is great for long chat histories. I also used useMemo where appropriate, like for optimizing the message list itself, ensuring derived data is only recomputed when its dependencies change. (And if you’re scrambling to cement the difference, I’m right there with you, so keep reading.) - Callback Optimization: Used
useCallback
for all event handlers passed down through props, preventing unnecessary re-creation of functions on every render, helpingReact.memo
anduseMemo
work effectively. - Ensured efficient rendering by structuring components so that only the necessary parts update when the state changes.
- Used smart refs for things like auto-scrolling the chat window as new messages arrive, and automatically focusing the input field after sending a message.
export default function Chatbot({ isOpen, onOpen }: IChatbot) {
const {
messages,
input,
setInput,
isLoading,
isTyping,
handleSubmit,
messagesEndRef,
inputRef,
clearSession,
} = useChatbot();
const [confirmClear, setConfirmClear] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<HTMLDivElement | null>(null);
const cardRef = useRef(null);
const handleExitChat = () => {
setConfirmClear(true);
if (cardRef.current) setAnchorEl(cardRef.current);
};
const handleClear = () => {
clearSession();
onOpen(false);
setConfirmClear(false);
setAnchorEl(null);
};
useEffect(() => {
let timeout: number | NodeJS.Timeout | undefined;
if (isOpen) {
timeout = setTimeout(() => {
inputRef.current?.focus();
console.log('Chat open state changed:', isOpen);
}, 100);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [isOpen]);
const renderMessages = useMemo(() => {
return messages.map((msg, index) => (
<Box
key={`${msg.role}-${index}-${msg.timestamp.getTime()}`}
sx={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
mb: 1.5,
}}>
<MemoizedChatMessage
message={msg.content}
isUser={msg.role === 'user'}
time={msg.timestamp
.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true })
.toLowerCase()}
/>
</Box>
));
}, [messages]);
return (
<Card
sx={{
position: 'fixed',
right: 0,
bottom: '2%',
width: '95%',
minHeight: '320px',
m: 1,
'@media (min-width: 600px)': {
right: '6%',
bottom: '3%',
width: '100%',
m: 0,
},
'@media (max-width: 400px)': {
bottom: '1%',
},
maxWidth: 400,
maxHeight: '75vh',
display: 'flex',
flexDirection: 'column',
borderRadius: 2,
overflow: 'hidden',
zIndex: 1400,
boxShadow: '0 4px 20px rgba(199, 21, 133, 0.15)',
}}
elevation={4}
ref={cardRef}>
{/* Header */}
<CardContent
className="chatbot-header"
sx={{
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: '#c71585', // mulberry-pink
color: 'white',
}}>
<Box
display="flex"
justifyContent="space-between"
alignItems="center">
<Typography variant="h6">
{`Portfolio Assistant${messages.length === 0 ? '' : ': EVE'}`}
</Typography>
<Box>
<IconButton
size="small"
onClick={() => onOpen(false)}
sx={{
color: 'white',
mr: 1,
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' },
}}>
<MinimizeIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleExitChat}
sx={{ color: 'white', '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' } }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</CardContent>
{/* Messages */}
<Box
sx={{
flexGrow: 1,
overflowY: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
maxHeight: 'calc(75vh - 130px)',
}}>
<Box
sx={{
p: 1,
mb: 2,
backgroundColor: '#f8f8f8',
color: '#787878',
fontSize: '0.75rem',
}}>
This portfolio assistant is in beta mode and uses free resources, so responses might be
incorrect or incomplete.
</Box>
{renderMessages}
{isLoading && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
alignSelf: 'flex-start',
backgroundColor: '#ede8efc2', // light lavender/gray from global.scss
borderRadius: 2,
px: 2,
py: 1,
mb: 1.5,
}}>
<CircularProgress
size={20}
color="secondary"
/>
</Box>
)}
{isTyping && messages[messages.length - 1]?.role === 'assistant' && <TypingIndicator />}
<div ref={messagesEndRef} />
</Box>
{/* Input area */}
<Divider />
<form
onSubmit={handleSubmit}
className="chatbot-form">
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 2,
backgroundColor: 'background.paper',
}}>
<TextField
fullWidth
inputRef={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me something..."
disabled={isLoading}
size="small"
sx={{ mr: 1 }}
/>
<IconButton
type="submit"
disabled={isLoading || !input.trim()}
color="primary"
sx={{
color: '#c71585',
'&:hover': { backgroundColor: 'rgba(199, 21, 133, 0.08)' },
'&.Mui-disabled': { color: 'rgba(199, 21, 133, 0.38)' },
}}>
<SendIcon />
</IconButton>
</Box>
</form>
{/* Clear confirmation overlay */}
{confirmClear && (
<Box
sx={{
position: 'absolute',
top: '64px', // Height of the header
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
zIndex: 1800,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<ConfirmClearDialog
onExit={() => setConfirmClear(false)}
onConfirm={handleClear}
open={confirmClear}
anchorEl={anchorEl}
/>
</Box>
)}
</Card>
);
}
Tip: Auto-focus the input field after sending a message for smoother chatting.
When to use useMemo
vs useCallback
vs React.memo
Choosing when to use useMemo
versus useCallback
or React.memo
can be somewhat confusing at first, but it boils down to optimizing different things.
You’d opt for useCallback
for functions to prevent them from being re-created on every render. This is important for event handlers, functions passed as props, or functions used in dependency arrays.
Then choose useMemo
for values (expensive calculations, objects, arrays, complex JSX) to prevent them from being recomputed or re-created on every render. Use it when referential equality of an object/array matters.
Lastly, use React.memo
for components to prevent the component from re-rendering if its props haven’t changed. This is particularly useful for components that render frequently, are expensive to render, or appear multiple times in a list.
const MemoizedChatMessage = React.memo(({ message, isUser, time }: IChatbotMessage) => (
<Suspense
fallback={
<CircularProgress
size={20}
color="secondary"
/>
}>
<ChatMessage
message={message}
isUser={isUser}
time={time}
/>
</Suspense>
));
More: How To Make A Fantastic Tic Tac Toe Game With React
Why JavaScript Everywhere?
Could I have built the backend logic for the Edge Function in Python? Yes, Supabase does support Python database functions. But staying in JavaScript had major benefits:
- Consistency: My Gatsby site is already JS/React, so keeping the backend in the same language means a consistent tech stack. Though not that obvious with this small scale of a project, a consistent stack means any engineer maintaining the system only needs to be proficient in one language.
- Edge Compatibility: Supabase Edge runs on Deno, a JavaScript/TypeScript runtime. Working with JS helps avoid potential headaches you might encounter trying to run other runtimes.
- Performance: JS cold-starts (i.e., the time it takes for a function to “wake up”) are typically faster than Python runtimes. JS can also have a lower memory footprint for lightweight tasks, avoiding the need to spawn separate Python interpreters.
- Simplicity: One runtime to manage, one language to debug 🙌
That said, since this is the first iteration and the first time I’m working with AI models directly, my thoughts will probably change with experience. Python is a data powerhouse that simplifies and facilitates LLM processes, so I wouldn’t be surprised to turn to it on different projects.
How It Performs Now
Since getting the chatbot up and running, I’ve been really happy with the results (and ridiculously proud of EVE—both bot and Beagle 🐾).
So far, my AI chatbot provides fast, real-time, streamed responses for most queries. Thanks to the vector similarity search, the answers are accurate and contextual, drawn directly from my portfolio content.
Operating costs are minimal since it runs comfortably under the free tiers of both Supabase and Google Gemini (so, please, take it easy on the questions).
Performance has been stable, though I should probably set up automated cleanup to keep the database healthy 🤔
Overall, it feels like a natural part of my site, not an add-on.
What I’d Do Differently Next Time
In hindsight, there are quite a few areas that stand out where I could have saved time or made things smoother.
- Planning the content structure smartly before any coding would have been a huge help. Cleaning up fragmented data later added complexity, but it was learning pains. As a visual learner, I often don’t grasp concepts until I experience them (as unfortunate as that sometimes can be).
- Designing and building the backend Edge Function with streaming (SSE) in mind from the very beginning would have simplified the development process compared to potentially adding it later. And I really don’t know if I would choose custom streaming again—it was a headache to implement in its own right on the front-end (perhaps a post for another time).
- Spending more time upfront to optimize batch uploads and API retries for the content embedding script would have made the initial data processing faster and less prone to errors. But that can also extend to not going through four models and two dimensions 😓
Other than that, this project went smoother than expected, mostly because the tools (Supabase, Gemini, React) made it easy.
I learned a lot and came out of this knowing there’s so much more to learn. The fact that all this technology changes daily doesn’t make things easy, but certainly keeps it exciting.
You may have noticed I haven’t mentioned a lot of the bottlenecks I faced in detail as per my usual. This isn’t to say it was smooth sailing; on the contrary, the troubleshooting I did and mini lessons learned are so many that they warrant a post of their own.
For now, EVE is officially in her beta phase, and she can only get better!
Let’s Call It A Wrap
Want to add a chatbot to your portfolio?
If you’re a developer looking for a project that touches on modern web development, database technology, AI integration, and backend logic, I highly recommend trying something similar.
Building EVE, the custom AI chatbot, wasn’t just a cool technical challenge; it genuinely improved my portfolio site and gave me invaluable hands-on experience with practical AI concepts, vector search, and serverless architectures.
Along the way, I touched on:
- Structuring content for AI
- Using Supabase for storage and functions
- Choosing a reliable model like Gemini
- Embedding data cleanly
- Building a fast React chatbot UI
See, what did I say? Working on a chatbot is a fantastic way to deepen your understanding and create something unique and interactive.
The result is an AI that truly represents you (or your pet dog) and makes your site way more fun to explore. Plus, the chat can help all those non-existent recruiters save time by answering their questions and piqueing their interest.
As always, the code can be found in the GitHub repository associated with this project.
Thanks for reading! If you have any questions, hit up the comments, and we can chat. I’m always happy to geek out about this stuff.
Bye bye 👋