메뉴
HN
Hacker News • 44일 전

주말 10달러로 메이커를 위한 50만 도메인 검색 엔진 만들기

IMP
7/10
핵심 요약

거대 기업의 SEO와 문서에 묻히지 않고, 개인 프로젝트와 창작물만 찾아주는 맞춤형 검색 엔진을 주말 단기로 구축한 사례입니다. 로컬 소형 언어 모델을 활용해 웹페이지를 요약하고 분류하며, 약 10달러의 GPU 서버 비용으로 50만 개 이상의 사이트 인덱스를 구축할 수 있었습니다. 개인 개발자가 AI를 활용해 기존 검색 엔진의 한계를 뛰어넘는 효율적인 로컬 도구를 만드는 데 훌륭한 영감을 제공합니다.

번역된 본문

marlin (글쓴이): 주말, 단 10달러로 메이커를 위한 50만 도메인 검색 엔진을 구축한 방법

일요일, 새벽 2시. 잠이 오지 않았고 또다시 검색 엔진들 때문에 짜증이 났습니다. 내가 정말로 관심 있는 포트폴리오, 개인 잡지, 조금 독특한 예술 프로젝트, 1인 개발자 소프트웨어 같은 검색어들은 늘 거대 기업의 문서와 SEO(검색 엔진 최적화) 쓰레기 더미 아래에 파묻혀 버리곤 했죠. 그래서 새벽 2시에 흔히들 하듯이 그 짓을 했습니다. 터미널을 켜고 계획을 타이핑했죠.

"나만을 위한 검색 엔진을 만들고 싶다. 약 4천만 개의 도메인이 있다. 각 도메인에 대해 약간의 메타데이터를 저장할 수 있다. 1KB씩만 잡아도 40GB인데, 이 정도면 충분히 가능하다."

수요일 점심쯤에는 560,183개의 홈페이지를 분류해 두었습니다. 다음에 가져올 가치 있는 크롤링 대기열은 비어 있었고, 여기서 멈추기로 결정했습니다. 이 글은 바로 그 주말에 있었던 이야기입니다. 무엇을 만들었고, 무엇이 망가졌고, 비용은 얼마나 들었으며, 여러분이 직접 하나 만들고 싶다면 해줄 수 있는 조언들은 무엇인지에 대해 다룹니다.

만약 딱 한 문단만 읽으신다면 핵심은 이것입니다. 약 10달러의 밤샘 GPU 대여 비용과, 시스템이 돌아가는 동안 이리저리 방향을 잡아준 몇 시간의 시간이면 디스크상 1GB 미만의 용량으로 수십만 개 사이트의 개인 검색 인덱스를 가질 수 있습니다. 이게 제가 말씀드리고 싶은 전부입니다. 아래에 적힌 내용들은 내가 그곳에 어떻게 도달했고, 그 과정에서 어떤 까다로운 문제들이 있었는지를 설명합니다. (자세한 기술적 세부 사항은 별도로 제공됩니다.)

내가 실제로 만들려고 했던 것

'웹 전체를 인덱싱(Indexing)하는 것'이 아니었습니다. 그저 물건을 만드는 사람들, 예술, 코드, 하드웨어, 시, 소극장 등을 하는 사람들을 찾고 싶었을 뿐이고, docs.company.com 같은 기업 문서들에 파묻히고 싶지 않았습니다. 오직 개인이 쓰는 단일 사용자 용이며, 계정 같은 건 없습니다.

크롤러는 오직 홈페이지만을 바라봅니다. 각 페이지를 읽고 이름, 두세 문장의 요약, 카테고리, 그리고 몇 가지 태그를 작성해 주는 작은 로컬 언어 모델을 사용했습니다. 그 위에는 단어의 절반만 타이핑하더라도 올바른 사이트를 찾을 수 있도록 퍼지 매칭(fuzzy matching) 기능이 적용된 작은 검색 UI를 올렸습니다. 저는 제가 명시적으로 만들지 '않는' 것들을 적어두었는데, 이는 주로 저를 돕는 AI 에이전트들이 이를 조용히 더 거대한 무언가로 '단순화(확장)'시키는 것을 막기 위함이었습니다. IP 스캐닝은 없고, Redis도 없으며, 페이지의 전체 HTML을 저장하지도 않고, 재크롤링 스케줄러도 없으며, 멀티 테넌트(다중 사용자) 시스템도 아닙니다. '제외'는 단지 카테고리의 체크박스 하나로 처리되어 검색 시에만 적용되었습니다. 크롤러는 여전히 이커머스 사이트도 요약하겠지만, 내가 굳이 그것들을 볼 필요가 없도록 한 것입니다.

간단히 계산해 보면 수천만 개의 도메인에 약 1KB씩 메타데이터를 할당하는 건 Postgres 박스 하나에겐 정말 아무것도 아닙니다. 페이지의 텍스트 자체는 말뭉치(Corpus)로 쓰려던 게 아니라, 모델이 읽고 나면 버려지는 임시 스크래치 버퍼(메모리) 역할만 하도록 설계했습니다.

