Keyword Search vs. Semantic Search: How I Upgraded My Site’s Search
My site’s search bar started as a simple keyword filter, then learned to search by meaning with vector embeddings — here’s the difference, how each version works, and why the second one became the foundation for an AI chatbot
Ingredients
- Claude Code — terminal-based AI for direct file editing ($200/yr)
- Supabase with pgvector extension — Postgres database with vector similarity search (free tier)
- Hugging Face Inference API — hosted embedding model for converting text to vectors (free tier)
- tsx — runs the TypeScript build script that regenerates the search index (free)
- @xenova/transformers — local embedding model for generating content vectors at build time (free)
Keyword Search vs. Semantic Search: What’s the Difference?
Keyword search looks for the words you typed. If a page contains “firewall,” a search for “firewall” finds it. It’s fast, predictable and great for names and exact phrases, but it has no idea what words mean. Search for “server security” and it won’t find a page that only ever says “firewall.”
Semantic search looks for what you meant. It turns both your query and every page into lists of numbers (called vector embeddings) that capture meaning, then returns the pages whose numbers sit closest to your query’s. That’s why people also ask about vector search vs. semantic search: they’re two views of the same thing. Semantic search is the goal (search by meaning); vector search is the technique that gets you there (compare embeddings).
Each has a blind spot. Keyword search misses meaning; semantic search can miss exact names and gets vague with vague queries. This site uses both: keyword first for exact matches, semantic as the fallback when keywords come up empty.
Where It Started: The First Search Bar
The first version, built in March, was pure keyword search. Three decisions from that build are still worth copying.
- Generate the index; don’t hand-maintain it. A hand-edited list of pages breaks the first time you forget to update it, and it fails silently: the new post just never shows up. Instead, a small build script reads the site’s list of posts and writes the search index automatically before every production build. That meant first moving the posts list into its own file so both the Writing page and the script could read it. Adding a post is now one step, and search picks it up for free.
- Load the index lazily. The index is fetched the first time someone opens search, not on every page load, and typing is debounced so results don’t recalculate on every keystroke.
- Render overlays outside the nav with a React portal. On mobile the search panel opened but you couldn’t type into it. The panel lived inside the nav links, which are hidden on small screens with
display: none.
position: fixed escapes the page layout, but not display: none. A fixed element inside a hidden parent is still hidden. If an overlay must always be visible, render it outside that tree entirely — React’s createPortal puts it directly on <body> while it still belongs to the nav for state and events.
What Changed and Why It Matters
That keyword search bar — a build script generates a JSON index of every page, post, and feature, and the search bar filters that list client-side as you type — was fast and reliable, but it could only find things when your words matched the text. Searching “firewall” returned nothing because no title or description contained the word “firewall,” even though an entire post existed about configuring one.
The upgrade: every piece of content on the site now has a vector embedding — a list of 384 numbers that represent the meaning of that content. When you search, your query gets converted to the same kind of vector, and the database finds whichever content is closest in meaning. The word “firewall” doesn’t need to appear anywhere — the model understands that firewalls relate to server security.
Each piece of content becomes a point in 384-dimensional space. Similar content clusters together. A search query lands near the content it’s about — even without matching any keywords.
Keyword search still runs first, instantly, client-side — same as before. Vector search only fires as a fallback when keyword matching returns zero results. You get the speed of the old system for obvious queries and the intelligence of the new system for everything else.
The pgvector Setup in Supabase
Supabase runs on Postgres, and Postgres has an extension called pgvector that adds a native vector column type and distance operators. Enabling it was one line of SQL: create extension if not exists vector. After that, I created a table with a vector(384) column — each row stores one piece of content alongside its embedding.
🔧 Developer section: Schema and search function
- One table of embeddings, one row per page or post: its title, link, a short description and a
vector(384)embedding, with a uniqueness rule so re-running the script updates rows instead of duplicating them - HNSW index on the embedding column with
vector_cosine_ops— this is the data structure that makes similarity lookups fast instead of scanning every row - A database search function that takes a query vector, computes cosine similarity, drops weak matches below a cutoff, and returns the closest few ranked by closeness
- The table holds only public page summaries, nothing private
Content embeddings are generated locally using @xenova/transformers with the all-MiniLM-L6-v2 model — a build script reads the search index and TL;DR summaries, generates vectors for all 24 items, and upserts them to Supabase. At query time, the Vercel API route sends your search text to Hugging Face’s hosted version of the same model, gets a vector back, and passes it to the Supabase search function. Same model on both sides means the vectors are comparable.
Keyword vs. Semantic: Side-by-Side
| Query | Keyword Search | Semantic Search |
|---|---|---|
| “firewall” | No results | How to Secure an Always-On Home Server |
| “health data” | No results | How I Automated Daily Garmin Recaps |
| “game theory” | No results | Numerator + How I Built Numerator |
| “email automation” | No results | How I Built a Market Briefing |
| “Numerator” | Numerator (instant) | Not needed — keyword handles it |
Keyword search is still the first pass for exact matches. Semantic search catches everything keyword misses.
Where Semantic Search Got It Wrong
Searching “theory” by itself returned nothing. That felt wrong — there’s a post about building Numerator, which is based on a game theory puzzle. But “theory” alone is genuinely ambiguous. Game theory, music theory, color theory — the model can’t know which you mean, so every piece of content scores below the similarity threshold. Adding one word of context — “game theory” — immediately surfaced Numerator as the top result.
This is a feature, not a bug, but it’s worth naming: semantic search rewards specificity. Vague queries get vague results. The system is honest about what it doesn’t know rather than guessing — a property I’d rather keep than tune away.
Why This Enables A Future Feature: Ask Goose
Vector search is half of a pattern called RAG — retrieval-augmented generation. The idea: instead of asking an AI to answer a question from memory (where it might hallucinate), you first retrieve the most relevant content from your own data, then pass that content to the AI as context. The AI answers based on what you actually wrote, not what it imagines you wrote.
With embeddings in Supabase and a similarity search function already working, the retrieval half is done. Ask Goose — a conversational search feature where visitors can ask Goose questions about the site and get answers grounded in real content — becomes a matter of wiring the retrieval results into a Claude API call. The vector infrastructure built today is the foundation that makes it possible. Instead of building search and then rebuilding for AI, I built the AI-ready version first.
Building the retrieval layer before the generation layer forces you to get the data quality right first. If the embeddings return irrelevant results, no amount of prompt engineering will fix the answers. By validating search quality now, Ask Goose inherits a tested foundation instead of debugging two problems at once.