Laravel Vector Search: Semantic Search in Your App Without a PhD in Machine Learning

whereVectorSimilarTo(), embeddings, pgvector, cosine similarity, chunking strategies, and the difference between keyword search, full-text search, and semantic search — a plain-English guide to making your Laravel app understand what users mean, not just what they type.


Here’s what happens with every search feature built on LIKE '%query%' or even full-text search: a user types “how do I cancel” and gets zero results because the help article says “subscription termination process.” A user types “cheap” and misses the products tagged “affordable” and “budget-friendly.” A user searches for “error when uploading” and the relevant support ticket says “file size limit exceeded.”

These users gave up. Or they emailed support. Or they churned.

Semantic search fixes this. Not by better synonym matching, not by a bigger thesaurus — by understanding what words mean and finding content that means the same thing, even when it uses completely different words.

This post builds a real semantic search feature in Laravel: the theory in plain English, the pgvector setup, the embedding pipeline, and a search endpoint that actually understands your users.


The Three Levels of Search — A Plain English Comparison

Before implementing anything, understanding why semantic search is different:

Level 1: LIKE Search (Keyword Match)

SELECT * FROM products WHERE name LIKE '%blue shoes%'

Finds rows where the exact characters “blue shoes” appear in sequence. Fast. Fragile. “Running shoes, blue” won’t match. “Navy trainers” definitely won’t.

Level 2: Full-Text Search

SELECT * FROM products
WHERE MATCH(name, description) AGAINST ('blue shoes' IN NATURAL LANGUAGE MODE)

Better. Understands word boundaries, stemming (runningrun), stop words. “Shoes for running” might match “running shoes.” Still entirely keyword-based — if the words aren’t in the text, the result doesn’t appear.

Level 3: Semantic Search (Vector Search)

The query “comfortable shoes for long walks” finds products described as “ergonomic footwear with cushioned soles ideal for extended periods of standing” — even though these two phrases share no significant keywords.

How? Both phrases are converted to vectors (arrays of numbers) that represent their meaning. Similar meanings produce vectors that are close together in space. The search finds vectors closest to the query vector.

"comfortable shoes for long walks" → [0.2, -0.5, 0.8, 0.1, ...] (1,536 numbers)
"ergonomic footwear cushioned soles" → [0.19, -0.48, 0.79, 0.11, ...] (similar numbers)

cosine similarity between them: 0.94 (very similar)

"motorcycle engine parts" → [0.9, 0.2, -0.6, 0.4, ...] (very different numbers)
cosine similarity: 0.12 (not similar)

This is the core insight. Words become numbers. Similar meanings become close numbers. Search becomes distance measurement.


Setting Up pgvector

# Ubuntu/Debian
apt-get install postgresql-16-pgvector

# macOS
brew install pgvector

# Enable in your database
psql -d your_database -c "CREATE EXTENSION IF NOT EXISTS vector;"
# Laravel — ensure you're using PostgreSQL
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=your_database

The Migration: Adding Vector Columns

// database/migrations/add_search_vectors_to_products.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Ensure pgvector extension
        Schema::ensureVectorExtensionExists();

        Schema::table('products', function (Blueprint $table) {
            // 1,536 dimensions for OpenAI text-embedding-3-small
            // 3,072 for text-embedding-3-large (more accurate, slower, more expensive)
            $table->vector('search_vector', dimensions: 1536)
                  ->nullable()  // nullable while we backfill existing records
                  ->after('description');

            // HNSW index for approximate nearest neighbour search
            // Much faster than exact search on large datasets
            // Created automatically by ->index() in Laravel AI SDK
        });

        // Create HNSW index separately for more control
        DB::statement('
            CREATE INDEX products_search_vector_idx
            ON products
            USING hnsw (search_vector vector_cosine_ops)
        ');
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('search_vector');
        });
    }
};

Choosing Index Type

-- HNSW (Hierarchical Navigable Small World) — recommended for most cases
-- Faster queries, more memory, approximate results
CREATE INDEX USING hnsw (embedding vector_cosine_ops)

-- IVFFlat (Inverted File with Flat quantization)
-- Less memory, slightly slower, requires knowing data size upfront
CREATE INDEX USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)
-- Rule of thumb: lists ≈ sqrt(number of rows)

For most applications under 1 million rows, HNSW is the right choice.


Building the Embedding Pipeline

