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::updatedthat 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.









