Skip to content

Embeddings for Internal Search: The Pattern That Replaces ElasticSearch for Most SMB Sites

Most SMB sites have either no internal search or terrible internal search. Embeddings fix it for $0 of new infrastructure.

John Cravey with AIFounder4 min readUpdated Jul 6, 2026

Most SMB sites either skip internal search (the user has to navigate manually) or run a clunky keyword-based search that misses everything except exact matches. Vector embeddings + Supabase + a small Next.js search component changes that for $0 of new infrastructure. The user can search for ‘how much does a kitchen remodel cost in Plano’ and find the post titled ‘What Custom Kitchens Run in DFW’ because the embeddings understand it’s the same intent. Here’s the setup.

Free estimate · 2 minutes

AI search is already sending buyers. Be citable.

Build the estimate to see the AI-visibility work we would ship for your site in the first thirty days. Sixty seconds, no opt-in.

Why this matters more than people think

Internal site search produces 2-4x the conversion rate of regular browsing on sites that have it. Users who search are higher-intent — they’re looking for something specific. If your search is bad, those users leave to Google and may not come back. If your search is good, they find what they wanted and convert.

Keyword search matches the exact tokens the user typed. ‘kitchen remodel cost’ matches pages containing those words. ‘how much for a new kitchen’ matches different pages, even though the intent is identical. Semantic search via embeddings understands both queries point to the same intent and returns the same results.

The architecture

  1. On content publish: split each page into 300-600 word chunks. Embed each chunk. Store in Postgres with the source URL.
  2. On user search: embed the query. Vector-similarity-search against the chunks. Group results by source URL, take the best chunk per URL.
  3. Render: show the top 8 URLs, with the best-matching chunk as a snippet preview.

Schema

create extension if not exists vector;

create table search_chunks (
  id uuid primary key default gen_random_uuid(),
  site_id text references sites(id) not null,
  url text not null,
  title text not null,
  chunk_text text not null,
  embedding vector(1024),
  created_at timestamptz default now()
);

create index on search_chunks using hnsw (embedding vector_cosine_ops);
create index on search_chunks(site_id);

Indexing the content

Build an indexing script that runs at deploy time. For FH-style sites where content lives in TypeScript (the static-generation pattern), walk the post and page data structures, chunk each one, embed each chunk, upsert into search_chunks.

// scripts/index-search.ts
import { POSTS } from "@/lib/blog/posts";
import { SERVICES } from "@/app/components/home/data";
import { embed } from "./embed";
import { supabaseAdmin } from "./supabase";

async function indexAll(siteId: string) {
  await supabaseAdmin.from("search_chunks").delete().eq("site_id", siteId);
  
  for (const post of POSTS) {
    const text = postToPlainText(post);
    const chunks = chunkByHeadings(text, 500);
    for (const chunk of chunks) {
      const embedding = await embed(chunk);
      await supabaseAdmin.from("search_chunks").insert({
        site_id: siteId,
        url: `/blog/${post.slug}`,
        title: post.title,
        chunk_text: chunk,
        embedding,
      });
    }
  }
}

Search server action

// app/search/actions.ts
"use server";
import "server-only";
import { embed } from "@/lib/ai/embed";
import { supabase } from "@/lib/supabase/server";

export async function search(query: string, siteId: string) {
  if (query.length < 2) return [];
  const queryEmbedding = await embed(query, { inputType: "query" });
  const { data } = await supabase.rpc("match_search_chunks", {
    site_id_filter: siteId,
    query_embedding: queryEmbedding,
    match_count: 20,
  });
  if (!data) return [];
  // Group by URL, take best chunk per URL
  const byUrl = new Map<string, typeof data[number]>();
  for (const row of data) {
    if (!byUrl.has(row.url) || row.similarity > byUrl.get(row.url)!.similarity) {
      byUrl.set(row.url, row);
    }
  }
  return [...byUrl.values()].slice(0, 8);
}

Hybrid: keyword + vector

Pure vector search misses exact title matches (a user typing the exact post title doesn’t need semantic search, they need keyword match). Combine: run both BM25 keyword search (via Postgres ts_vector) and vector search, then reciprocal-rank-fusion them. Code is ~30 lines, quality improvement is meaningful.

Latency budget

Embed the query: 50-150ms. Postgres vector search: 10-40ms. Total: ~100-200ms. Acceptable for an as-you-type search box. If you want faster, batch + debounce on the client (don’t fire on every keystroke, fire 300ms after the last keystroke).

Cost

Embedding generation: ~$0.10 per million tokens with Voyage’s voyage-3-large or OpenAI’s text-embedding-3-small. Indexing the entire FH blog (60+ posts, ~150 chunks at 500 tokens each) costs ~$0.01. Per-query cost: ~$0.0001. For SMB volumes (thousands of searches per month), monthly cost is negligible.