머신 구성

총 4개의 프로세스가 동작했습니다. 그중 3개는 제 PC에서 로컬로 돌렸고, 1개는 데이터베이스를 직접 건드리지 않는 대여한 GPU 서버였습니다.

• 패처(Fetcher): 대기 중인 도메인을 하나씩 가져와서 HTTPS, 그리고 HTTP 순서로 시도합니다. JavaScript는 실행하지 않고 간단한 HTML 파서로 제목, 본문 텍스트, 외부 링크만 추출합니다. 그런 다음 해당 데이터베이스 행(Row)을 '준비 완료' 상태로 바꿉니다.

• 워커(Worker): '준비 완료'된 도메인을 가져옵니다. 페이지가 비어 있거나, 주차되어 있거나(Parked), 봇 인증(Bot-challenge) 차단벽이라면 모델을 아예 거치지 않고 건너뜁니다. 그렇지 않은 경우, 소형 로컬 모델(Gemma, 4B 파라미터 모델)에 구조화된 요청을 한 번 보내 이름, 요약, 카테고리, 태그를 받아옵니다. 임시 텍스트는 지워버리고, 추출해 왔던 외부 링크들은 그 링크가 나온 페이지의 종류에 따라 우선순위를 매겨 다시 대기열(Queue)에 넣습니다.

• 스튜어드(Steward): 이 프로세스는 메인 대기열을 전혀 건드리지 않습니다. 의심스러울 정도로 너무 많은 페이지를 생성해 내는 호스트(Host)에서 도메인을 표본 추출(Sampling)하여 모델에게 "차단, 유지, 불확실" 중 하나를 묻습니다. 그리고 조용히 차단 목록(Blocklist)을 관리합니다. 왜 이것이 필요했는지는 나중에 더 설명하겠습니다.

• API와 작은 웹 UI: 필터가 있는 검색, 숨길 카테고리를 토글하는 페이지, 그리고 로그를 찌푸리며 볼 필요 없이 내 공장(시스템)이 실제로 무슨 짓을 하고 있는지 볼 수 있는 대시보드입니다.

패처(Fetcher)가 가져온 모든 데이터는 '진행 중'인 동안 해당 도메인 데이터베이스 행(Row)에 직접 저장됩니다. 모델 처리가 끝나면 해당 텍스트는 즉시 지워집니다. 이는 생각보다 훨씬 중요합니다. 왜냐하면 실제 규모로 갔을 때 원시 페이지 텍스트가 영원히 쌓이게 둘 수 없기 때문입니다. 4천만 개의 행에 각각 몇 킬로바이트씩 곱해지면 용량이 엄청나게 빠르게 증가합니다. 저는 그 텍스트가 필요했던 이유가 단지 모델이 읽는 데 걸리는 몇 초 동안뿐이었습니다.

사실 웹의 90%는 기업입니다

첫 번째 버전은 몇 시간 만에 작동했습니다. 샘플 도메인을 향해 실행시키고, 항목들이 요약되는 것을 지켜보고, 검색해 보았습니다. 정말 좋았습니다. 그러다 일요일 오후,

