A user types "cheap places to stay near the beach" into your help center search, and your LIKE '%beach%' query returns an article about beach volleyball rules. Meanwhile, the article titled "Budget Coastal Rentals" sits unfound because none of its words match. Full-text search helps with stemming, but it still matches words, not meaning.
The usual fix has been to bolt on a dedicated vector database or a hosted search service, then write a sync pipeline to keep it in step with your models. Laravel 13 removes most of that work. Vector columns, an Eloquent cast, and a whereVectorSimilarTo query builder method now ship in the framework, so semantic search can live in the database you already run.
What you need before you start #
Vector search in Laravel 13 depends on two things. First, a database that can store and compare vectors: PostgreSQL with the pgvector extension or MariaDB 11.7 and later (MongoDB works through the Laravel MongoDB package). Second, the first-party Laravel AI SDK, which handles calling an embedding provider and turning text into arrays of numbers.
An embedding is a long array of floats, often 1,536 of them, that encodes what a piece of text means. Texts with similar meanings land close together in that space, so "budget coastal rentals" and "cheap places near the beach" end up neighbors even with zero shared words.
The migration side is short:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::ensureVectorExtensionExists();
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained();
$table->string('title');
$table->text('body');
$table->vector('embedding', dimensions: 1536)->nullable()->index();
$table->timestamps();
});
ensureVectorExtensionExists() enables pgvector if it is not already on, and calling index() on a vector column creates an HNSW index using cosine distance. The dimensions value has to match your embedding model exactly, so 1536 for OpenAI's text-embedding-3-small. Swap models later and you are re-embedding every row, so pick deliberately.
On the model, the AsVector cast converts between PHP arrays and the database vector format:
use Illuminate\Database\Eloquent\Casts\AsVector;
protected function casts(): array
{
return [
'embedding' => AsVector::class,
];
}
Generating and storing embeddings #
Embedding generation is a network call to a paid API, so it does not belong in a request cycle or a model saving hook. Push it to a queued job, and batch where you can. The AI SDK's Embeddings class sends many inputs in one API call:
use App\Models\Article;
use Laravel\Ai\Embeddings;
Article::whereNull('embedding')
->chunkById(100, function ($articles) {
$response = Embeddings::for(
$articles->map(fn ($a) => $a->title . "\n\n" . $a->body)->all()
)->generate();
foreach ($articles->values() as $i => $article) {
$article->update(['embedding' => $response->embeddings[$i]]);
}
});
Embedding the title together with the body gives the vector more signal for short articles. For long documents, you will get better matches by splitting the content into chunks with their own rows and embeddings, because one vector has to summarize everything it covers.
Querying by meaning #
With embeddings stored, the search itself is one method:
$results = Article::query()
->where('team_id', $user->team_id)
->whereVectorSimilarTo('embedding', $request->input('q'), minSimilarity: 0.4)
->limit(10)
->get();
whereVectorSimilarTo compares stored vectors using cosine similarity, drops anything below minSimilarity (a value from 0.0 to 1.0), and orders results with the closest match first. When you pass a plain string, Laravel embeds it for you with your configured provider, so the user's query goes straight in.
That convenience has a cost: every search is now an embedding API call, with the latency and per-token billing that comes with it. Turn on embedding caching in config/ai.php (caching.embeddings.cache), which keys results on provider, model, dimensions, and input and keeps them for 30 days. Popular queries then skip the network entirely.
If you need the raw numbers, the lower-level methods expose them:
$results = Article::query()
->select('*')
->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')
->whereVectorDistanceLessThan('embedding', $queryEmbedding, maxDistance: 0.3)
->orderByVectorDistance('embedding', $queryEmbedding)
->limit(10)
->get();
Selecting the distance is handy while you tune thresholds. Log a few real queries with their distances and you will quickly see where relevant results stop and noise starts. A threshold copied from a blog post (including this one) is a starting guess, not a setting.
When vector search is the wrong tool #
Semantic search is great at intent and bad at exactness. A user searching for an order number, a SKU, or an error code wants an exact match, and embeddings will happily return "similar-looking" codes. Keep whereFullText or a plain where for those cases, and consider routing queries that look like identifiers away from the vector path.
Also watch the combination of filters and approximate indexes. An HNSW index finds nearest neighbors first and your where clauses filter after, so a narrow filter like a single small team can return fewer than ten results. Newer pgvector releases add iterative index scans to help, but test with realistic data.
Laravel's search documentation also covers a hybrid pattern worth knowing: retrieve 50 candidates with fast full-text search, then reorder them with the AI SDK's rerank collection macro. For many apps that beats pure vector search on both cost and relevance. Start with the version that needs the least infrastructure, measure it against real queries, and only add moving parts when the logs say you need them.