The Embedding Service

// app/Services/EmbeddingService.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\AI;
use Illuminate\Support\Facades\Cache;

class EmbeddingService
{
    // text-embedding-3-small: 1,536 dimensions, $0.02/1M tokens
    // text-embedding-3-large: 3,072 dimensions, $0.13/1M tokens
    // text-embedding-ada-002: 1,536 dimensions (legacy, more expensive)
    private const MODEL      = 'text-embedding-3-small';
    private const BATCH_SIZE = 100;  // OpenAI allows up to 2048 inputs per call

    /**
     * Generate an embedding for a single text.
     * Cached to avoid re-embedding identical content.
     */
    public function embed(string $text): array
    {
        $cacheKey = 'embedding:' . md5($text);

        return Cache::remember($cacheKey, now()->addDay(), function () use ($text) {
            $result = AI::embed($text, model: self::MODEL);
            return $result->embeddings[0]->embedding;
        });
    }

    /**
     * Generate embeddings for multiple texts in one API call.
     * Far more efficient than calling embed() in a loop.
     */
    public function embedBatch(array $texts): array
    {
        if (empty($texts)) return [];

        $allEmbeddings = [];

        foreach (array_chunk($texts, self::BATCH_SIZE) as $batch) {
            $result = AI::embed($batch, model: self::MODEL);

            foreach ($result->embeddings as $embedding) {
                $allEmbeddings[] = $embedding->embedding;
            }

            // Small delay between batches to respect rate limits
            if (count($texts) > self::BATCH_SIZE) {
                usleep(100_000);  // 100ms
            }
        }

        return $allEmbeddings;
    }

    /**
     * Build the searchable text from a model.
     * What you embed determines what the search can find.
     */
    public function buildProductText(array $product): string
    {
        // Concatenate all relevant fields
        // More context = better embeddings = better search results
        return implode(' | ', array_filter([
            $product['name'],
            $product['description'],
            $product['category'],
            $product['brand'],
            implode(', ', $product['tags'] ?? []),
            // Don't include: IDs, URLs, prices, technical fields
        ]));
    }
}

What You Embed Determines What You Find

The most important decision in a semantic search implementation:

// ✗ Only embedding the name — too little context
$text = $product->name;
// "Nike Air Max 270" → AI can't infer it's comfortable or for running

// ✗ Embedding everything including prices and IDs — noise
$text = $product->toJson();
// "{"id":42,"price":2999,"sku":"NK-AM270",...}" → numbers confuse embedding

// ✓ Embed human-readable, semantically rich content
$text = implode(' | ', [
    $product->name,             // "Nike Air Max 270"
    $product->description,      // "Lightweight running shoe with Air cushioning..."
    $product->category,         // "Running Shoes"
    $product->subcategory,      // "Road Running"
    $product->brand,            // "Nike"
    $product->tags->pluck('name')->join(', '), // "comfortable, cushioned, lightweight"
    $product->target_audience,  // "Long distance runners, casual wear"
]);

Populating Embeddings: The Backfill Job

For existing records, a background job to generate embeddings:

// app/Jobs/GenerateProductEmbeddings.php
<?php

namespace App\Jobs;

use App\Models\Product;
use App\Services\EmbeddingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;

class GenerateProductEmbeddings implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $timeout = 300;
    public int $tries   = 3;
    public int $backoff = 60;

    public function handle(EmbeddingService $embeddingService): void
    {
        // Process products without embeddings in batches
        Product::whereNull('search_vector')
            ->select(['id', 'name', 'description', 'category', 'brand'])
            ->chunkById(100, function ($products) use ($embeddingService) {

                // Build texts for this batch
                $texts = $products->map(fn($p) =>
                    $embeddingService->buildProductText($p->toArray())
                )->all();

                // Batch embed — one API call for up to 100 products
                $embeddings = $embeddingService->embedBatch($texts);

                // Bulk update with vectors
                $products->each(function ($product, $index) use ($embeddings) {
                    DB::table('products')
                        ->where('id', $product->id)
                        ->update([
                            'search_vector' => DB::raw(
                                'CAST(\'' . json_encode($embeddings[$index]) . '\' AS vector)'
                            ),
                        ]);
                });
            });
    }
}
# Dispatch the backfill
php artisan tinker
>>> GenerateProductEmbeddings::dispatch()

# Or run directly for small datasets
>>> app(App\Services\EmbeddingService::class)->backfillProducts()

Keeping Embeddings Fresh: Model Observer

// app/Observers/ProductObserver.php
<?php

namespace App\Observers;

use App\Jobs\UpdateProductEmbedding;
use App\Models\Product;

class ProductObserver
{
    // Fields that affect the embedding — if these change, re-embed
    private const EMBEDDING_FIELDS = [
        'name', 'description', 'category', 'brand', 'tags',
    ];

    public function saved(Product $product): void
    {
        $changed = array_intersect(
            array_keys($product->getDirty()),
            self::EMBEDDING_FIELDS
        );

        if (!empty($changed) || $product->wasRecentlyCreated) {
            UpdateProductEmbedding::dispatch($product)->onQueue('embeddings');
        }
    }
}
// app/Jobs/UpdateProductEmbedding.php
class UpdateProductEmbedding implements ShouldQueue
{
    public function __construct(private Product $product) {}

    public function handle(EmbeddingService $embeddingService): void
    {
        $text      = $embeddingService->buildProductText($this->product->toArray());
        $embedding = $embeddingService->embed($text);

        $this->product->updateQuietly([
            'search_vector' => DB::raw(
                'CAST(\'' . json_encode($embedding) . '\' AS vector)'
            ),
        ]);
    }
}
// app/Providers/AppServiceProvider.php
Product::observe(ProductObserver::class);

The Search: whereVectorSimilarTo()

// app/Services/ProductSearchService.php
<?php

namespace App\Services;

use App\Models\Product;
use Illuminate\Support\Collection;

class ProductSearchService
{
    private const SIMILARITY_THRESHOLD = 0.6;  // 0 = completely different, 1 = identical
    private const DEFAULT_LIMIT        = 20;

    public function __construct(
        private EmbeddingService $embeddingService
    ) {}

    /**
     * Semantic search — finds products by meaning, not keywords.
     */
    public function search(
        string $query,
        int    $limit    = self::DEFAULT_LIMIT,
        array  $filters  = [],
    ): Collection {
        // Convert query to embedding
        $queryEmbedding = $this->embeddingService->embed($query);

        return Product::query()
            ->select([
                'id', 'name', 'description', 'price',
                'category', 'brand', 'slug',
            ])
            ->whereNotNull('search_vector')

            // Apply any filters (category, price range, etc.)
            ->when($filters['category'] ?? null,
                fn($q, $cat) => $q->where('category', $cat)
            )
            ->when($filters['max_price'] ?? null,
                fn($q, $price) => $q->where('price', '<=', $price)
            )
            ->when($filters['in_stock'] ?? false,
                fn($q) => $q->where('stock', '>', 0)
            )

            // The vector similarity search
            ->whereVectorSimilarTo('search_vector', $queryEmbedding, limit: $limit * 2)

            // Filter by minimum similarity threshold
            ->filter(fn($product) => $product->similarity >= self::SIMILARITY_THRESHOLD)

            ->take($limit)
            ->values();
    }

    /**
     * Hybrid search — combine semantic and keyword for better precision.
     */
    public function hybridSearch(string $query, int $limit = self::DEFAULT_LIMIT): Collection
    {
        $queryEmbedding = $this->embeddingService->embed($query);

        // Get semantic results
        $semantic = Product::whereVectorSimilarTo('search_vector', $queryEmbedding, limit: $limit)
            ->select(['id', 'name', 'price', 'category', 'brand', 'slug'])
            ->get()
            ->keyBy('id');

        // Get full-text results
        $fullText = Product::whereFullText(['name', 'description'], $query)
            ->select(['id', 'name', 'price', 'category', 'brand', 'slug'])
            ->limit($limit)
            ->get()
            ->keyBy('id');

        // Merge using Reciprocal Rank Fusion (RRF)
        // RRF score = sum of 1/(k + rank) for each result set
        $k      = 60;  // RRF constant
        $scores = collect();

        $semantic->each(function ($product, $id) use ($scores, $k) {
            $rank = $scores->keys()->search($id);
            $rank = $rank === false ? $scores->count() + 1 : $rank + 1;
            $scores[$id] = ($scores[$id] ?? 0) + 1 / ($k + $rank);
        });

        $fullText->each(function ($product, $id) use ($scores, $k) {
            $rank = $scores->keys()->search($id);
            $rank = $rank === false ? $scores->count() + 1 : $rank + 1;
            $scores[$id] = ($scores[$id] ?? 0) + 1 / ($k + $rank);
        });

        // Sort by combined RRF score
        $sortedIds = $scores->sortDesc()->keys()->take($limit);

        // Merge the result collections in score order
        return $sortedIds->map(fn($id) =>
            $semantic[$id] ?? $fullText[$id]
        )->values();
    }
}

