Semantic Search in Laravel 13 With MariaDB Vector: Column, Index and Query Without an External Service
Since Laravel 13.28, meaning-based search fits in the database you already run: a vector column, an index and a query. The path is short, and it breaks in three very specific places: the driver, the dimensions and the index metric.
What MariaDB Vector Changes and What It Does Not
One More Column in the Database You Already Have
An embedding is a text translated into a direction in a space with many dimensions. Searching by meaning means asking which documents sit near the direction of this question. Until recently that meant standing up a separate engine or using PostgreSQL with pgvector. Since Laravel 13.28, with MariaDB 11.7 or newer, the column, the index and the query are part of the same engine: no second service and no extension to install.
What Semantic Similarity Solves, and What It Does Not
It solves conceptual search: someone types "where to eat well in Napa" and your document about vineyards shows up even though it shares no words with the query. It does not solve exact matching. If the user searches "invoice 2026-08" or a tracking number, the vector cannot tell that code from a similar one, and there full-text still wins. Knowing this before you start keeps you from replacing a search that worked.
Requirements Before You Write a Line
MariaDB 11.7 or Newer: 11.8 LTS and 12.3 LTS for Production
The VECTOR type arrived in MariaDB 11.7 and is stable in 11.8 LTS, which is the version that makes sense in production (12.3 LTS is the other long-term option). Plenty of cPanel boxes and LTS distros still sit on the 10.x branch, and going from 10.x to 11.8 is a major jump: logical backup with mariadb-dump, a maintenance window and testing in a separate environment before you touch anything.
Read also
Laravel 13.28 and the mariadb Driver, Not mysql
MariaDB grammar for vectors landed in 13.27 and the vec_fromtext() fix plus the AsVector cast followed in 13.28. In your .env the driver has to be DB_CONNECTION=mariadb. This is the first trap and the most common one: MariaDB speaks the MySQL protocol, so with mysql everything seems to work until the first vector query arrives.
Checking Versions on the Server and in CI
Run the two commands below. If the first one does not return 11.7 or higher, stop. And check the pipeline too: if your tests run against a CI MySQL, the suite goes green and the deploy fails in production over an engine difference nobody looked at.
mariadb -e 'SELECT VERSION();'
php artisan --versionThe Migration: Vector Column and Index
Dimensions Are a Contract With the Embedding Model
The column stores a fixed number of dimensions and that number is decided by the model that produces the vectors: 1536 if you use a 1536-dimension model, 3072 if you use another. It is not a performance setting you can tune later. If you change models and the dimension changes, you need a new column or a migration plus re-embedding every row. That is why you should also store which model produced each vector.
Read also
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->vector('embedding', dimensions: 1536)->index();
$table->string('embedding_model')->nullable();
$table->timestamps();
});The SQL Laravel Emits Underneath
An ->index() on a vector column does not create a normal B-tree index: it emits the engine's own syntax. In MariaDB the result looks like this, and you should confirm it on your exact version with a local migration:
alter table documents
add vector index documents_embedding_index (embedding)
M=6 DISTANCE=cosine;The M parameter balances precision, index size and search speed, and DISTANCE accepts euclidean or cosine. Laravel emits cosine because its queries compare by cosine similarity. If you ever change the index metric you have to rebuild the index: it does not recalculate itself.
The pgvector Helper You Must Not Call Here
Many vector search examples start with Schema::ensureVectorExtensionExists(). On MariaDB that line throws an exception reading "Extensions are only supported by Postgres". It is a pgvector helper and it is not used here: in MariaDB the VECTOR type is already in the server, there is no extension to enable.
The Cast and the Similarity Query
Why the AsVector Cast Is Required
In MariaDB the column comes back as packed float32 bytes, so an array or JSON cast cannot work. Send JSON to a VECTOR column and the server answers with 1292 Incorrect vector value. The AsVector cast handles both directions: it reads the binary on the way out and writes with vec_fromtext() on the way in.
use Illuminate\Database\Eloquent\Casts\AsVector;
protected function casts(): array
{
return [
'embedding' => AsVector::class,
];
}whereVectorSimilarTo, minSimilarity and the Rest of the Methods
With the cast in place the query is one line. The first argument is the column and the second is the search text; if the AI SDK is installed, the text is turned into an embedding along the way.
$results = Document::query()
->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley')
->limit(10)
->get();
// With a minimum similarity threshold
$results = Document::query()
->whereVectorSimilarTo('embedding', $text, minSimilarity: 0.7)
->get();Three methods from the same group show up as soon as you adjust ordering or filtering: whereVectorDistanceLessThan to filter by maximum distance, orderByVectorDistance to sort without filtering and selectVectorDistance to return the distance as a column you can display or debug.
Where the Embeddings Come From
The vector is produced by an external model, and that brings two costs: the API bill and the time. For the initial backfill, a queued job with chunkById and an explicit rate limit is the difference between finishing overnight and getting your account throttled. If you already built agents with the PHP AI SDK, the embeddings piece is the same one covered in the agents and sub-agents SDK guide.
Filling the Table Without Taking Down the Database or the Quota
A Queued Job, in Batches, With a Rate Limit
Never inside a migration or a tinker session. One job per batch, with chunkById so the table never loads into memory, and a pause between provider calls. The process has to be interruptible and resumable without leaving half-written rows.
Idempotency: Text Hash and Model Version
Storing a hash of the source text and the model name next to the vector gives you two things: re-embedding only what changed, and spotting rows with vectors from an older model. Mixing embeddings from different models in the same column makes distances meaningless, and that error raises no warning at all: it just returns bad results.
How Much a Vector Weighs and What the Index Costs
Float32 is 4 bytes per dimension. A 1536-dimension row takes 6,144 bytes, about 6 KB, in the column alone. A million rows is roughly 6 GB of vectors, plus the index structure. Doing that arithmetic before you start saves you from discovering the table does not fit on the disk you have.
Hybrid Search: Text and Vectors in One Query
MATCH AGAINST With Cosine Distance
MariaDB lets you combine both signals in a single statement: textual relevance on one side and vector proximity on the other, with a weight for each. The pattern is to score with both and sort by the weighted sum, tuning the weights to what your user actually searches for.
select id, title,
match(title, body) against (? in natural language mode) as text_score,
vec_distance_cosine(embedding, vec_fromtext(?)) as distance
from documents
where match(title, body) against (? in natural language mode)
order by (0.4 * text_score) + (0.6 * (1 - distance)) desc
limit 20;When Full-Text Is Enough
For proper nouns, codes, exact references and document numbers, full-text wins and is cheaper too. If that is nearly all your cases, a dedicated engine is still a better idea than adding vectors, and that path is covered in the Scout and Meilisearch guide.
The Three Traps That Cost an Afternoon
The mysql Driver That Half Works
With DB_CONNECTION=mysql normal queries pass because MariaDB speaks the MySQL protocol, but vector queries and vector indexes throw. It mostly happens in projects that migrated from MySQL to MariaDB and never changed that .env line.
The Index Metric That Gets Silently Ignored
MariaDB only uses the vector index when the distance function in the query matches the one in the index. If it does not match, it says nothing: it scans the whole table and answers correctly, just slowly. It is the most expensive bug to diagnose because the results are right.
Error 4079 and Why You Need 13.28
SQLSTATE[HY000]: General error: 4079 Illegal parameter data type varchar for operation 'VEC_DISTANCE_COSINE'. VEC_DISTANCE functions only accept VECTOR and the query builder was sending the embedding as JSON text. The fix emits vec_distance_cosine(col, vec_fromtext(?)) and adds the cast. If you see that error, your version predates 13.28.
| Symptom | Cause | Fix |
|---|---|---|
| Everything works until the first vector query | DB_CONNECTION=mysql | Switch to mariadb |
| "Extensions are only supported by Postgres" | ensureVectorExtensionExists was called | Remove that line |
| Error 4079 with VEC_DISTANCE_COSINE | Version older than 13.28 | Upgrade Laravel |
| Correct but slow answers | Index metric differs from the query metric | Rebuild the index with the right metric |
| 1292 Incorrect vector value | JSON sent without the cast | Use AsVector |
Checking That the Index Is Actually Used
EXPLAIN and the Full Scan in Disguise
Having the index created does not guarantee the planner uses it. Run EXPLAIN on the real query and look for the vector index in the plan. There are reported cases of full scans even with the index present, both on small tables and on tables with hundreds of thousands of rows, and on small tables the planner tends to prefer scanning everything because it is cheaper. Test with realistic volume.
What to Measure After Deploying
Query latency with percentiles, not a single run. And cost per search, which adds the embedding model call to the database query. A model change can improve first-result quality and multiply cost, so measure both at the same time.
Checklist and Next Steps
Ten boxes to tick before calling this done:
- MariaDB 11.7 or newer confirmed in production with SELECT VERSION().
- Laravel 13.28 or newer.
- DB_CONNECTION=mariadb, in CI as well.
- Embedding model chosen before loading data.
- Vector column with the model's exact dimensions.
- AsVector cast on the model.
- Vector index with the metric the query uses.
- Backfill in batches, idempotent and rate limited.
- EXPLAIN reviewed with realistic volume.
- Latency and cost per search measured after deploying.
The database already knows how to search by meaning; what is left is judgement work: pick the model, measure, and decide when vectors are not worth it. If your database is PostgreSQL, the equivalent is pgvector and that path is in the MySQL to PostgreSQL migration guide. And if the real problem is that a query takes four seconds, look at the plan before adding vectors, as explained in how to find and fix a slow query in MySQL 8.4.



