Hoe ik in een weekend voor $10 een zoekmachine bouwde voor 500.000 domeinen voor makers
"Ik wil een zoekmachine maken die alleen voor mij is. Er zijn ongeveer 40 miljoen domeinen. We kunnen voor elk domein een beetje metadata opslaan. Zelfs met 1 KB per stuk is dat 40 GB, wat goed te doen is."
Tegen woensdaglunch had ik 560.183 homepages gecatalogiseerd, was de wachtrij met interessante pagina's leeg en besloot ik te stoppen. Dit is het verhaal van dat weekend: wat ik bouwde, wat er kapot ging, wat het kostte en wat ik je zou vertellen als je zelf een zoekmachine wilt bouwen.
De kernboodschap: voor ongeveer $10, een nachtelijke GPU-huur en een paar uur sturing terwijl het proces liep, kun je een persoonlijke zoekindex creëren van enkele honderdduizenden sites, die minder dan een gigabyte op schijf inneemt. Alles hieronder legt uit hoe ik daar kwam en waar de valkuilen zitten. De volledige technische details worden apart verstrekt.
Wat ik precies wilde bouwen
Het doel was niet om "het web te indexeren", maar specifiek om mensen te vinden die dingen maken: kunst, code, hardware, poëzie, kleine theaters, zonder te verdrinken in docs.company.com. Het moest een persoonlijke tool zijn voor één gebruiker, zonder accounts.
De opzet bestond uit:
- Een crawler die alleen naar homepages kijkt.
- Een klein lokaal taalmodel (LLM) dat elke pagina leest en een naam, twee of drie zinnen, een categorie en een handvol tags schrijft.
- Een eenvoudige zoekinterface met fuzzy matching, zodat ik een half woord kon typen en nog steeds de juiste site vond.
Ik schreef expliciet op wat ik niet bouwde, zodat AI-agenten die me hielpen het niet stilletjes zouden "vereenvoudigen" tot iets groters: geen IP-scanning, geen Redis, geen opslag van volledige HTML-pagina's, geen recrawl-scheduler en niets voor meerdere gebruikers (multi-tenant). Het negeren van bepaalde sites was slechts een selectievakje bij een categorie, toegepast tijdens het zoeken. De crawler vatte e-commercesites nog steeds samen, ik hoefde ze alleen niet te zien.
De rekensom was: tientallen miljoenen domeinen met ongeveer 1 KB aan metadata per stuk, wat voor een Postgres-database bijna niets is. De paginatekst zelf was nooit bedoeld als corpus, maar enkel als tijdelijke buffer die werd weggegooid zodra het model klaar was.
De architectuur
Het systeem bestond uit vier processen: drie op mijn eigen pc en één gehuurde GPU die nooit direct contact had met de database.
- De Fetcher: Haalt een domein uit de wachtrij, probeert eerst HTTPS dan HTTP, en trekt de titel, hoofdtekst en uitgaande links via een eenvoudige HTML-parser (zonder JavaScript-uitvoering). De status van de rij wordt vervolgens op "ready" gezet.
- De Worker: Haalt een 'ready' domein op. Als de pagina leeg is, geparkeerd is of een bot-challenge bevat, wordt het model overgeslagen. Anders wordt er één gestructureerd verzoek gestuurd naar een klein lokaal model (Gemma, 4B parameters) voor een naam, samenvatting, categorie en tags. De tijdelijke tekst wordt gewist en uitgaande links worden in de wachtrij geplaatst met een prioriteit gebaseerd op het type pagina waar ze vandaan komen.
- De Steward: Raakt de hoofdwachtrij niet aan. Deze scant steekproeven van domeinen die verdacht veel pagina's produceren, vraagt het model of deze geblokkeerd moeten worden ("block, keep, or unsure") en houdt stilletjes een blocklist bij.
- De API en Web UI: Een eenvoudige interface voor zoeken met filters, een pagina om categorieën te verbergen en een dashboard om de voortgang van de "fabriek" te volgen in plaats van alleen naar logs te turen.
Alles wat de fetcher verzamelt, staat tijdelijk in de database-rij van het domein terwijl het "in progress" is. Zodra het model klaar is, wordt die tekst gewist. Dit is cruciaal; bij echte schaal kun je niet toestaan dat ruwe paginatekst zich blijft opstapelen. Veertig miljoen rijen van enkele kilobytes per stuk loopt snel op.
Het web is voor 90% corporate
De eerste versie werkte binnen een paar uur. Maar toen ik zondagmiddag keek naar wat er daadwerkelijk was gecatalogiseerd, bleek het de "verkeerde" kant van het web te zijn: meer dan 90% bestond uit corporate sites en documentatie. Mijn startlijst was scheefgetrokken en de crawler had geen voorkeur voor wat hij vervolgens moest volgen.
De oplossing was niet om alles simpelweg te blokkeren; een saaie corporate-pagina kan immers linken naar een persoonlijke blog. In plaats daarvan gaf ik de wachtrij gewichten: pagina's geclassificeerd als "portfolio", "zine" of "software" gaven hun uitgaande links een zeer hoge prioriteit, terwijl "corporate" of "docs" ze naar beneden drukten. Een tekstbestand, category-priority.txt, werd het stuurwiel voor de rest van het weekend.
Tijdens dit proces moest ik de database twee keer volledig wissen vanwege slechte kwaliteit. Twee bugs vielen op:
- Eén site had als samenvatting alleen "academic-profile", een categorielabel dat het model in het verkeerde veld had geplaatst. Oplossing: een verdacht korte samenvatting wordt nu gezien als een fout en één keer opnieuw geprobeerd met een striktere prompt.
- Een GoDaddy-parkeerpagina zonder echte HTML werd door het model samengevat als zijnde "voor de furry community", enkel omdat het woord "furry" in de hostname stond. De les: vertrouw zichtbare tekst boven de titel, en de titel boven gissingen op basis van de domeinnaam. Als een pagina bijna leeg of duidelijk geparkeerd is, wordt het model niet eens geraadpleegd.
Tumblr is niet het hele web
Zondagavond ontstond er een nieuw probleem: Tumblr- en Neocities-blogs verschenen in zulke grote aantallen dat ze de gehele index dreigden te worden. Omdat ik de esthetiek van Neocities juist zocht, wilde ik ze niet blokkeren. De oplossing was een limiet: het hoofddomein is altijd toegestaan, maar zodra een rootdomein meer dan 100 subdomeinen heeft geproduceerd, stopt het toevoegen van nieuwe pagina's daarvan. Hiermee werden direct 45.000 wachtrij-pagina's verwijderd.
Ook verschoof de focus: minder "alles indexeren" en meer "mensen vinden die dingen maken voor een community". Ik startte de crawl opnieuw met tien specifiek gekozen ingangen: tilde-communities, kleine onafhankelijke blogplatforms en een paar webrings. Bijna de gehele uiteindelijke index is terug te leiden naar links gevonden via deze tien startpunten.
Maandagochtend volgde een vergelijkbaar probleem: forum-farms en Chinese B2B-vendor microsites maakten gebruik van de "forum"-categorie boost. De les was hetzelfde: ik degradeerde de prioriteit van "forum" sterk en hield een expliciete blocklist bij voor bekende content-fabrieken.
GPU's huren: vallen en opstaan
Mijn eigen consumenten-GPU kon ongeveer één pagina per seconde samenvatten. Dat is prima voor het bouwen van de prompt, maar onmogelijk voor het vullen van een index. Daarom huurde ik een cloud-GPU. Hier ging de meeste debugging-tijd in zitten, waarbij het niet om de AI zelf ging, maar om de infrastructuur.
Mijn eerste setup gebruikte een wrapper-library die een distributed compute framework opstartte, zelfs voor één enkele GPU. Dit framework vocht met de hostmachine om CPU-tijd; ik betaalde voor een GPU, maar werd beperkt door CPU-contention. Ik verving dit door een eenvoudige open source inference server zonder wrapper. Daarnaast loste ik crashes bij een koude start op door de concurrency geleidelijk op te bouwen in plaats van direct op volle snelheid te starten.
De machine die uiteindelijk het werk deed was een mid-range workstation GPU met een volledige, niet-gedeelde set CPU-cores. Dit bleek belangrijker dan het model van de GPU zelf. De doorvoer was ongeveer 600 samenvattingen per minuut, tegen huurkosten van ongeveer 35 cent per uur. Dat komt neer op ongeveer één dollar om honderdduizend sites te catalogiseren.
Geschatte kosten en tijd per aantal sites
| Sites | Geschatte kosten | 1 GPU (Tijd) | 2 GPU's (Tijd) | 3 GPU's (Tijd) |
|---|---|---|---|---|
| 10k | ~$0.10 | ~17 min | ~8 min | ~6 min |
| 100k | ~$1 | ~3 uur | ~1.5 uur | ~1 uur |
| 1M | ~$10 | ~1.2 dagen | ~14 uur | ~9 uur |
| 10M | ~$100 | ~12 dagen | ~6 dagen | ~4 dagen |
Opmerking: Deze cijfers zijn gebaseerd op de beste van de drie geteste setups (vLLM met dedicated CPU slice).
De Steward: automatisering tegen spirals
Maandagavond besefte ik dat ik niet handmatig kon blijven letten op "bad spirals" (domeinen die oneindig veel pagina's genereren). Ik creëerde daarom de 'Steward': een tweede klein proces dat steekproeven nam van voltooide pagina's van verdachte domeinen en het model vroeg of het domein geblokkeerd moest worden.
Dit was de belangrijkste toevoeging die ervoor zorgde dat de index kon groeien van 65.000 naar 560.000 pagina's zonder dat ik er constant op hoefde te letten. De Steward blokkeerde zelfstandig 177 problematische domeinen (voornamelijk hotel- en boekingssites), terwijl legitieme grote platforms en universiteiten buiten schot bleven.
Analyse van de resultaten
In het begin bestonden blogs en persoonlijke sites uit meer dan de helft van de index (vooral door Tumblr en Neocities). Aan het einde was dat aandeel gedaald naar ongeveer 12%. Dit kwam niet omdat ik minder persoonlijke sites vond, maar omdat de crawl volwassener werd en een bredere mix oppikte: non-profits, community-sites, softwareprojecten, tijdschriften, musea en podcasts.
De categorie 'non-profit' alleen al eindigde met bijna 27.000 organisaties, waaronder voedselbanken en natuurbehoudstichtingen. Dit maakte het project echt waardevol.
Uiteindelijk raakten de geprioriteerde categorieën op en begon de crawler de backlog van prioriteit nul te verwerken. Omdat dit een ongefilterde doorsnee is van het internet, begon het model hier prompt een golf aan adult-sites te catalogiseren.
Ongeveer 15% van alles wat werd gecrawld eindigde als "leeg". Dit waren pagina's die een kale JavaScript-shell waren (die mijn eenvoudige HTML-parser niet kon lezen) of bot-detectie muren. Ik heb bewust geen JavaScript-rendering ingebouwd, omdat het draaien van een volledige browser op deze schaal veel duurder is.
Valkuilen bij opschaling
Wie dit zelf probeert, zal merken dat crawlen en GPU's huren het leukste deel is. Het gedeelte dat stilletjes een puinhoop wordt, is de afhandeling van categorieën en tags.
Ik liet het model vrijelijk eigen categorie- en tagnaam bedenken. Dat werkte snel bij de start, maar resulteerde in 671 verschillende categorieën (waarvan velen slechts één keer werden gebruikt) en meer dan 121.000 tags. Dit komt vooral omdat het model termen telkens net anders spelt. Ik moest een handmatig merge-tool schrijven om dit op te schonen, wat niet schaalbaar is voor grotere projecten. Wie iets groters bouwt dan een weekend-index, moet vooraf beslissen of het model beperkt wordt tot een vaste lijst categorieën of dat er tijd wordt gereserveerd voor opschoning.
Een ander schalingsprobleem is de sturing van de prompt en crawl. Geen van de oplossingen kwam voort uit een slimme "one-shot" prompt, maar uit het observeren van productiedata en het bijsturen van gewichten in een bestand op basis van wat er daadwerkelijk gebeurde.
Conclusie: moet je dit zelf bouwen?
Als je een zoekmachine wilt die alleen resultaten geeft waar je echt naar zoekt: ja, dit is goed te doen in een weekend en goedkoop genoeg om te proberen. De aanpak die voor mij werkte:
- Splits het ophalen (fetching) van het samenvatten (summarising), zodat een dure GPU niet idle staat door een trage netwerkverbinding.
- Geef gewichten aan je crawl-wachtrij in plaats van categorieën hard te blokkeren.
- Stel limieten in voor subdomeinen in plaats van hele platforms te verbannen.
- Sla het model volledig over bij lege of geparkeerde pagina's.
- Bouw een geautomatiseerde "sink-detector" (zoals de Steward) zodra de index te groot wordt om handmatig te controleren.
Ik ben niet van plan mijn database nu publiekelijk te hosten, maar de code wordt open source beschikbaar gesteld onder de naam Marlin (naar de vis uit Finding Nemo, op een zoektocht door de oceaan).
Groetjes,