The Search Controller

// app/Http/Controllers/SearchController.php
<?php

namespace App\Http\Controllers;

use App\Http\Resources\ProductResource;
use App\Services\ProductSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    public function __construct(
        private ProductSearchService $searchService
    ) {}

    public function __invoke(Request $request): JsonResponse
    {
        $request->validate([
            'q'         => ['required', 'string', 'min:2', 'max:200'],
            'category'  => ['nullable', 'string'],
            'max_price' => ['nullable', 'numeric', 'min:0'],
            'in_stock'  => ['nullable', 'boolean'],
            'mode'      => ['nullable', 'in:semantic,keyword,hybrid'],
            'limit'     => ['nullable', 'integer', 'min:1', 'max:50'],
        ]);

        $query   = $request->input('q');
        $mode    = $request->input('mode', 'hybrid');
        $limit   = $request->integer('limit', 20);
        $filters = $request->only(['category', 'max_price', 'in_stock']);

        $results = match ($mode) {
            'semantic' => $this->searchService->search($query, $limit, $filters),
            'keyword'  => $this->keywordFallback($query, $limit, $filters),
            default    => $this->searchService->hybridSearch($query, $limit),
        };

        return response()->json([
            'query'   => $query,
            'mode'    => $mode,
            'count'   => $results->count(),
            'results' => ProductResource::collection($results),
        ]);
    }

    private function keywordFallback(string $query, int $limit, array $filters): \Illuminate\Support\Collection
    {
        return Product::where('name', 'ILIKE', "%{$query}%")
            ->orWhere('description', 'ILIKE', "%{$query}%")
            ->limit($limit)
            ->get();
    }
}
// routes/api.php
Route::get('/search', SearchController::class)->middleware('throttle:search');

// Rate limit search specifically
RateLimiter::for('search', fn(Request $request) =>
    Limit::perMinute(30)->by($request->user()?->id ?? $request->ip())
);

Understanding Cosine Similarity

The similarity score returned by pgvector is cosine similarity — the cosine of the angle between two vectors:

Score = 1.0  → identical meaning
Score = 0.9+ → very similar, almost certainly relevant
Score = 0.7+ → similar topic, usually relevant
Score = 0.5+ → related but different enough to matter
Score < 0.5  → probably not relevant

Setting the right threshold depends on your data:

// For product search (concrete terms, precise matching needed)
private const SIMILARITY_THRESHOLD = 0.70;

// For help article search (broader concepts, more lenient)
private const SIMILARITY_THRESHOLD = 0.60;

// For support ticket matching (highly varied language, very lenient)
private const SIMILARITY_THRESHOLD = 0.50;

Practical Applications Beyond Product Search

Help Article / Knowledge Base Search

// Any table with text content can have a search vector
Schema::table('help_articles', function (Blueprint $table) {
    $table->vector('search_vector', dimensions: 1536)->nullable();
});

// Build the text to embed
$text = implode(' ', [
    $article->title,
    $article->summary,
    $article->content,  // truncate if very long — ~8,000 tokens max
    $article->tags->pluck('name')->join(' '),
]);
// "How do I change my password" finds articles about "account security settings"
$articles = HelpArticle::whereVectorSimilarTo('search_vector', $embedding, limit: 5)
    ->where('published', true)
    ->get();

Support Ticket Routing

// Find similar past tickets to auto-categorise or auto-suggest resolution
$similarTickets = SupportTicket::whereVectorSimilarTo('search_vector', $embedding, limit: 5)
    ->where('status', 'resolved')
    ->select(['id', 'subject', 'resolution', 'category'])
    ->get();

// Suggest the category based on most common category in similar resolved tickets
$suggestedCategory = $similarTickets
    ->groupBy('category')
    ->sortByDesc(fn($group) => $group->count())
    ->keys()
    ->first();

“More Like This” Related Content

// Find products similar to one the user is viewing
$product = Product::find($id);

