DEV Community

Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Mastering Supabase pgvector for Semantic Search in Next.js

import { Callout } from '@/components/mdx'

Introduction

Search is a fundamental feature of any content-heavy SaaS or application, but traditional full-text keyword search (ILIKE '%search_term%') feels archaic in the AI era. Users don't use exact terminology—they search by context, concept, and intent.

Using Supabase pgvector in combination with OpenAI embeddings and Next.js, we can build a semantic search engine that understands meaning. If a user searches for "billing issues", the engine will surface documents about "credit card declines" or "invoice errors"—even if the literal words are completely different.

1. Preparing the Supabase Database

First, we need to enable the vector extension in Postgres and create a table designed to store our application's text alongside high-dimensional embedding arrays.

In your Supabase SQL editor (or via local migrations), run:

-- Enable the pgvector extension to work with embedding vectors
CREATE EXTENSION IF NOT EXISTS vector;

-- Create our knowledge base table
CREATE TABLE public.documents (
  id uuid primary key default gen_random_uuid(),
  content text not null,
  metadata jsonb,
  -- 1536 is the correct dimensionality for OpenAI's text-embedding-3-small
  embedding vector(1536) 
);

-- Create an HNSW index for blazing fast vector similarity search
CREATE INDEX ON public.documents USING hnsw (embedding vector_cosine_ops);
Enter fullscreen mode Exit fullscreen mode

Writing the Match Function

Supabase exposes SQL functions directly to your frontend via RPC (Remote Procedure Call). We need a function that takes a search embedding array and compares it to our documents table using cosine distance (<=>).

CREATE OR REPLACE FUNCTION match_documents (
  query_embedding vector(1536),
  match_threshold float,
  match_count int
)
RETURNS TABLE (
  id uuid,
  content text,
  similarity float
)
LANGUAGE sql STABLE
AS $$
  SELECT
    documents.id,
    documents.content,
    1 - (documents.embedding <=> query_embedding) AS similarity
  FROM documents
  -- Filter by threshold to only return relevant matches
  WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
  ORDER BY documents.embedding <=> query_embedding
  LIMIT match_count;
$$;
Enter fullscreen mode Exit fullscreen mode


Why HNSW? The hnsw index is vastly superior to the older ivfflat index. HNSW provides faster query times and doesn't require rebuilding the index as your dataset scales. It consumes slightly higher RAM, but the performance payoff in production Next.js apps is unarguable.

2. Generating Embeddings via Next.js Server Actions

To insert searchable data, we must take plain text (e.g., an article, a user profile) and convert it into a vector array via OpenAI, then store it in Supabase. We do this securely server-side.

Install the required SDK: npm install openai @supabase/ssr

// app/actions/embeddings.ts
'use server'

import OpenAI from 'openai'
import { createClient } from '@/lib/supabase/server'

const openai = new OpenAI()

export async function ingestDocument(content: string) {
  const supabase = createClient()

  // 1. Generate the embedding from OpenAI
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: content.trim(),
  })

  const embedding = response.data[0].embedding

  // 2. Insert into Supabase
  const { error } = await supabase.from('documents').insert({
    content,
    embedding
  })

  if (error) {
    throw new Error('Failed to insert vector into database')
  }

  return { success: true }
}
Enter fullscreen mode Exit fullscreen mode

3. The Search Experience Implementation

Now, we build the actual search implementation. When the user submits a search string, we process it into an embedding vector, then call the match_documents SQL function we defined earlier.

// app/actions/search.ts
'use server'

import OpenAI from 'openai'
import { createClient } from '@/lib/supabase/server'

export async function performSemanticSearch(query: string) {
  const openai = new OpenAI()
  const supabase = createClient()

  // Turn user text query into an embedding
  const embeddingResponse = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: query,
  })

  const queryEmbedding = embeddingResponse.data[0].embedding

  // Call the Postgres RPC function
  const { data: matchedDocs, error } = await supabase.rpc('match_documents', {
    query_embedding: queryEmbedding,
    match_threshold: 0.70, // Adjust depending on strictness needs
    match_count: 5 // Return top 5 results
  })

  if (error) throw new Error(error.message)

  return matchedDocs
}
Enter fullscreen mode Exit fullscreen mode

The Search UI

Using Next.js Client Components and useTransition, we can craft a beautiful, responsive search interface.

// components/SemanticSearchbar.tsx
'use client'

import { useState, useTransition } from 'react'
import { performSemanticSearch } from '@/app/actions/search'

export function SemanticSearchbar() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<any[]>([])
  const [isPending, startTransition] = useTransition()

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    startTransition(async () => {
      const docs = await performSemanticSearch(query)
      setResults(docs)
    })
  }

  return (
    <div className="max-w-xl mx-auto space-y-4">
      <form onSubmit={handleSearch} className="flex gap-2">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search by meaning or concept..."
          className="w-full p-2 border rounded-md"
        />
        <button 
          type="submit" 
          disabled={isPending}
          className="px-4 py-2 bg-black text-white rounded-md"
        >
          {isPending ? 'Searching...' : 'Search'}
        </button>
      </form>

      <ul className="grid gap-2">
        {results.map((r, i) => (
          <li key={i} className="p-4 border shadow-sm rounded-md">
            <p className="text-sm text-gray-800">{r.content}</p>
            <span className="text-xs text-green-600 block mt-2">
              {(r.similarity * 100).toFixed(1)}% Match
            </span>
          </li>
        ))}
      </ul>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Considerations for Scale

  1. Chunking: Don't embed entire 5,000-word articles as a single vector. Split them into 300-500 token chunks. When a chunk is matched, return the parent document.
  2. Metadata Filtering: Combine semantic search with metadata matching. If a user only wants "Active" projects, add traditional SQL filtering to your RPC function explicitly alongside cosine distance.

Key Takeaways

  • Vector math solves strict lexical issues: Enable pgvector and build HNSW indexes to allow your Next.js application to "understand" search intent.
  • Server Actions streamline embeddings: Keep the OpenAI SDK server-side and trigger it directly from forms using Next 14+ Server Actions.
  • RPC solves complex SQL in Supabase: Utilize the RPC method to invoke complex mathematical similarity matching securely.

Next Steps

To push your Next.js application's boundaries even further into AI territory, check out our comprehensive guide on Architecting Next.js Servers for AI Integration where we discuss streaming architectures and agentic UI interfaces.


Originally published at https://www.iloveblogs.blog

Top comments (0)