---
title: "Laravel Vector Search Postmortem | whereVectorSimilarTo"
description: "How Laravel 13 vector search really works, from whereVectorSimilarTo down to the pgvector SQL, plus the HNSW filtering bug that silently thins your results."
keywords:
 - laravel vector search
 - whereVectorSimilarTo
 - laravel 13 semantic search
canonical_url: "/laravel-vector-search-postmortem/"
date_published: "2026-09-24"
date_modified: "2026-09-24"
type: post
author: Rohan Jalil
---

# Laravel Vector Search Postmortem


Laravel 13 gives you semantic search straight from the query builder. You call `whereVectorSimilarTo` on an Eloquent model, hand it a plain English string, and get back rows ranked by meaning instead of keyword overlap. A search for “best wineries in Napa Valley” surfaces an article titled “Top Vineyards to Visit” even though the two share no words at all, an example the [Laravel search documentation](https://laravel.com/docs/13.x/search#semantic-vector-search) uses itself.


I have been running this in production since [Laravel 13](https://laravel.com/docs/13.x/releases) landed on 17 March 2026, and the API is genuinely pleasant. It is also hiding a lot of machinery. This post takes the same approach I took with [Laravel macros](/laravel-macros-postmortem/), which is to walk the call all the way down to the SQL and show you where it can bite you.


---


## What Laravel 13 Actually Shipped


Four query builder methods arrived together.


- `whereVectorSimilarTo` filters by cosine similarity and orders results for you
- `whereVectorDistanceLessThan` filters by raw distance
- `orderByVectorDistance` sorts by proximity without filtering
- `selectVectorDistance` pulls the computed distance back as a column


Alongside those, Laravel added the `Illuminate\Database\Eloquent\Casts\AsVector` cast, the `Schema::ensureVectorExtensionExists()` helper, a `vector` column type on the schema builder, and a `toEmbeddings` method on [Stringable](https://laravel.com/api/master/Illuminate/Support/Stringable.html).


Before you write a line of this, check three requirements. Laravel 13 needs PHP 8.3 as a minimum. Vector search needs the [Laravel AI SDK](https://laravel.com/docs/13.x/ai-sdk), which you install separately with `composer require laravel/ai`. And your database has to be PostgreSQL with the `pgvector` extension, MariaDB 11.7 or later, or MongoDB through the Laravel MongoDB package. Plain MySQL will throw, and I will get to why.


---


## How Do You Store a Vector?


An embedding is an array of floats that encodes the meaning of a piece of text. OpenAI’s `text-embedding-3-small` returns 1,536 of them. Your column has to declare that dimension up front, and it has to match whatever your provider returns.


```
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::ensureVectorExtensionExists();

Schema::create('documents', function (Blueprint $table) {
 $table->id();
 $table->string('title');
 $table->text('content');
 $table->vector('embedding', dimensions: 1536)->index();
 $table->timestamps();
});
```


Two things in that migration deserve attention.


`Schema::ensureVectorExtensionExists()` runs `CREATE EXTENSION IF NOT EXISTS vector` against PostgreSQL before the table exists. Skip it on a fresh database and the `vector` column type will fail because Postgres has no idea what a vector is. If you deploy to [Laravel Cloud](https://laravel.com/cloud), every Postgres instance already ships with `pgvector` installed, so the call is a no-op there.


Calling `index()` on a vector column does not create a normal B-tree. Laravel creates an HNSW index using cosine distance. HNSW stands for Hierarchical Navigable Small World, and it is an approximate nearest neighbour structure. Remember the word approximate, because the filtered-search section further down is entirely about what it costs you.


On the model, cast the column so Laravel converts between PHP arrays and the database vector format.


```
use Illuminate\Database\Eloquent\Casts\AsVector;
use Illuminate\Database\Eloquent\Model;

class Document extends Model
{
 protected function casts(): array
 {
 return [
 'embedding' => AsVector::class,
 ];
 }
}
```


Older tutorials tell you to use the plain `'array'` cast here. That worked on PostgreSQL and it silently breaks on MariaDB, which returns the column as little endian float32 bytes rather than JSON. The Laravel team added `AsVector` in [pull request 61337](https://github.com/laravel/framework/pull/61337) for exactly this reason. Use `AsVector` and you stop caring which driver you are on.


---


## How Do You Generate the Embeddings?


For a single string, Laravel bolts the call onto Stringable.


```
use Illuminate\Support\Str;

$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
```


That reads nicely and it costs you one HTTP round trip to your embedding provider. Put it inside a `foreach` over a thousand records and you have written an N+1 problem with a credit card attached to it. Batch instead.


```
use Laravel\Ai\Embeddings;

$response = Embeddings::for([
 'Napa Valley has great wine.',
 'Laravel is a PHP framework.',
])->generate();

$response->embeddings; // [, ]
```


One API call covers the whole array. I batch in chunks of 100 and push the work onto a [queued job](https://laravel.com/docs/13.x/queues), which keeps request latency out of the picture and gives me retries for free when a provider rate limits us.


The AI SDK can also cache embeddings for you. Flip `ai.caching.embeddings.cache` to `true` in `config/ai.php` and identical inputs stop hitting the provider for 30 days. The cache key combines the provider, the model, the dimensions, and the input content, so changing any of those produces a fresh request rather than a stale hit. On a knowledge base where the same support article gets re-indexed on every content save, this cut our embedding spend noticeably.


---


## How Does the Magic Stuff Work?


Here is the query most people write.


```
$documents = Document::query()
 ->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley', minSimilarity: 0.4)
 ->limit(10)
 ->get();
```


You passed a string. The column holds 1,536 floats. Something has to bridge that gap, and the bridge sits inside [Illuminate\Database\Query\Builder](https://laravel.com/api/master/Illuminate/Database/Query/Builder.html). Follow the call.


- The builder inspects the second argument. When it receives an array it treats those floats as the query vector. When it receives a string, Laravel routes the string through your configured embedding provider first and uses the result. That convenience hides a network call inside your query chain, which matters more than it sounds like it does.
- The builder then asks whether this connection can compute vector distances at all, through `ensureConnectionSupportsVectors()`.
- If the driver supports it, the builder asks the grammar to compile a distance expression for the column.
- The grammar returns driver specific SQL. On PostgreSQL that is `pgvector`‘s cosine distance operator, written `<=>`. On MariaDB it is `vec_distance_cosine(embedding, vec_fromtext(?))`.
- The `minSimilarity` value gets converted into a distance threshold, because cosine distance and cosine similarity are inverses of each other. A `minSimilarity` of 0.4 becomes a distance ceiling of 0.6.
- The builder adds an `order by` on that same distance expression, ascending, so the closest rows come back first. You do not write the ordering yourself and you should not add your own.


That grammar layer is newer than the feature. When Laravel 13 first shipped, `Query\Builder::ensureConnectionSupportsVectors()` hard coded an `instanceof PostgresConnection` check, and the `<=>` operator was inlined directly in the builder. [Pull request 61250](https://github.com/laravel/framework/pull/61250), merged on 20 August 2026, moved both concerns into the grammar through a pair of methods, `supportsVectorDistance()` and `compileVectorDistanceExpression()`. [PostgresGrammar](https://laravel.com/api/master/Illuminate/Database/Query/Grammars/PostgresGrammar.html) kept its existing SQL and MariaDbGrammar implemented its own.


The pattern mirrors how Laravel already handles `compileRandom()` and `supportsSavepoints()`, which is a base implementation on the grammar that each driver overrides. If you ever wondered where to hook in a driver Laravel does not support yet, that is the seam.


Notice how much of this API leans on [named arguments](https://www.php.net/manual/en/functions.arguments.php#functions.named-arguments). `dimensions:`, `minSimilarity:`, `maxDistance:`, `as:`. Laravel explicitly excludes named arguments from its backwards compatibility promise, so a parameter rename in a future minor release will break your code without violating semver. I keep these calls wrapped in a repository class for that reason.


---


## Why Does Plain MySQL Throw?


Call `whereVectorSimilarTo` on a plain MySQL connection and Laravel raises a [RuntimeException](https://www.php.net/manual/en/class.runtimeexception.php). This is deliberate and the reasoning is worth knowing.


MariaDB 11.7 and later ship a native `VECTOR` column type and native distance functions such as `VEC_DISTANCE_COSINE`. Standard MySQL 9 has a `VECTOR` column type but no distance functions. The `DISTANCE()` and `VECTOR_DISTANCE()` functions people occasionally point at live in MySQL HeatWave on Oracle Cloud, not in the MySQL binaries you install from a package manager.


A PHP side fallback did get considered during the MariaDB work and the maintainers left it out on purpose. Fetching every row and computing similarity in memory would quietly defeat `limit()` and pagination, and it would change the performance profile of the query without telling you. I agree with that call. An exception you hit in development beats a query that works fine on 500 rows and melts on 500,000.


The practical consequence for anyone planning a [RAG build](/services/rag-development/) is simple. Pick PostgreSQL. Almost every project we take on that wants semantic search later is already on MySQL, and the migration is the awkward conversation, not the embeddings.


---


## What if I Want Lower Level Control?


`whereVectorSimilarTo` filters and orders in one shot, which is convenient until you want to show users a relevance score or apply your own ranking. The other three methods let you drive manually.


```
use App\Models\Document;

$queryEmbedding = Str::of($request->input('q'))->toEmbeddings();

$documents = Document::query()
 ->whereVectorDistanceLessThan('embedding', $queryEmbedding, maxDistance: 0.5)
 ->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')
 ->orderByVectorDistance('embedding', $queryEmbedding)
 ->limit(10)
 ->get();

$documents->first()->distance; // 0.1832...
```


Two gotchas here. First, generate the embedding once and pass the array to all three methods. Pass a string three times and you pay for three embedding calls on a single query. Second, distance runs the opposite direction from similarity. A distance of 0 means identical and higher numbers mean less related, which is the reverse of the 0 to 1 scale `minSimilarity` uses. I have watched more than one developer set `maxDistance: 0.9` expecting a tight filter and get the entire table back.


---


## Why Does My Filtered Search Return Fewer Rows Than I Asked For?


**Short answer:** pgvector’s HNSW index applies your `where` clause after the approximate scan, not during it. It collects `hnsw.ef_search` candidates (40 by default) across the whole table, then the filter discards the ones that belong to other tenants, so a tenant that owns 1% of rows gets back a fraction of the `limit` with no error and no slow query.


This is the one that costs real money, and the Laravel documentation recommends the exact pattern that triggers it. Here is the shape, taken from the official docs.


```
$documents = Document::query()
 ->where('team_id', $user->team_id)
 ->whereVectorSimilarTo('embedding', $request->input('query'))
 ->limit(10)
 ->get();
```


Scoping semantic search to a tenant is obviously correct application design. It also collides with how HNSW works.


An HNSW index walks a graph and keeps a candidate list in flight. In `pgvector` the size of that list is `hnsw.ef_search` and it [defaults to 40](https://docs.pgedge.com/pgvector/v0-8-1/filtering/). Your `where` clause gets applied after the index scan finishes, not during it. So the index collects 40 nearest neighbours across the whole table, then Postgres throws away everything belonging to another tenant, and whatever survives is your result set.


The pgvector documentation puts the arithmetic plainly. If a condition matches 10% of rows, with the default `ef_search` of 40, roughly 4 rows survive on average. If your tenant owns 0.1% of the table, you asked for 10 chunks and you get one, or zero.


There is no error. There is no slow query in your logs. Your RAG pipeline just feeds the model thinner context than it should and the answers get vaguer. On a multi tenant knowledge base this is the worst class of bug we deal with, because the system looks healthy from every angle you normally check.


Three fixes, and I reach for them in this order.


Turn on iterative scans first. `pgvector` 0.8.0 added `hnsw.iterative_scan`, which keeps walking the graph until enough rows survive the filter instead of stopping at the first batch. Set it per transaction rather than globally so a background reindex and a user facing search can use different values.


```
DB::transaction(function () use ($user, $query) {
 DB::statement("SET LOCAL hnsw.iterative_scan = 'relaxed_order'");
 DB::statement('SET LOCAL hnsw.ef_search = 100');

 return Document::query()
 ->where('team_id', $user->team_id)
 ->whereVectorSimilarTo('embedding', $query)
 ->limit(10)
 ->get();
});
```


Use a partial index second, when the filter covers a small fixed set of values. A partial index holds only the filtered rows, builds faster, and gives you effectively full recall inside that subset. Laravel’s schema builder will not generate this for you, so drop to raw SQL in the migration.


Raise `ef_search` third, as the blunt instrument. If your filter keeps roughly 1 row in 50, you need something like 50 times the candidate list to see the same number of survivors, and latency climbs with it.


Whichever you pick, measure recall before you ship. Run the same queries with the index disabled through `SET LOCAL enable_indexscan = off`, compare the returned ID sets against the approximate run, and you will know what your index is actually costing you. Under a few hundred thousand vectors an exact scan takes milliseconds anyway, so you can afford the comparison. [Crunchy Data’s write up on hybrid vector search](https://www.crunchydata.com/blog/hybrid-vector-search) goes deeper on reading the query plans if you want the DBA level detail.


---


## What Else Breaks in Production?


A few things I wish someone had told me before our first deploy.


Changing your embedding model invalidates every vector you have stored. Embeddings from `text-embedding-3-small` and embeddings from a Cohere model do not live in the same vector space, and comparing them produces numbers that look plausible and mean nothing. Switching models means a full re-embed of the corpus, so budget for it as a migration rather than a config change.


Dimension mismatches fail loudly, which is the one mercy here. Declare `dimensions: 1536` and feed the column a 3,072 float vector and Postgres rejects the insert. Pin the model name in config rather than relying on a provider default that can shift under you.


Rows with a null embedding never appear in results and never raise an error. Any record created before you added the embedding pipeline, or any record whose embedding job failed, is invisible to search forever. I keep a scheduled command that counts rows where `embedding is null` and alerts when the number moves.


Pure vector search is bad at exact identifiers. An embedding encodes meaning, so a literal token like an SKU or an error code holds no special position in the vector space. Postgres full text search matches it exactly. When users search for both concepts and identifiers, you need both retrieval methods.


---


## Should I Rerank?


Often, yes. Reranking uses a model to reorder an existing result set by relevance to the query, and unlike vector search it needs no stored embeddings at all. Laravel exposes it as a collection macro.


```
$articles = Article::query()
 ->whereFullText('body', $request->input('query'))
 ->limit(50)
 ->get()
 ->rerank('body', $request->input('query'), limit: 10);
```


Retrieve fast, then rerank narrow. Full text search cuts a large table down to 50 candidates in milliseconds using an index you already have, and the reranker sorts those 50 properly. You get database speed on the wide pass and model quality on the narrow one, and you only pay the model for 50 documents instead of your whole corpus.


For the systems we build, the strongest retrieval usually combines all three. Vector search catches paraphrases, full text catches identifiers and rare tokens, and reranking settles the final order.


---


## Where Should Vector Search Live in Your App?


Keep embedding generation out of your controllers. I put it in one of two places depending on how fresh the index needs to be.


An [Eloquent observer](https://laravel.com/docs/13.x/eloquent#observers) on `saved` dispatches a queued job that regenerates the embedding. This keeps the index current without blocking the request, and the queue handles provider failures with retries.


A scheduled Artisan command backfills in batches for anything the observer missed and for the initial import. Chunk it, batch the embedding calls, and log what it touched.


Wrap the query side in a dedicated search service rather than calling `whereVectorSimilarTo` from controllers. Those named arguments are outside Laravel’s backwards compatibility guarantee, your `minSimilarity` threshold will need tuning against real user queries, and the session variables for `ef_search` belong in one place. When we hand a codebase over, that one class is where the whole retrieval strategy lives.


---


Laravel 13 took something that used to need a separate vector database, an embedding service, and three community packages, and reduced it to a migration and a query builder method. That is a real simplification. It also moved the hard parts from infrastructure into query tuning, and the failure mode changed from a service being down to results being quietly incomplete.


We build [retrieval systems and knowledge bases](/services/rag-development/) on this stack, and we do [Laravel development](/web-development/laravel/) for teams who would rather not learn HNSW recall tuning the expensive way. If you are planning semantic search into an existing application, [talk to us](/contact-us/) before you pick the database.


---


## Frequently Asked Questions


### Does Laravel vector search work with MySQL?


No. Laravel 13 vector search supports PostgreSQL with the `pgvector` extension, MariaDB 11.7 or later, and MongoDB through the Laravel MongoDB package. Calling `whereVectorSimilarTo` on a plain MySQL connection throws a `RuntimeException`, because standard MySQL has no native vector distance function.


### What does whereVectorSimilarTo do in Laravel 13?


It compares a stored embedding column against a query embedding using cosine similarity, filters out results below the `minSimilarity` threshold, and orders results by relevance automatically. If you pass a plain string instead of an array, Laravel generates the embedding for you using your configured provider.


### Why does my Laravel vector search return fewer rows than the limit?


HNSW indexes apply `where` clauses after the index scan, not during it. `pgvector` collects `hnsw.ef_search` candidates, 40 by default, then your filter discards most of them. Enable `hnsw.iterative_scan`, use a partial index, or raise `ef_search`.


### Do I need the Laravel AI SDK for vector search?


Yes. Vector search requires the Laravel AI SDK, installed with `composer require laravel/ai`. The SDK generates the embeddings that `whereVectorSimilarTo` compares against.