// Use the product's own vector to find similar ones
$similar = Product::whereVectorSimilarTo(
    'search_vector',
    $product->search_vector,  // the vector is already stored
    limit: 6
)
->where('id', '!=', $product->id)  // exclude the product itself
->where('active', true)
->get();

This is the “You might also like” feature, powered by semantic similarity rather than manually curated relationships.


Measuring and Improving Search Quality

The most important question: is semantic search actually better for your users?

A/B Testing Search Mode

use Laravel\Pennant\Feature;

// Define a feature flag for 50% rollout
Feature::define('semantic-search', fn(User $user) =>
    $user->id % 2 === 0  // 50% of users
);

// In the search controller
$mode = Feature::active('semantic-search') ? 'semantic' : 'keyword';

Tracking Search Metrics

// Log search events for analysis
Log::channel('search')->info('search', [
    'query'       => $query,
    'mode'        => $mode,
    'result_count'=> $results->count(),
    'user_id'     => auth()->id(),
    'session_id'  => session()->getId(),
]);

// Track click-through after search
// (record which result the user clicked)
Route::post('/search/click', function (Request $request) {
    SearchClick::create([
        'query'      => $request->input('query'),
        'product_id' => $request->input('product_id'),
        'position'   => $request->input('position'),
        'mode'       => $request->input('mode'),
    ]);
});

The metric that matters most: click-through rate by position. If semantic search shows the right result first, users click position 1. If they scroll to position 5, something is wrong. Compare CTR by position between keyword and semantic modes.


Performance Considerations

Query Performance at Scale

< 10,000 rows:     Exact search — no index needed, fast enough
10k - 500k rows:  HNSW index — approximate, very fast
> 500k rows:      HNSW with tuned ef_construction and m parameters
                  Or consider partitioning by category
-- Fine-tune HNSW for large datasets
CREATE INDEX products_vector_idx
ON products
USING hnsw (search_vector vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- m = max connections per node (higher = more accurate, more memory)
-- ef_construction = size of candidate list during build (higher = more accurate, slower build)

Caching Query Embeddings

// Common queries are embedded repeatedly — cache them
public function embed(string $text): array
{
    return Cache::remember(
        'embedding:' . md5(trim(strtolower($text))),
        now()->addHours(24),
        fn() => AI::embed($text)->embeddings[0]->embedding
    );
}

// At scale: pre-compute embeddings for your top 1,000 search queries
// and cache them permanently

The Complete Implementation Checklist

Setup:
✓ pgvector extension installed and enabled
✓ vector column added with correct dimensions (1,536 for text-embedding-3-small)
✓ HNSW index created on the vector column
✓ EmbeddingService configured with API key via AI::embed()

Data pipeline:
✓ buildProductText() (or equivalent) crafts semantically rich text
✓ Backfill job processes existing records in chunks
✓ Model observer triggers re-embedding when relevant fields change
✓ UpdateEmbedding job queued on 'embeddings' queue (separate from main)

Search:
✓ SIMILARITY_THRESHOLD set appropriately for your content type
✓ whereVectorSimilarTo() with limit parameter
✓ Filters applied before vector search for efficiency
✓ Hybrid search implemented (semantic + keyword via RRF)
✓ Keyword fallback for when no embeddings exist yet

Production:
✓ Query embeddings cached (24-hour TTL)
✓ Rate limiting on search endpoint
✓ Search events logged for quality measurement
✓ A/B testing between keyword and semantic modes
✓ Click-through tracking for quality comparison

Final Thoughts

Semantic search is the first search experience that stops feeling like a database query and starts feeling like the app actually understands you. “I need something comfortable for walking all day” finds the right shoes. “How do I stop getting billed” finds the cancellation article. “Error when I try to save” finds the relevant support resolution.

The implementation in 2026 is genuinely accessible. pgvector is a PostgreSQL extension that installs in two commands. AI::embed() is one line. whereVectorSimilarTo() is a query builder method. The hardest part is deciding what text to embed — and the answer is always “more semantic context, fewer technical identifiers.”

Start with product search or knowledge base search — high impact, clear quality signal. Measure click-through rates before and after. The improvement is typically obvious within the first week of A/B testing.

Semantic search used to require a team of ML engineers and a dedicated vector database. Now it requires a PostgreSQL extension and an afternoon.

Leave a Reply

Your email address will not be published. Required fields are marked *