Chunking strategy for blog content

Use the post’s H2 structure. Each H2 section becomes a chunk. Pre-pend the post title to each chunk so the embedding has document-level context. Result: chunks are semantically coherent (single topic per chunk) and search results land users on the relevant section of the post.

Search UI

Two patterns. (1) Modal with search field + result list (Cmd+K style). (2) Dedicated /search page with persistent URL. We default to modal for FH client blogs because it doesn’t require navigation and feels fast.

Common mistakes

  • Embedding documents and queries with different models. Use the same model for both, or use an asymmetric model with the right inputType.
  • Storing only one embedding per document. Long documents have multiple topics; chunking captures all of them.
  • Forgetting to re-index after content changes. Build a CI step that re-indexes on every deploy.
  • Skipping the title in the chunk. Without it, sections lose their document context and rank worse.

How this lands across FH client work

Two FH client sites have semantic search live. Both are content-heavy (a 200-post blog and a 600-page documentation site). User search → click-through rate is 32% higher than navigation-only conversions. Total infrastructure cost: zero — runs on existing Supabase. If your site has 50+ content pages and either no search or bad search, book a consultation — the implementation is a 3-day engagement that ships a real search experience.

Answers

Frequently asked questions

Why does internal search matter more than people think?

Because someone using search has already declared intent: they know what they want and cannot find it. That is the highest-intent moment on the site, and most small-business sites either have no search or one that returns nothing useful, which turns intent into an exit.
Matching on meaning rather than exact words, so a visitor asking how to cancel finds a page that says terminate your subscription. Keyword search fails on synonyms and phrasing, which is exactly how real people search when they do not know your vocabulary.

Do I need ElasticSearch or a dedicated search service?

For most small-business sites, no. Postgres with a vector extension handles semantic search alongside the data you already store, which removes a service to operate, secure, and keep in sync. The dedicated stack earns its place at a scale most business sites never reach.

How is content indexed for this?

Each piece of content is embedded into a vector stored beside the row, refreshed when the content changes. The indexing job is small; the discipline is in keeping it current, because a search index that stopped updating is worse than none since it confidently returns stale results.

Should search run as a server action?

Yes, which keeps the query, the credentials, and the ranking on the server and returns only results. Doing similarity search in the browser means shipping either the data or the keys, and both are worse than the round trip you saved.

Why combine keyword and vector search here too?

Because visitors search for both concepts and exact strings. Someone typing a product code or an error message needs a literal match; someone describing a problem needs meaning. Running both and merging is what makes search feel reliable rather than clever.

How do I know if the search is any good?

Log the queries and check which return nothing useful. That log is the most honest content-gap report a site produces: every empty search is a visitor telling you what you failed to publish, in their own words.

What does this cost to run?

Embedding is cheap per document and paid on change rather than per search, and the query is a database operation. At small-business content volumes it is close to free, which is why the infrastructure argument for a dedicated search service rarely applies here.

How should results be presented?

With enough context to judge relevance: a title, a snippet showing why it matched, and a clear link. A list of titles alone forces the visitor to click through candidates, which recreates the frustration search was meant to remove.

What is the most common implementation mistake?

Chunking content badly, so results are fragments that make no sense on their own. Split along real boundaries such as sections or complete answers, not fixed character counts, and the same setup goes from unusable to useful with no other change.

Does this help SEO?

Indirectly and meaningfully. The query log tells you what people expected to find and did not, which is a content brief written by your own visitors. Publishing answers to those queries improves both the site and its search visibility.

When would a dedicated search product still be right?

At large content volumes, with complex faceting, multi-language requirements, or a need for search analytics as a product surface. Those are real reasons. Wanting search to be better when the current one is a keyword LIKE query is not one of them.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
The Death of Cookie-Based Tracking: What Replaces It in 2026
Older post
Reading Supabase Logs: The Five Queries That Catch 80% of Production Issues
Keep reading

More from the blog

AI·5 min

RAG for SMB Sites: When Retrieval-Augmented Generation Actually Solves a Real Problem

RAG is the right answer about 10% of the time. Here’s the framework for the other 90%.

AI·5 min

Tool Use With Claude: Building Agents That Don’t Hallucinate Your Production Data

Agents are powerful when they have tools. They’re dangerous when those tools aren’t bounded. Here’s the safe pattern.

SEO·5 min

SEO for AI: How to Rank When the Search Engine Is an Answer Engine

More of your buyers get their answer from an AI than from page one. The playbook for being that answer is concrete, and most of your competitors have not run it.