Semantic Search in Laravel: Building a Vector-Powered Product Discovery Engine

php dev.to

If you've ever typed "cozy winter boots" into a search bar and gotten results for "thermal hiking footwear" — that's semantic search working in your favour. Traditional keyword search would've returned nothing, or worse, irrelevant noise. For Laravel developers, implementing this kind of intelligent search is no longer a PhD-level undertaking. With embeddings, a vector store, and a few well-placed Artisan commands, you can ship a genuinely smart search experience.

This article walks through building a vector-powered product discovery engine in Laravel — something you can adapt for e-commerce catalogues, documentation search, or any content-heavy application.

What Is Semantic Search, Really?

Classic full-text search (think MySQL LIKE queries or even Laravel Scout with Algolia) matches on token overlap. Semantic search, by contrast, works on meaning. It converts text into high-dimensional numerical vectors (embeddings) that capture conceptual relationships. Vectors for "running shoes" and "athletic footwear" will sit close together in vector space — even though they share no keywords.

The pipeline looks like this:

  1. Generate embeddings for all your content at index time
  2. Store those embeddings in a vector database
  3. At query time, embed the user's query
  4. Find the nearest vectors (most semantically similar content)
  5. Return the results

Setting Up the Stack

For this tutorial we'll use:

  • Laravel 11
  • OpenAI PHP client for generating embeddings
  • Pgvector (PostgreSQL extension) for vector storage — it's free, battle-tested, and works with Laravel's standard Eloquent ORM
  • Laravel Scout for the search abstraction layer

First, install the dependencies:

composer require openai-php/laravel
composer require pgvector/pgvector
Enter fullscreen mode Exit fullscreen mode

Add your OpenAI key to .env:

OPENAI_API_KEY=sk-your-key-here
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
Enter fullscreen mode Exit fullscreen mode

Enabling Pgvector in Your Migration

You'll need PostgreSQL with the pgvector extension enabled. Most managed Postgres providers (Supabase, Railway, Neon) support it out of the box.

// database/migrations/2024_01_01_000001_add_embedding_to_products_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Pgvector\Laravel\Vector;

