How to Add AI Search to Any Website with Embeddings and Supabase

Friday 13/02/2026

·8 min read
Share:

Your users are typing "how do I cancel my subscription" into your search bar and getting zero results because the docs page is titled "Managing your billing." Keyword search breaks the moment someone phrases things differently than you wrote them. You need semantic search — and it's way easier to add than you think.

Here's how to build an AI search feature using OpenAI embeddings and Supabase's pgvector extension. We'll index your content, store the embeddings, and build a React search component that actually understands what users mean.

The stack

  • OpenAI's embedding API — converts text into vectors (using text-embedding-3-small, which is cheap and fast)
  • Supabase — Postgres database with the pgvector extension for storing and querying vectors
  • React — for the search UI
pnpm add openai @supabase/supabase-js

First, enable the pgvector extension in your Supabase project. Go to the SQL Editor in your Supabase dashboard and run:

-- Enable the pgvector extension
create extension if not exists vector;

-- Create a table for your documents
create table documents (
  id bigserial primary key,
  title text not null,
  content text not null,
  url text not null,
  embedding vector(1536)
);

-- Create an index for fast similarity search
create index on documents using ivfflat (embedding vector_cosine_ops)
  with (lists = 100);

-- Create a function to search by similarity
create or replace function match_documents (
  query_embedding vector(1536),
  match_threshold float default 0.7,
  match_count int default 5
)
returns table (
  id bigint,
  title text,
  content text,
  url text,
  similarity float
)
language sql stable
as $$
  select
    documents.id,
    documents.title,
    documents.content,
    documents.url,
    1 - (documents.embedding <=> query_embedding) as similarity
  from documents
  where 1 - (documents.embedding <=> query_embedding) > match_threshold
  order by documents.embedding <=> query_embedding
  limit match_count;
$$;

The 1536 dimension matches OpenAI's text-embedding-3-small model. The <=> operator is cosine distance — pgvector handles the heavy lifting.

Generating and storing embeddings

You need a script that takes your content, generates embeddings, and stores them in Supabase. This runs once to index your content, then again whenever you add or update pages.

// scripts/index-content.ts
import OpenAI from 'openai'
import { createClient } from '@supabase/supabase-js'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_KEY!
)

interface Document {
    title: string
    content: string
    url: string
}

async function generateEmbedding(text: string): Promise<number[]> {
    const response = await openai.embeddings.create({
        model: 'text-embedding-3-small',
        input: text,
    })
    return response.data[0].embedding
}

async function indexDocuments(documents: Document[]) {
    console.log(`Indexing ${documents.length} documents...`)

    for (const doc of documents) {
        // Combine title and content for a richer embedding
        const textToEmbed = `${doc.title}\n\n${doc.content}`
        const embedding = await generateEmbedding(textToEmbed)

        const { error } = await supabase.from('documents').upsert(
            {
                title: doc.title,
                content: doc.content,
                url: doc.url,
                embedding,
            },
            { onConflict: 'url' }
        )

        if (error) {
            console.error(`Failed to index "${doc.title}":`, error.message)
        } else {
            console.log(`Indexed: ${doc.title}`)
        }
    }

    console.log('Done.')
}

// Example: index your pages
const pages: Document[] = [
    {
        title: 'Managing your billing',
        content: 'To cancel your subscription, go to Settings > Billing and click Cancel Plan...',
        url: '/docs/billing',
    },
    {
        title: 'Getting started',
        content: 'Create an account and follow the onboarding wizard to set up your first project...',
        url: '/docs/getting-started',
    },
]

indexDocuments(pages)

Run it with npx tsx scripts/index-content.ts. In a real project, you'd pull your content from your CMS, markdown files, or database instead of hardcoding it.

Gotcha: The text-embedding-3-small model has an input limit of 8191 tokens. If your pages are long, chunk them into ~500-word pieces. Each chunk becomes its own row in the database. This is the same chunking strategy used in RAG chatbots — the concept is identical.

Building the search API

Now create an API route that takes a search query, converts it to an embedding, and finds matching documents:

// src/app/api/search/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server'
import OpenAI from 'openai'
import { createClient } from '@supabase/supabase-js'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_KEY!
)

export async function POST(req: NextRequest) {
    const { query } = await req.json()

    if (!query || typeof query !== 'string') {
        return NextResponse.json({ error: 'Query is required' }, { status: 400 })
    }

    // Generate embedding for the search query
    const embeddingResponse = await openai.embeddings.create({
        model: 'text-embedding-3-small',
        input: query,
    })
    const queryEmbedding = embeddingResponse.data[0].embedding

    // Search for matching documents using the Supabase function
    const { data, error } = await supabase.rpc('match_documents', {
        query_embedding: queryEmbedding,
        match_threshold: 0.5,
        match_count: 5,
    })

    if (error) {
        console.error('Search error:', error)
        return NextResponse.json({ error: 'Search failed' }, { status: 500 })
    }

    return NextResponse.json({ results: data })
}