원문 보기
원문 보기 (영어)
marlin How I built a 500k-Domain Search Engine for Makers in a Weekend for $10 Sunday, 2am. I couldn’t sleep and I was annoyed at search engines again. Every query I actually cared about, portfolios, zines, weird little art projects, one-person software, drowned under a foot of corporate documentation and SEO sludge. So I did the thing you do at 2am: I opened a terminal and typed out a plan. “I want to make a search engine for myself only. There are 40-ish million domains. We can store a bit of metadata about each one. Even with 1KB each that’s 40GB, which is doable.” By Wednesday lunch I had 560,183 homepages catalogued, an empty queue of anything worth fetching next, and a decision to stop. This is the story of that weekend: what I built, what broke, what it cost, and what I’d tell you if you wanted to build your own. The headline, if you only read one paragraph: for about $10, an overnight GPU rental, and a few hours of steering the thing while it ran, you can have a personal search index of a few hundred thousand sites, under a gigabyte on disk. That’s the whole pitch. Everything below is how I got there and where the sharp edges are. Full technical details are provided separately . What I was actually trying to build Not “index the web.” Just: find people doing stuff, art, code, hardware, poetry, little theatres, and not drown in docs.company.com . Personal, single user, no accounts. A crawler that only ever looks at homepages, a small local language model that reads each one and writes a name, two or three sentences, a category, and a handful of tags. A little search UI on top with fuzzy matching so I could type half a word and still find the right site. I wrote down what I was explicitly not building, mostly so that agents helping me wouldn’t quietly “simplify” it into something bigger: no IP scanning, no Redis, no storing full page HTML, no recrawl scheduler, nothing multi-tenant. Ignore was just a checkbox on a category, applied at search time. The crawler still summarised ecommerce sites, I just didn’t have to look at them. The napkin math was tens of millions of domains at roughly 1KB of metadata each, which is genuinely nothing for a Postgres box. Page text itself was never meant to be a corpus, just a scratch buffer that gets thrown away the moment the model is done with it. The machine Four processes, three of them on my own PC, one rented GPU that never touches the database directly: A fetcher. Grabs a pending domain, tries HTTPS then HTTP, pulls the title, body text, and outbound links with a simple HTML parser, no JavaScript execution. Sets the row to “ready.” A worker. Grabs a ready domain, skips the model entirely if the page is empty, parked, or a bot-challenge wall, otherwise makes one structured request to a small local model (Gemma, 4B parameters) and gets back a name, summary, category, and tags. Wipes the scratch text, enqueues the outbound links at a priority based on what kind of page they came from. A steward. This one doesn’t touch the main queue at all. It samples domains from hosts that are producing suspiciously many pages, asks the model “block, keep, or unsure,” and quietly maintains a blocklist. More on why I needed this later. An API and a tiny web UI. Search with filters, a page to toggle which categories are hidden, and a dashboard so I could actually see what the factory was doing instead of squinting at logs. Everything the fetcher grabs lives directly on the domain’s own database row while it’s “in progress.” Once the model is done with it, that text gets wiped. That matters more than it sounds, because at any real scale you cannot let raw page text pile up forever. Forty million rows times a few kilobytes each adds up fast, and I only needed that text for the few seconds the model was reading it. The web is 90% corporate, actually The first version worked within a couple hours. Point it at a sample of domains, watch things get summarised, search for them. Great. Then, Sunday afternoon, I looked at what had actually been catalogued and it was the wrong web. Over 90% corporate sites and documentation. My seed list was skewed and the crawler had no opinions about what to chase next. The fix wasn’t to block anything. Blocking felt tempting but wrong, because a boring corporate docs page might still link out to someone’s personal blog, and I didn’t want to lose that. Instead I weighted the queue: pages classified as “portfolio” or “zine” or “software” push their outbound links way up the priority list, pages classified as “corporate” or “docs” push theirs down. One text file, category-priority.txt , became the steering wheel for the rest of the weekend. I’d tune a number, watch what came in over the next hour, tune again. That same afternoon I wiped the database twice because the quality was bad enough to just start over. Two bugs stood out: One site’s summary field just said “academic-profile,” a category label the model had shoved into the wrong slot. Fix: treat a suspiciously short summary as a failure and retry once with a stricter prompt. Another site, a GoDaddy domain-parking page with basically no real HTML, got summarised by the model as being “for the furry community.” but it had just seen the word “furry” in the hostname, with empty body, and invented an entire fandom site out of nothing. That one taught me the actual rule: trust visible text over the title, trust the title over anything guessed from the domain name, and if a page is near-empty or clearly parked, don’t even bother asking the model, just mark it and move on. Sunday night: Tumblr is not the web By evening the crawl had a new problem. Tumblr and Neocities blogs were showing up in such volume that they were going to become the entire index. My first instinct was to block them, which felt wrong, because Neocities specifically is exactly the aesthetic I was hunting for. The actual fix was a cap: allow the main domain always, but once a given root domain has produced more than 100 subdomains, stop enqueueing new ones from it. One pass of that rule deleted over 45,000 queued pages instantly. Tumblr still ended up contributing over 9,000 pages to the final index, since the cap only applies going forward, but it stopped being the whole story. This was also the night the actual purpose came into focus, less “index everything,” more “find the people making things for a community.” I reseeded the crawl with about ten deliberately chosen doors: tilde communities, small independent blogging platforms, a webring or two. Almost the entire final index traces back to links found from those ten seeds, not from the seed list itself. Monday morning brought a related flavor of the same problem: forum farms and Chinese B2B vendor microsites riding a “forum” category boost into a black hole of near-identical pages. Same lesson, different category. I demoted “forum” hard and started keeping an explicit blocklist file for known mills. Renting a GPU, badly, then well My own GPU, a consumer card, could summarise roughly one page per second running locally. Fine for building the prompt, hopeless for actually filling an index. So I rented a cloud GPU to run the same small model at real concurrency, and this is where most of my actual debugging time went, none of it about the AI itself. The short version: my first rental setup used a wrapper library that insisted on spinning up a distributed compute framework even for a single GPU, and that framework fought with the host machine for CPU time. I was paying for a GPU and getting throttled by CPU contention I never asked for. Threw that away, ran the plain open source inference server instead, no wrapper. Hit a crash on cold start at high concurrency, which turned out to be a memory spike during the first batch, not a steady-state problem, fixed by ramping concurrency up gradually instead of slamming it at full speed from a cold start. The machine that actually did the job was a mid-range workstation GPU with a full, un