# Building a web search engine from scratch in two months
Source: https://blog.wilsonl.in/search-engine
Kiwi News: https://news.kiwistand.com/stories/Building-a-web-search-engine-from-scratch-in-two-months?index=0x68a33d550d8bf7cde51d490543a7b77ee1f6a2cfb4510f1cb41592478d93dc8456f295f4
Submitted by naomiii.eth on 2025-08-18 UTC, 2 upvotes, 0 comments
## Summary
A developer built a web search engine from scratch in two months to test whether neural text embeddings can answer complex, intent-based queries better than keyword matching. A cluster of 200 GPUs generated 3 billion SBERT embeddings, and at peak hundreds of crawlers ingested 50K pages per second, culminating in an index of 280 million pages. End-to-end query latency landed around 500 ms, with RocksDB and HNSW sharded across 200 cores, 4 TB of RAM, and 82 TB of SSDs. The stated motivations were a perceived decline in search quality, including more SEO spam and less relevant content.
## Article
Published August 10, 202534 min read
A while back, I decided to undertake a project to challenge myself: build a web search engine from scratch. Aside from the fun deep dive opportunity, there were two motivators:
Search engines seemed to be getting worse, with more SEO spam and less relevant quality content.
Transformer-based text embedding models were taking off and showing amazing natural comprehension of language.
A simple question I had was: why couldn't a search engine always result in top quality content? Such content may be rare, but the Internet's tail is long, and better quality results should rank higher than the prolific inorganic content and engagement bait you see today.
Another pain point was that search engines often felt underpowered, closer to keyword matching than human-level intelligence. A reasonably complex or subtle query couldn't be answered by most search engines at all, but the ability to would be powerful:
Search engines cover broad areas of computer science, linguistics, ontology, NLP, ML, distributed systems, performance engineering, and so on. I thought it'd be interesting to see how much I could learn and cover in a short period. Plus, it'd be cool to have my own search engine. Given all these points, I dived right in.
In this post, I go over the 2-month journey end-to-end, starting from no infra, bootstrapped data, or any experience around building a web search engine. Some highlights:
A cluster of 200 GPUs generated a combined 3 billion SBERT embeddings.
At peak, hundreds of crawlers ingested 50K pages per second, culminating in an index of 280 million.
End-to-end query latency landed around 500 ms.
RocksDB and HNSW were sharded across 200 cores, 4 TB of RAM, and 82 TB of SSDs.
You can play around with a deployed instance of this search engine as a live demo. Here's a high-level architecture map of the system that will be covered in this post:
Proving ground
Normalization
Chunking
Semantic context
Statement chaining
Initial results
Crawler
Pipeline
Storage
Service mesh
GPU buildout
Sharded HNSW
Optimizing latency
Knowledge graph
SERP
AI assistant
State tracking
Search quality
Live demo
Costs
Conclusion and what's next
Proving ground
I started off by creating a minimal playground to experiment if neural embeddings were superior for search: take some web page, chunk it up, and see if I can answer complex indirect natural language queries with precision.
As an example, let's say I'm looking at the S3 documentation. Here are how some queries are answered by current systems, and how I envisioned they should be answered:
QueryTraditional searchNeural search
i want to use s3 instead of postgres but with databases i can tag some human comment with some file in another columnRandom results about Postgres, S3, filesYou can also specify custom metadata at the time that the object is stored.
why does CORS still not work after allowing all?Random snippet about CORS, "S3 not working", object permissionsBucket configurations have an eventual consistency model...
could files get lost or corrupted?(No result shown)If a PUT request is successful, your data is safely stored.
can i use s3 from lua?(No result shown)The architecture of Amazon S3 is designed to be programming language-neutral, ... With the REST API, you use standard HTTP requests to create, fetch, and delete buckets and objects.
Basically, the search engine should understand intent, not keywords:
Queries are understood as a whole instead of broken down into keywords and phrases.
No need for query engineering: operators, format, right words to use.
"Tip of the tongue", implicit, and conceptual queries are mapped correctly to the right answer.
You can ask multi-concept, complex, nuanced queries and surface non-obvious relationships.
This is already great for searches in general. But it'd also be great for finding insights, unnoticed connections, and hidden gems. You can ask some very sophisticated specific query, and the search engine will surface a one-line sentence in an obscure quality essay. You could write a paragraph of your thoughts and views, and find other writers and areas with similar perspectives. You could query with phrases that signal quality and depth, to find content and communities of similar values.
Here's the sandbox flywheel I initially created to prove the concept:
Grow set of gathered diverse raw web pages.
Parse, normalize, augment, and embed them into a queryable HNSW index.
Build and expand the test dataset of queries as I crawl, debug, experiment, and eval.
Create a UI to introspect data at each stage, see failure points, and tune.
Normalization
HTML represents content in tags which have various intents: layout, text, media, interactivity, metadata, and app programming. Since a search engine focuses on text content, the first step of any pipeline is to sanitize and regularize the noisy markup from a crawled page and extract out semantic text. WHATWG already defines plenty of semantic elements and rules, which I subsetted into the following mini-spec:
Structures should be consistent: table > (thead, tbody, tfoot) > tr > (th, td); (ul, ol) > li.
Only semantic text elements should be kept: p, table, pre, blockquote, ul, ol, dl.
Text is trimmed and collapsed; no loose or unexpected text nodes outside of
.
Flatten text trees so that retrieving and mutating text spans (which happens often) doesn't require traversing and reconciling trees.
Remove or unwrap as many nodes as possible: scripts, attributes, empty elements,
, comment nodes, foreign/layout elements.
If main > article exists, use it instead of whole page.
One goal is to remove all of the chrome on a page as they're not part of the content, which pollute the context and distort meaning:
Menu bars, nav links, banners, footers, site information.
Comments sections, asides, links to other articles.
Interface elements, forms, controls, social media buttons.
These can get mixed up with the primary content and dilute the search engine's understanding of the page's actual content and intent, causing poor query results.
Removing these is straightforward if the page uses semantic elements like or ARIA roles, but otherwise devolves into heuristics and NLP. Methods like pattern matching on classes and IDs is fraught, and removing content by accident is worse than keeping in noise. More advanced methods like visually classifying DOM structure or training statistical text models are possible given more time and resources.
Given HTML's laxness, many sites don't follow these rules rigorously, so you get undercoverage and overcoverage. Unfortunately, this even applies to some big sites that could not be ignored, so I had to hard code some special rules for them (much like a well-known browser).
Example special rules for en.wikipedia.org
if re.match(r"^en\.wikipedia\.org/wiki/", url): if tag_name not in HEADING_ELEMS: last_heading = find_prev_sibling(child, lambda e: e.tagName in HEADING_ELEMS) if ( last_heading and last_heading.tagName == "h2" and get_text_content(last_heading).replace("[edit]", "").strip() in ("Sources", "Further reading", "External links", "See also") ): # This is in a section we don't want to keep. continue classes = set(child.getAttribute("class").split(" ")) if "hatnote" in classes: continue # Remove "meta" information about the Wikipedia article itself. See https://en.wikipedia.org/wiki/Wikipedia:Hatnote. if tag_name == "ol" and "references" in classes: continue # Remove section containing list of references. if tag_name == "table" and "sidebar" in classes: continue # Remove sidebar, which sometimes contains useful facts but often just contains "adjacent" information and links, and is hard to parse due to use of table for formatting (not semantics). if "thumb" in classes: continue # Remove figures. if "navbox" in classes: continue # Remove the navigation boxes at the bottom of the page. if "printfooter" in classes: continue # Remove the message "Retrieved from $url". if child.getAttribute("id") == "siteSub": continue # Remove the message "From Wikipedia, the free encyclopedia". if c.tagName == "sup" and "reference" in classes: continue # Remove numbered references around square brackets within body text. if "mw-jump-link" in classes: continue # Remove "Jump to content" link. if "mw-editsection" in classes: continue # Remove "[edit]" links. if "mw-ui-button" in classes: continue # Remove UI buttons. if "wb-langlinks-edit" in classes: continue # Remove "Edit links" link. if "mwe-math-fallback-image-display" in classes or "mwe-math-fallback-image-inline" in classes: continue # This is a fallback, we can remove it as we handle