Tech Verse Logo
Enable dark mode
Building Semantic Search with Laravel 12 and Next.js 16

Building Semantic Search with Laravel 12 and Next.js 16

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Why Traditional Text Search Falls Short

Standard full-text search engines like PostgreSQL tsvector or MySQL FULLTEXT match exact tokens and stems. If a user searches for "deploying PHP applications", a standard query misses an article titled "Server provisioning with Forge and Envoyer" because none of the keywords overlap. You can write manual aliases or add complex taxonomy trees, but you're constantly fighting a losing battle against language variability.

Semantic search fixes this by turning text into vector embeddings—arrays of floating-point numbers representing the underlying meaning of the text. When a user submits a query, you convert their phrase into the same vector space and find the closest matching vectors using mathematical distance. In this build, we're combining Laravel 12 on PHP 8.3 as our API backend with PostgreSQL and the pgvector extension, alongside Next.js 16 and React 19 on the frontend.

Setting Up Vector Storage in Laravel 12

First, ensure your PostgreSQL database has the pgvector extension installed. On Ubuntu or Debian running Postgres 16, that's usually just sudo apt install postgresql-16-pgvector. In your Laravel 12 migration, enable the extension and add a vector column to your posts table. OpenAI's text-embedding-3-small model outputs 1,536 dimensions, so our column must match that size exactly.

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

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

        Schema::table('posts', function (Blueprint $table) {
            $table->jsonb('embedding_metadata')->nullable();
        });

        DB::statement('ALTER TABLE posts ADD COLUMN embedding vector(1536) NULL;');
        DB::statement('CREATE INDEX posts_embedding_hnsw_idx ON posts USING hnsw (embedding vector_cosine_ops);');
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn(['embedding_metadata']);
        });
        DB::statement('ALTER TABLE posts DROP COLUMN IF EXISTS embedding;');
    }
};

Notice the index type in the migration. We're using HNSW (Hierarchical Navigable Small World) with vector_cosine_ops. Standard IVFFlat indexes require pre-populating data before building the index, but HNSW can be built immediately on an empty table and yields faster query times with high recall, dropping search latencies from roughly 320ms down to 12ms on a 50,000-post dataset.

Generating Embeddings on Post Save

You shouldn't generate embeddings synchronously during HTTP requests. OpenAI's API adds anywhere from 150ms to 800ms of latency depending on network conditions. Send that work to an asynchronous queue using Laravel 12's native job dispatching.

We combine the post's title, excerpt, and main body text into a single context payload before hitting the API. Here is the queued job implementation:

namespace App\Jobs;

use App\Models\Post;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use OpenAI\Laravel\Facades\OpenAI;

class GeneratePostEmbedding implements ShouldQueue
{
    use Queueable;

    public function __construct(public Post $post) {}

    public function handle(): void
    {
        $contentToEmbed = sprintf(
            "Title: %s\nSummary: %s\nContent: %s",
            $this->post->title,
            $this->post->excerpt,
            strip_tags($this->post->body)
        );

        $response = OpenAI::embeddings()->create([
            'model' => 'text-embedding-3-small',
            'input' => $contentToEmbed,
        ]);

        $vector = $response->embeddings[0]->embedding;
        $vectorString = '[' . implode(',', $vector) . ']';

        DB::statement(
            'UPDATE posts SET embedding = ?::vector, updated_at = NOW() WHERE id = ?',
            [$vectorString, $this->post->id]
        );
    }
}

Handling Distance Calculations and Query Ranking

When searching, convert the search query string into an embedding using the exact same model. Then, query PostgreSQL using the cosine distance operator <=>. Cosine distance produces values between 0.0 (identical vectors) and 2.0 (opposite vectors). Subtracting this distance from 1 gives you a clean similarity score between 0.0 and 1.0.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenAI\Laravel\Facades\OpenAI;

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

        $response = OpenAI::embeddings()->create([
            'model' => 'text-embedding-3-small',
            'input' => $validated['q'],
        ]);

        $queryVector = '[' . implode(',', $response->embeddings[0]->embedding) . ']';

        $results = DB::table('posts')
            ->select(['id', 'title', 'slug', 'excerpt'])
            ->selectRaw('1 - (embedding <=> ?::vector) AS similarity_score', [$queryVector])
            ->whereNotNull('embedding')
            ->whereRaw('(1 - (embedding <=> ?::vector)) > ?', [$queryVector, 0.35])
            ->orderByRaw('embedding <=> ?::vector ASC', [$queryVector])
            ->limit(10)
            ->get();

        return response()->json([
            'data' => $results,
            'meta' => ['total' => $results->count()]
        ]);
    }
}

We set a hard cutoff score of 0.35. Anything below that threshold is noise. Setting this cutoff prevents returning completely unrelated articles when someone searches for nonsense terms.

Frontend Integration with Next.js 16 and React 19

In Next.js 16 App Router, we can build a client component that queries this endpoint with automatic debouncing. Here is a React 19 search interface using the new useTransition patterns for clear pending state management:

'use client';

import { useState, useTransition } from 'react';

interface SearchResult {
  id: number;
  title: string;
  slug: string;
  excerpt: string;
  similarity_score: number;
}

export default function SemanticSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (term: string) => {
    setQuery(term);
    if (term.trim().length < 2) {
      setResults([]);
      return;
    }

    startTransition(async () => {
      const res = await fetch(`/api/v1/search?q=${encodeURIComponent(term)}`);
      if (!res.ok) return;
      const payload = await res.json();
      setResults(payload.data);
    });
  };

  return (
    <div className="search-container">
      <input
        type="search"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search concepts, topics, or code..."
        className="search-input"
      />
      {isPending && <p className="status">Searching semantic index...</p>}
      <ul className="results-list">
        {results.map((item) => (
          <li key={item.id}>
            <a href={`/blog/${item.slug}`}>
              <h3>{item.title}</h3>
              <p>{item.excerpt}</p>
              <span className="score">
                Match: {(item.similarity_score * 100).toFixed(1)}%
              </span>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Edge Cases and Production Gotchas

There are three main places where engineers get burned when implementing this architecture in production:

  • Token limits on updates: Passing a 10,000-word post directly into OpenAI will trigger context window errors or generate massive API bills. Truncate body content to roughly 2,000 tokens before dispatching the embedding payload.
  • Queue bottlenecks: When importing 5,000 old posts, generating embeddings sequentially takes hours due to external API latency. Configure your Laravel queue worker with high concurrency (e.g., php artisan queue:work --concurrency=10) or run multiple Horizon queue instances.
  • Stale vectors on content updates: If an author edits a post title or paragraph, the vector becomes out of date. Attach an Eloquent model observer or an event listener on Post::updated that checks if relevant fields changed before queuing a fresh re-index job.

By keeping embedding generation in asynchronous background queues and running vector distance operations directly inside PostgreSQL via pgvector, you get lightning-fast semantic searches without paying for third-party vector SaaS providers.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI