The Challenge
Ilé ìmọ̀ — nothing new just a tool that helps you search through clutters.
I decided to write an article about this tiny project, the first two paragraphs will describe my process, this might get a tiny bit touchy so if you’re looking for the perfect engineer, she’s not here.
Ilé ìmọ̀ came from need, i am not yoruba but where i found solace spoke these chants. hard day, long even, but a task i wanted to see through, typing away, browsing with interest about principles, mechanisms and concepts i had to learn to beat the timeline. Starting with LLMs, then context, then tokens, they were so many terms to learn but i was willing to learn them all. understanding the secrets, the psyche of not just the AI tool but the engineers behind it gently thawed and softened the geometrical edges of this tool the more i read, to remember or simply to guess and keep guessing purely based on vibes and memory is the best way i would describe these Large Language models.
Did you know : Large Language Models hallucinate because they they don’t have a default “i don’t know mechanism” talk about ignorance and arrogance built into your tool, cue laughing emoji.
Anyways, next was to start building my slides, knowing me and my obsession with giving plenty context, i was going to start with a knowledge dump and refine as i go, canva was a no brainer, straight to duplicate an existing slide but immediately after creating my copy, i realise, opening up the slide was taking longer than necessary, oh no, network no, i have less than 14 hours to do all this, but i was not to be pertubed, it was manageable and it would get better like it always does, i push through, i start the journey of going back and forth between recordings, gemini notes, actual resources and this really nice medium article that really helped clear up the air on certain myths most of us have about AI and LLMs that i have lost and hence the reason why i never close MY TABS VLAD!
But back to the details and i promise this will not be a rant, i will go through my process, i promise. i realise the network was totally dead, quickly i start refining my knowledge dump into a traditional pptx, anyone from secondary school knows i’m good with that shi, uhm, i mean, i’m good at using slides, hence, i slide away, i write them in the simplest most straightforward context way i could remember and i had dumped into canva which i could no longer edit, but i am faded, nothing is sticking in my brain anymore, my instructor is going to have a field day asking me about stuff i can’t remember so, i go to sleep, yes, i go to sleep, 4 hours is typically enough for me, i am an engineer afterall, there’s always so much to do with so little time so a 4 hours boost was all i needed.
3AM, that’s when i woke up, the network was back but all these knowledge gain had me discombobulated, time felt too little for the idea i initially had, a tax advisory tool, don’t even think of stealing my idea, it’s already in the works. i needed something straightforward, something i could build and something like she said, that you can use in your day to day, easy right? wrong!
As someone that has to fight imposter syndrome everyday, it wasn’t easy. cause i need an answer if anyone would care to answer, am i an imposter if i had the idea first but researching it made me realise other people not only had the idea but also built it as a solution? hmmm.
But back to Ilé ìmọ̀, it was that easy, one message from a colleague and me running to gemini to confirm the answer i had given them was all it took to realise, a tool that would help an anxious neurodivergent brain like mine, was one i could plug into a database or repository of choice and get my answer, non-ambguity, no prompt hassle(still prompt well abeg, don’t ask my tool are you AI? abeg) so the dream was borne, and it was simple.
Ilé ìmọ̀ was initially supposed to be a class knowlege assistant, but 2 weeks into building my idea it dawns on me, that it can be used by anyone, a straight forward tool, that can be used by anyone, to plug into any online cloudbase, and then you can query and search through the database, get an answer and a citation link that tells you exactly what you want and where it is in your repository or library and maybe that no such thing exists in your database.
Now the main mechanism we are trying to build here is RAG, Retrieval-Augmented Generation. an AI technique that improves large language models (LLMs) by letting them search an external database for relevant facts before they write an answer, hence making our AI a bit more intellectual and less psychotic. by ensuring our tool is no longer guessing and giving us accurate and up to date answers.
For this build i am sticking with my babies, React, and JS. TS i will master you later.
Other very important tools include:
- Vite — most tutorials used it: i finally did a bit of research and discovered it is the solution to the depracated CRA that always shows you webpack vunerabilities when you run npm install, yay vite.
- Framer motion: my mentor don inspire me to take my designing and styling career seriously, he’s such a good frontend developer, God when.
- Supabase for auth — initally didn’t plan to — but we’re connecting our application to real dbs and repositories, abeg, no be me go do jail o, but in more demure terms, the 3 As of security, Authentication, Availability and Audit/Account, the major reasons why this is moving from public, to authenticated.
- since we mentioned auth, you know express is surely going to be in the picture. and so much more, my repository has a more technical TRD and a simpler PRD to aid, lol, i can’t write that all here, let’s continue our story.
- Don’t forget our chosen LLM Provider o, we went with Google Gemini.
The Build
As a scatterbrain individual who likes concise and clear instructions, first thing i did was to setup my codebase and gather my resources.
normally you have your prerequsites like node.js, git, free supabase and free google ai studio account.
then onto the code, bearing in mind i was going to have two parts to my code, the frontend — client and the db and backend setup — server, head into the tailwind documentation and select build with vite and all commands for your bash or cmd is broken down there but here are the parts i took:
npm create vite@latest client -- --template react
cd client
npm installnpm install framer-motion
npm install tailwindcss @tailwindcss/vitei mostly used plain css for this project but tailwind pairs well with framer-motion and so based on most tutorials, it’s just the easier choice.
Add the @tailwindcss/vite plugin to your Vite configuration in client/vite.config.js.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})also to ease your stress, you can head into extensions and install es7+ for react by dsznajder and tailwind css intellisense by tailwind labs — they boh enhanced my coding experience, before now, i just stuck with eslint/linters and prettier.
now before we finish up with our setup, we would have to clean up the react boiler plate a bit, so head into the src/index.css and replace everything there with a simple tailwindcss import.
@import "tailwindcss";then run npm run dev
finally, the journey began, firstly with the vite boilerplate. beautiful, useless, mine, lol.
now for the server. different folder, different life. mkdir server, npm init -y, and install express, cors and dotenv. three packages, three jobs. express so i'm not handling raw network requests like an animal, cors because the browser will absolutely refuse to let localhost:5173 talk to localhost:3000 unless you explicitly say it's fine, and dotenv so my secrets live in a file instead of being hardcoded into a repository i intend to show people.
nodemon too, as a dev dependency, because restarting the server manually after every save is a special kind of punishment, i infact suffered through this punishment before i realised there is a better way .
wrote the world’s smallest express server, a /ping route that returns {status: "server is alive"}, ran it, terminal says "Server running on port 3000".
browser says site can’t be reached.
Lesson one: the server starting and the server being reachable are two different sentences
i tried 127.0.0.1. i tried a different port. i checked the firewall. i went into netstat. nothing.
turns out powershell had script execution disabled, so npm run dev was failing silently before it ever reached my code(the joys of setting up a new system). Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, restart the terminal, and suddenly nodemon actually runs.
except now it says [nodemon] clean exit - waiting for changes before restart.....i struggled but it finally started working, all that matters.
The database
supabase project, new, free tier, and immediately into the SQL editor because i wanted to type the schema out myself. not the GUI. i learn things by typing them and i needed to actually understand what i was building, i’m a frontend engineer, don’t forget, it was me, documentation, a poorly watched youtube video, claude and Google, lol, but i kept it as short as possible .
three tables:
sources— one row per connected thing. a repo, a drive folder, eventually a zoom recording.chunks— one row per piece of text, with its embedding and alocator.connections— one row per service a user has linked, holding their access token.
that locator column is the whole product, honestly. it's a jsonb field holding whatever each provider needs to point at an exact spot. for github that's {file_path, branch, start_line, end_line}. for zoom it'd be a timestamp. for slack, a message id.
NotebookLM can tell you “page 4, paragraph 2” because it stored that when it chunked. if you flatten a document into plain text and don’t save where each piece came from, that information is gone forever. you cannot get it back later. you can only capture it at ingestion.
so the rule became: the AI never generates a link. ever. it tells me which chunk it used, and my server builds the URL from stored data. LLMs are confident liars(not really sha, it’s worse, they’re psychotic, cause they hallucinate) but about URLs and the entire promise of this tool is that the citation is real.
Row Level Security, or: the database should not trust my code
this is the part i’d tell anyone building multi-user anything.
when i decided people could connect their own repos, this stopped being a personal tool and became a product where strangers’ private code lives in the same table. so every row gets a user_id, and then:
create policy "Users manage their own chunks"
on chunks for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);using controls what you can see. with check controls what you can insert or change a row into. you need both without the second one, someone could theoretically write a row with someone else's user_id.
Get Nanji Lakan’s stories in your inbox
Join Medium for free to get updates from this writer.
the point of this is that postgres refuses the leak even if my app code has a bug. i tested it properly too: two accounts, one fake chunk each, curl’d the API as each user. account A only ever saw account A’s row. if i deleted every WHERE user_id from my server tomorrow, nothing would leak. the database is the boundary, my code is just convenience(it’s so much more though, it’s my sweat — cue maniacal laugh).
i enjoyed this part more than i expected to — i lied, i only enjoyed the frontend, can’t wait for you to see my shelves.
The GitHub loader, and a bug that taught me how JavaScript actually works
octokit, fine-grained PAT or classical, getTree with recursive true, filter to text files, decode from base64 because github returns file contents encoded, push into an array. 761 files fetched from my own repo on the first proper run.
then i ran the real ingestion and got Request quota exhausted.
60 requests per hour. that’s the unauthenticated limit. authenticated is 5000(this felt like fighting lagos conductor for change Aswear). so my token wasn’t being sent — and all it was, was an omission in the term “Bearer” lol.
Embeddings, and a number that has to match
text-embedding-004, 404. deprecated. listed the available models against my actual key instead of trusting a tutorial, i trust that indian man more with my life than my code cause how come he did it 2 years ago, huh??, anyways, found gemini-embedding-001, swapped it in.
vector length: 3072.
my chunks.embedding column is vector(768). those numbers have to match or every insert fails. gemini defaults to 3072 but supports truncation, so outputDimensionality: 768 in the config.
and then a detail from the docs i almost skipped: gemini-embedding-001 does not auto-normalize truncated vectors. the newer model does, this one doesn't. cosine similarity compares direction, not magnitude, so unnormalized vectors tend to break silently i guess, or it was google search AI cajoling me — long story short i thought my chunking was the problem, like “AM I THE DRAMA”.
function normalize(vector) {
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0))
return vector.map((val) => val / magnitude)
}nine lines. would have been a nightmare to debug backwards.
The frontend, home sweet home — sweets are bad for you sha.
this is the part i actually enjoy so forgive me.
the whole visual idea is books. spines on a shelf. i wanted the hero to look like an illustration at rest and only reveal itself as interactive when you touch it, which is a thing i noticed on deepjudge.ai — yup, they were my inspo. Their hero is just a field of vertical bars, most of them black, one or two in a contrast colour like lavender and orange — inspired me to update my linkedin cover photo, then some were deliberately diagonal. restraint plus one strong idea.
so: one row of thin spines. click one and it widens into a square while the others shift aside to make room, and its metadata types itself out inside. not fades in — types, line by line, staggered. there’s a small useTypewriter hook doing it with a setInterval and proper cleanup, because if you don't clear the timer, hmmm, closing one book mid-type go scatter the next one you open.
every spine was flexible, i just used flex.flexBasis: 0 and flexGrow: 1, the open one animates to flexGrow: 10. the row's width is fixed so it just redistributes. nothing overflows because the total can't exceed the container. framer's layout prop animates the reflow instead of snapping.
i also gave the shelf an idle behaviour, every couple of seconds a random spine opens on its own, so the hero is alive before you touch it. the moment you actually click something, the ambient loop stops and gets out of your way. lastAutoKeyRef makes sure it doesn't pick the same book twice in a row.
the palette is one green. sage. everything else is ink, cream, white. i used these same colors in a former project, Bloom After, now i’m stuck cause i don’t like any other colors, haew.
The nav bounces because i wanted it to….blame my mentor, his works are Amazing.
it drops in as a small circle, bounces with decaying keyframes, and then spreads open into the bar. two phases, and the handoff is onAnimationComplete flipping a landed boolean which swaps all the target values.
the bounce keyframes are [-400, 0, -30, 0, -12, 0] with a times array so the first fall takes longer than the later rebounds. without the times array framer spaces keyframes evenly and every bounce takes the same duration, which reads as mechanical. real things lose energy — i promise if you read my code, you’ll get it, i’m lazy so i find the easiest way out.
i reused this exact pattern later for the chat pill. that’s the actual win, i really needed to improve my understanding of reusable components. and here i built one animation pattern properly and i somewhat applied it to completely unrelated elements.
also: onAnimatedComplete is not a real prop. react will silently ignore any prop it doesn't recognise, so my nav bounced perfectly and then just sat there as a circle forever while i stared at it. one letter.
The scroll sequence
source cards float around a headline, converge inward as you scroll, collapse into a single ball, the ball travels down the page and lands in the next section where it opens into the chat input.
the cards genuinely morph — width and height collapse, border radius goes to 9999, contents fade first so you’re not watching text squash inside a shrinking box. five cards become five identical small circles stacked at the same point, then hand off to the ball in the final 2% of the convergence. you don’t see a swap, you see one thing become another, i was so excited about this part.
getting the ball to actually travel took me embarrassingly long, and the reason is instructive: i had it inside a sticky container. sticky pins an element in the viewport, which is the exact opposite of travelling through a page. i was animating its position while the frame it lived in refused to move. once i pulled it out and made it fixed against the viewport instead, all the coordinate maths i'd been recalculating every time i changed a section height just disappeared.
that was the general shape of this whole build honestly, the fix was usually deleting something, not adding.
Auth, OAuth, and a token i pasted in public
magic links via supabase. signInWithOtp, no password to store or leak. hit the email rate limit almost immediately because the default SMTP is dev-only and i was testing in a loop, so i added a password form behind import.meta.env.DEV which vanishes in a production build.
then github OAuth properly, because “paste your own token” is not a product, lol. register an OAuth app, /connect builds the authorize URL with the user's supabase id in the state param, github redirects to /callback with a code, exchange it for an access token, store it against that user in connections.
the state parameter is doing real work there. the callback arrives as a fresh browser redirect with no auth header, state is the only way to know whose account this token belongs to. it's also the standard CSRF guard.
and then, while debugging a rate limit, i pasted a working personal access token into a chat in full. twice. both repo-scoped. i revoked them, but i want it written down because the lesson isn't "be careful", it's that i was three hours deep and had stopped reading what i was copying. github's push protection later caught my .env before it hit the remote, which is the only reason my supabase secret key and gemini key aren't public right now.
**/.env in gitignore. .env.example committed with empty values. rotate anything that touched a terminal you don't fully trust.
Retrieval, finally
the search runs in postgres, not in node:
select
c.id, c.content, c.locator,
s.title as source_title, s.url as source_url,
1 - (c.embedding <=> query_embedding) as similarity
from chunks c
join sources s on s.id = c.source_id
where c.user_id = match_user_id
order by c.embedding <=> query_embedding
limit match_count;<=> is pgvector's cosine distance operator. smaller is closer, so 1 - distance gives a similarity score where bigger is better.
the question gets embedded with the same model at the same dimensionality as the chunks. this is non-negotiable and worth saying plainly: vectors from different models are not comparable. they’re not even wrong, they’re meaningless together.
then the chat route pulls the top five, builds a prompt, and the system prompt does the tone work:
answer conversationally, like explaining to a classmate. be concise and warm, not textbook-formal. answer only from the excerpts provided. if they don’t contain the answer, say so plainly. never invent file names, line numbers, or links.
the whole value proposition is that the citation is real, so the model is explicitly forbidden from generating one. it references chunks by number, my server maps those numbers to stored locators, and buildLink constructs the URL. the model never touches it.
What it does now
you sign in, connect github, pick a repo, it indexes. then you ask it something in plain english and it answers conversationally with citation chips underneath, file path, line range, and a link that drops you at exactly those lines on github.
What it doesn’t do yet
drive, slack, zoom. drive specifically i tried early and got walled by google cloud demanding card verification that my card kept failing, my money wasn’t good enough(it’s free but i’m bitter), so i swapped the connector order and built github first. same pipeline, different loader, that’s the whole point of the architecture.
ingestion also runs synchronously, chunk by chunk, in the request. a forty-file repo takes minutes and would blow past most hosting timeouts. it needs to be a background job before this goes anywhere real. i know. it’s written down.
no chat history either. every question is a cold start(For nowwwwwww).
What i’d tell myself two weeks ago
isolate before you integrate. almost every hour i lost was to testing two unproven things at once and having no idea which one was broken. the moment i started writing tiny throwaway scripts one to check the token, one to check the embedding length, one to check what a model actually returns, one to see what happens for a ball bounce between sections, debugging became easier.
Reading the actual docs for anything moving fast. i got burned on a deprecated embedding model, a deprecated chat model, tailwind restructuring its entire config system between v3 and v4(wasn’t aware of that cause i used tutourials to prepare), and supabase changing its key format. every tutorial i found was confidently describing a version that no longer exists(still existed but mostly depracated).
anyway. Ilé ìmọ̀. house of knowledge. it works, and it tells you where it got the answer.
still not TS though. later(maybe never, we’ll see) but go check out my work and this article will be getting updates cause there’s still so much to do but atleast now you can query through your repo, the plan is to allow you query and cite your different repos, drives, databases get answers and a link that takes you straight to where your answers were stored etc. The MVP is Github for now though.
Happy Scrolling….hehe!