The key insight: the user's query gets the same embedding treatment as your indexed content. "Cancel my subscription" and "Managing your billing" end up as vectors that point in similar directions — that's how semantic search works.

Building the React search component

Here's a search component with debouncing so you're not hammering the API on every keystroke:

// src/components/AISearch.tsx
'use client'

import { useState, useCallback, useRef } from 'react'

interface SearchResult {
    id: number
    title: string
    content: string
    url: string
    similarity: number
}

export default function AISearch() {
    const [query, setQuery] = useState('')
    const [results, setResults] = useState<SearchResult[]>([])
    const [loading, setLoading] = useState(false)
    const debounceTimer = useRef<NodeJS.Timeout | null>(null)

    const search = useCallback(async (searchQuery: string) => {
        if (searchQuery.length < 3) {
            setResults([])
            return
        }

        setLoading(true)
        try {
            const res = await fetch('/api/search', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ query: searchQuery }),
            })
            const data = await res.json()
            setResults(data.results || [])
        } catch (err) {
            console.error('Search failed:', err)
            setResults([])
        } finally {
            setLoading(false)
        }
    }, [])

    const handleInput = (value: string) => {
        setQuery(value)
        if (debounceTimer.current) clearTimeout(debounceTimer.current)
        debounceTimer.current = setTimeout(() => search(value), 300)
    }

    return (
        <div className="w-full max-w-2xl mx-auto">
            <input
                type="text"
                value={query}
                onChange={(e) => handleInput(e.target.value)}
                placeholder="Search docs..."
                className="w-full px-4 py-3 border rounded-lg text-lg"
            />

            {loading && <p className="mt-2 text-gray-500">Searching...</p>}

            {results.length > 0 && (
                <ul className="mt-4 space-y-3">
                    {results.map((result) => (
                        <li key={result.id}>
                            <a
                                href={result.url}
                                className="block p-4 border rounded-lg hover:bg-gray-50"
                            >
                                <h3 className="font-semibold">{result.title}</h3>
                                <p className="text-sm text-gray-600 mt-1">
                                    {result.content.slice(0, 150)}...
                                </p>
                                <span className="text-xs text-gray-400 mt-1 block">
                                    {Math.round(result.similarity * 100)}% match
                                </span>
                            </a>
                        </li>
                    ))}
                </ul>
            )}

            {query.length >= 3 && !loading && results.length === 0 && (
                <p className="mt-4 text-gray-500">No results found.</p>
            )}
        </div>
    )
}

Cost reality check

OpenAI's text-embedding-3-small costs $0.02 per 1M tokens. For context:

  • Indexing 1,000 pages (average 500 words each): ~$0.01
  • 10,000 search queries per month: ~$0.10

This is essentially free. The Supabase free tier gives you 500MB of storage and unlimited API calls, which easily handles tens of thousands of documents with their embeddings.

Compare that to Algolia ($1/1,000 requests) or Elasticsearch (managing your own cluster). Vector search on Supabase is the cheapest option for most projects.

Performance tips

A few things I learned the hard way:

Use the right index. The ivfflat index we created earlier is good for up to ~100k rows. If you go beyond that, switch to hnsw:

create index on documents using hnsw (embedding vector_cosine_ops);

HNSW is faster for large datasets but uses more memory and takes longer to build.

Cache embeddings for repeated queries. If users keep searching the same things, cache the query embedding so you skip the OpenAI API call:

// Simple in-memory cache for query embeddings
const embeddingCache = new Map<string, number[]>()

async function getQueryEmbedding(query: string): Promise<number[]> {
    const normalized = query.toLowerCase().trim()
    const cached = embeddingCache.get(normalized)
    if (cached) return cached

    const response = await openai.embeddings.create({
        model: 'text-embedding-3-small',
        input: normalized,
    })
    const embedding = response.data[0].embedding
    embeddingCache.set(normalized, embedding)
    return embedding
}

Lower the similarity threshold for broader results. We used 0.5 in the API route, but the right number depends on your content. Start at 0.5 and adjust — too high and you miss relevant results, too low and you get noise.

What's next

This setup works great for searching your own content, but what if you want the search results to feed into a conversational AI? That's exactly what RAG is — check out Build a RAG Chatbot in 100 Lines of TypeScript where we use the same embedding + vector search approach but pipe the results into Claude for natural language answers.

If you're building an AI feature that calls external APIs, you'll also want to handle rate limits and errors properly in production — that's coming in a future post.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts