Skip to content
Go back

Implementing Semantic Search with the Laravel AI SDK

We recently moved Growth Method to PostgreSQL. Once we were on Postgres, an obvious next step appeared: replace our Algolia-powered search with semantic search using pgvector and the Laravel AI SDK — better results, one less paid dependency, and zero new infrastructure.

Why replace Algolia?

Algolia is a great product, but for our use case it was overkill. We had a single searchable model (Campaigns) and one search input (a ⌘K command palette). Algolia was billing us per search request for what amounted to keyword matching.

With pgvector, we get semantic search — search that understands meaning — running directly inside the database we already pay for. No external API calls for indexing, no separate search service to manage.

Traditional keyword search matches the exact words you type. If you search for “reduce churn”, it only finds campaigns containing “reduce” or “churn”.

Semantic search understands what you mean. It converts text into numerical representations (called embeddings) that capture meaning, then finds content with similar meaning — even when the words are completely different.

You search for…Keyword search findsSemantic search also finds
”reduce churn”Campaigns containing “reduce” or “churn""Improve customer retention with weekly check-ins"
"get more signups”Campaigns containing “signups""Increase free trial conversions on landing page"
"word of mouth”Nothing (no campaigns contain this phrase)“Referral programme”, “Warm outreach to existing contacts"
"pricing”Campaigns containing “pricing""Test annual vs monthly billing toggle on checkout”

The “word of mouth” example is the one that sold me. We had campaigns about referral programmes, influencer partnerships, and warm outreach — all conceptually about word of mouth — but keyword search couldn’t connect them.

How does it work?

The basic flow is:

  1. When a campaign is created, send its text to OpenAI’s embedding API
  2. OpenAI returns an array of 1,536 numbers that represent the meaning of that text
  3. Store that array in a vector column alongside the campaign
  4. When someone searches, embed their query the same way and find the closest matches

Laravel 13 has built-in support for all of this via the AI SDK and the whereVectorSimilarTo query builder method. (We recently migrated from PrismPHP to the Laravel AI SDK, which made this possible.)

Choosing a search UX pattern

Before building, I had to decide how semantic search should feel in the UI. There are four common patterns:

Pattern 1: Hybrid with re-ranking. Show keyword results instantly as the user types, then silently replace them with semantic results once the embedding comes back (~300ms later). This is what Notion AI, Linear, and Algolia’s AI re-ranking do.

Pattern 2: Semantic on Enter. Show keyword suggestions as you type, run the full semantic search when the user presses Enter. This is how Google and GitHub work.

Pattern 3: Debounced semantic only. Wait until the user stops typing, then run semantic search. No keyword fallback. Feels slightly laggy.

Pattern 4: Manual toggle. Let the user switch between “Keyword” and “Smart” search. Works for developer tools, confusing for everyone else.

We went with Pattern 1 — hybrid with re-ranking. Our users aren’t developers, so they won’t know to press Enter for “smarter results.” Having it just work automatically is better UX. The worst case is they see keyword results for 300ms before semantic results appear.

In the Livewire component, this means: try semantic search first, fall back to keyword if it fails or returns nothing:

$campaigns = collect();

// Try semantic search for queries of 3+ characters
if (strlen($this->search) >= 3) {
    try {
        $campaigns = Campaign::query()
            ->select(['id', 'name', 'description', 'stage'])
            ->whereNotNull('embedding')
            ->whereVectorSimilarTo('embedding', $this->search, minSimilarity: 0.3)
            ->take(5)
            ->get();
    } catch (\Throwable) {
        // Fall through to keyword search
    }
}

// Fall back to keyword search
if ($campaigns->isEmpty()) {
    $campaigns = Campaign::query()
        ->select(['id', 'name', 'description', 'stage'])
        ->where(function ($query) {
            $query->whereLike('name', "%{$this->search}%")
                ->orWhereLike('description', "%{$this->search}%");
        })
        ->take(5)
        ->get();
}

When you pass a plain string to whereVectorSimilarTo, Laravel generates the embedding for you using your configured AI provider.

Implementation

1. Enable pgvector

pgvector is a PostgreSQL extension. If you’re on Laravel Cloud, it’s already included. On a self-managed server, install it once:

sudo apt install postgresql-18-pgvector

Then in your migration, Laravel handles the rest:

Schema::ensureVectorExtensionExists();

Schema::table('campaigns', function (Blueprint $table) {
    $table->vector('embedding', dimensions: 1536)->nullable()->index();
});

The dimensions: 1536 matches OpenAI’s text-embedding-3-small model. The index() creates an HNSW index for fast similarity searches.

2. Generate embeddings

We dispatch a queued job whenever a campaign is created, using Laravel’s event system. The job calls OpenAI’s embedding API and stores the result:

$embedding = Embeddings::for([$text])->generate()->first();

$campaign->updateQuietly(['embedding' => $embedding->embedding]);

For existing data, we wrote an artisan command to backfill all 1,119 campaigns. It took about a minute to process them all through the queue.

3. Configure the AI provider

Laravel’s config/ai.php just needs your OpenAI key:

'default_for_embeddings' => 'openai',

'providers' => [
    'openai' => [
        'driver' => 'openai',
        'key' => env('OPENAI_API_KEY'),
    ],
],

The embedding cost is negligible — roughly $0.02 per million tokens.

The gotcha: client-side filtering

This one cost me an hour of debugging, so I’ll save you the trouble.

We use the Flux command component for our ⌘K palette. After wiring everything up, semantic search appeared to return nothing. The server response contained the correct results, but the UI showed an empty list.

The problem: flux:command has built-in client-side filtering. It compares the input text against each option and hides anything that doesn’t match as a substring. Since “word of mouth” doesn’t contain the word “referral”, Flux was hiding the semantic results.

The fix is one prop:

<flux:command :filter="false">

This tells Flux to display whatever items the server returns without applying its own filtering. If you’re building server-driven search with any client-side filtering component, check for this.

Tuning with minSimilarity

The whereVectorSimilarTo method accepts a minSimilarity threshold between 0 and 1, where 1.0 means identical:

->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.3)

We started at 0.3, which is fairly permissive. Tune this by feel:

The right threshold depends on your data and how your users search.

What we might build next

The pgvector infrastructure we built for search is the foundation for other features:

Each of these reuses the same embedding column and query pattern. The hard part — getting pgvector set up, embeddings generated, and the query working — is done.

Questions I asked along the way

“Is adding an embedding column to a table the standard implementation?”

Yes — each table you want to search semantically gets its own embedding column. But not everything needs one. Embeddings are for understanding the meaning of unstructured text (descriptions, hypotheses, summaries). Structured data like dates, numbers, and user IDs don’t need embeddings — that’s what normal SQL queries are for.

“How do other apps handle semantic search vs instant as-you-type results?”

There are four main patterns (described above). The key insight is that semantic search requires an API call to generate the query embedding, which adds ~200-500ms latency. Most apps solve this by showing keyword results instantly and upgrading to semantic results when they’re ready.

Wrapping up

If you’re already on PostgreSQL, adding semantic search takes a migration and a few files. The Laravel AI SDK handles the embedding generation, whereVectorSimilarTo handles the querying, and pgvector handles the storage and indexing. The whole implementation was a few files and a migration.

The main thing I’d do differently: check for client-side filtering in your UI components before spending an hour debugging why results aren’t appearing.


Back to top ↑