return new class extends Migration {
    public function up(): void
    {
        // Enable the extension once
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

        Schema::table('products', function (Blueprint $table) {
            // 1536 dimensions for text-embedding-3-small
            $table->vector('embedding', 1536)->nullable();
        });

        // Add an HNSW index for fast approximate nearest-neighbour search
        DB::statement(
            'CREATE INDEX products_embedding_idx ON products 
             USING hnsw (embedding vector_cosine_ops)'
        );
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('embedding');
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

Generating and Storing Embeddings

Create a service class to handle embedding generation:

// app/Services/EmbeddingService.php

namespace App\Services;

use OpenAI\Laravel\Facades\OpenAI;

class EmbeddingService
{
    public function generate(string $text): array
    {
        $response = OpenAI::embeddings()->create([
            'model' => config('openai.embedding_model', 'text-embedding-3-small'),
            'input' => $this->prepareText($text),
        ]);

        return $response->embeddings[0]->embedding;
    }

    private function prepareText(string $text): string
    {
        // Normalise whitespace and truncate to stay within token limits
        return substr(trim(preg_replace('/\s+/', ' ', $text)), 0, 8000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now wire this up to your Product model with an observer:

// app/Observers/ProductObserver.php

namespace App\Observers;

use App\Models\Product;
use App\Jobs\GenerateProductEmbedding;

class ProductObserver
{
    public function saved(Product $product): void
    {
        // Dispatch a queued job — never block the request cycle
        GenerateProductEmbedding::dispatch($product);
    }
}
Enter fullscreen mode Exit fullscreen mode
// app/Jobs/GenerateProductEmbedding.php

namespace App\Jobs;

use App\Models\Product;
use App\Services\EmbeddingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Pgvector\Laravel\Vector;

class GenerateProductEmbedding implements ShouldQueue
{
    use Queueable;

    public function __construct(private readonly Product $product) {}

    public function handle(EmbeddingService $service): void
    {
        // Build a rich text representation of the product
        $text = implode(' ', [
            $this->product->name,
            $this->product->description,
            $this->product->category,
            implode(' ', $this->product->tags ?? []),
        ]);

        $embedding = $service->generate($text);

        $this->product->updateQuietly([
            'embedding' => new Vector($embedding),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Search Controller

With embeddings stored, the actual search becomes elegant:

// app/Http/Controllers/ProductSearchController.php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Services\EmbeddingService;
use Illuminate\Http\Request;
use Pgvector\Laravel\Vector;

class ProductSearchController extends Controller
{
    public function __invoke(Request $request, EmbeddingService $service)
    {
        $request->validate(['q' => 'required|string|min:2|max:500']);

        $queryEmbedding = new Vector(
            $service->generate($request->string('q'))
        );

        $results = Product::query()
            ->whereNotNull('embedding')
            ->orderByRaw('embedding <=> ?', [$queryEmbedding])
            ->limit(20)
            ->get(['id', 'name', 'slug', 'price', 'category']);

        return response()->json([
            'query' => $request->q,
            'results' => $results,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The <=> operator is pgvector's cosine distance operator. Smaller values mean more similar. You can also use <#> (inner product) or <-> (L2 distance) depending on your use case.

Hybrid Search: The Best of Both Worlds

Pure semantic search isn't always better. A user searching for an exact SKU like "AIR-MAX-2024-BLK" needs keyword matching, not conceptual similarity. Hybrid search combines both:

$results = Product::query()
    ->whereNotNull('embedding')
    ->where(function ($query) use ($request) {
        $query->whereFullText(['name', 'description'], $request->q)
              ->orWhereRaw('1=1'); // Fallback to include semantic results
    })
    ->orderByRaw(
        '(embedding <=> ?) * 0.7 + (ts_rank(to_tsvector(\'english\', name), plainto_tsquery(\'english\', ?)) * -0.3)',
        [$queryEmbedding, $request->q]
    )
    ->limit(20)
    ->get();
Enter fullscreen mode Exit fullscreen mode

This is a simplified illustration — production hybrid ranking often uses Reciprocal Rank Fusion (RRF) to merge two separate ranked lists cleanly.

Bulk Indexing Existing Products

For a product catalogue that already exists, create an Artisan command:

// app/Console/Commands/IndexProductEmbeddings.php

use App\Jobs\GenerateProductEmbedding;
use App\Models\Product;

public function handle(): void
{
    $total = Product::whereNull('embedding')->count();
    $this->info("Indexing {$total} products...");

    Product::whereNull('embedding')
        ->chunkById(100, function ($products) {
            foreach ($products as $product) {
                GenerateProductEmbedding::dispatch($product)
                    ->onQueue('embeddings');
            }
        });

    $this->info('Jobs dispatched. Monitor with: php artisan queue:work --queue=embeddings');
}
Enter fullscreen mode Exit fullscreen mode

Run it with php artisan products:index-embeddings. Use a dedicated queue so embedding jobs don't compete with time-sensitive work like order processing.

A Note on Costs and Caching

text-embedding-3-small costs $0.02 per million tokens — practically free for most catalogues. That said, cache your query embeddings aggressively. If ten users search "waterproof jackets" in the same minute, generate the embedding once:

$cacheKey = 'embedding:' . md5($request->q);
$queryEmbedding = Cache::remember($cacheKey, now()->addHour(), fn () =>
    new Vector($service->generate($request->string('q')))
);
Enter fullscreen mode Exit fullscreen mode

This pattern matters at scale — and it's the kind of infrastructure detail that also feeds into how search-engine-discoverable your site is, since faster, more relevant responses reduce bounce rates. If you're thinking about the downstream SEO implications of better UX, the team at hanzweb.ae has written extensively on how technical performance choices compound into ranking signals.

Wrapping Up

Semantic search isn't magic — it's a well-understood pipeline: embed, store, query, rank. Laravel's ecosystem makes each of those steps clean and maintainable. The key architectural decisions to remember:

  • Always queue embedding generation — never block a request
  • Use HNSW indexes in pgvector for production-scale performance
  • Hybrid search beats pure semantic or pure keyword search for most real-world catalogues
  • Cache query embeddings to control costs and latency

Start with a small catalogue, validate that semantic results feel right for your domain, then scale. The jump from prototype to production is smaller than it looks.

Source: dev.to

arrow_back Back to Tutorials