Laravel 13 puede buscar por significado en MariaDB con whereVectorSimilarTo(): una búsqueda de "pagos con tarjeta" encuentra los artículos de Cashier y Stripe aunque sus títulos no lleven ninguna de esas palabras. Hasta agosto, el query builder de Laravel solo lo admitía en PostgreSQL con pgvector. Desde Laravel 13.29, del 25 de agosto de 2026, funciona también con MariaDB 11.7 o superior, junto al cast AsVector (PR #61337).
Un embedding es un vector, es decir, una lista de números que representa el significado de un texto. Si 2 textos hablan de lo mismo, sus vectores se parecen aunque no compartan palabras. La idea es guardar el vector de cada fila y, al buscar, pedir a la base de datos las más cercanas al de la consulta.
En esta entrada vamos a probarlo con los 540 títulos de este blog y con los embeddings generados en local con Ollama, sin cuentas de pago. Veremos lo que encuentra frente a un LIKE, dónde se equivoca y los errores del camino. Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, Laravel AI SDK 1.2.1, PHP 8.5, MariaDB 11.8 y Ollama 0.34 en Laravel Sail.
Por qué no sirve el MySQL de Sail
Sail instala MySQL 8.4 por defecto, y con él no se puede. No tiene el tipo de columna vector, así que la migración falla con un error de sintaxis. Con el índice, Laravel ni siquiera llega a la base de datos: lanza The database driver in use does not support vector indexes. Y whereVectorSimilarTo() falla con Vector distance queries are only supported by Postgres and MariaDB.
MariaDB y Ollama en Sail
Vamos a crear un proyecto nuevo con Laravel Sail y MariaDB en lugar de MySQL:
composer create-project laravel/laravel buscador
cd buscador
composer require laravel/sail --dev
php artisan sail:install --with=mariadb
El comando añade el servicio mariadb a compose.yaml y deja el .env con DB_CONNECTION y DB_HOST apuntando a MariaDB, así que la base de datos no necesita nada más. Solo tenemos que añadir al .env la URL de Ollama, que usaremos más adelante:
OLLAMA_URL=http://ollama:11434
Si ya tienes un proyecto con Sail y MySQL, sail:install --with=mariadb añade el servicio mariadb, pero no quita mysql. Los 2 publican el mismo puerto, así que Docker falla al levantar Sail con port is already allocated. Quita de compose.yaml el servicio mysql, su línea en el depends_on de laravel.test y el volumen sail-mysql. En el .env, el comando cambia DB_CONNECTION a mariadb, pero deja DB_HOST con mysql: cámbialo también a mariadb. La base de datos de MariaDB empieza vacía, así que tendrás que volver a migrar.
Ollama es el servidor que ejecuta los modelos en tu máquina. Para añadirlo a Sail, crea el archivo compose.override.yaml en la raíz del proyecto. Docker Compose lo combina con compose.yaml:
services:
laravel.test:
depends_on:
- ollama
ollama:
image: 'ollama/ollama:latest'
volumes:
- 'sail-ollama:/root/.ollama'
networks:
- sail
volumes:
sail-ollama:
driver: local
Levanta Sail y descarga el modelo de embeddings:
./vendor/bin/sail up -d
./vendor/bin/sail exec ollama ollama pull qwen3-embedding:0.6b
Hemos elegido qwen3-embedding:0.6b porque ocupa 639 MB, admite más de 100 idiomas y devuelve vectores de 1024 dimensiones. El que usa el AI SDK por defecto, nomic-embed-text, se queda corto con nuestros títulos en español: para "pagos con tarjeta" devuelve primero "Cargo, el gestor de paquetes de Rust". No necesitas tarjeta gráfica, pero cargado en memoria ocupa 2,4 GB, según ollama ps.
El resto de comandos de esta entrada se ejecutan en el contenedor de la aplicación, así que abre una terminal en él:
./vendor/bin/sail shell
Configurar los embeddings
Instala el AI SDK y publica su configuración, como se explica en Laravel AI SDK 1.x:
composer require "laravel/ai:^1.2.1"
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
Abre config/ai.php y cambia el proveedor de los embeddings y el modelo de Ollama:
'default_for_embeddings' => 'ollama',
// ...
'ollama' => [
'driver' => 'ollama',
'key' => env('OLLAMA_API_KEY', ''),
'url' => env('OLLAMA_URL', 'http://localhost:11434'),
'models' => [
'embeddings' => [
'default' => 'qwen3-embedding:0.6b',
],
],
],
Necesitas como mínimo la 1.2.1, del 12 de octubre de 2026 (PR #1115). Hasta la 1.2.0, si no configurabas las dimensiones, el SDK le pedía a Ollama vectores de 768 dimensiones, y al guardarlos en una columna de 1024, MariaDB respondía con 1292 Incorrect vector value. Desde la 1.2.1, el SDK usa las dimensiones del modelo.
La tabla y el modelo
Vamos a crear el modelo con su migración:
php artisan make:model Article -m
Abre la migración que se acaba de crear y cambia su contenido por el siguiente:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->vector('embedding', dimensions: 1024)->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('articles');
}
};
La columna lleva las dimensiones del modelo, y index() crea un índice vectorial, que MariaDB usa para acelerar la búsqueda de los vectores más cercanos. Trabaja con la distancia del coseno, la misma que usa Laravel al buscar. No la marques como nullable(): MariaDB no admite nulos en un índice vectorial, y la migración falla con 1252 All parts of a VECTOR index must be NOT NULL.
El modelo, en app/Models/Article.php, queda así:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Casts\AsVector;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['title', 'slug', 'embedding'])]
#[Hidden(['embedding'])]
class Article extends Model
{
protected function casts(): array
{
return [
'embedding' => AsVector::class,
];
}
}
El cast AsVector convierte el array de PHP al formato de MariaDB al guardar, y al revés al leer. Con #[Hidden], los 1024 números no salen cuando el modelo se convierte en JSON.
Ejecuta las migraciones:
php artisan migrate
Generar los embeddings
Los 540 títulos del blog los hemos publicado en un gist. Vamos a descargarlos en database/data/articles.json:
mkdir -p database/data
curl -sL https://gist.githubusercontent.com/cursosdesarrolloweb/11c3f296ef42048f457d6abd639d4361/raw/articles.json -o database/data/articles.json
Cada uno lleva su title y su slug:
[
{
"title": "Claude Opus 5.5 vs Sonnet 5.5: cuál usar para programar",
"slug": "claude-opus-5-5-vs-sonnet-5-5",
"published_at": "2026-10-12"
},
{
"title": "Servir Markdown a los agentes de IA desde Laravel",
"slug": "markdown-agentes-ia-laravel",
"published_at": "2026-10-12"
},
...
]
Vamos a crear un comando que los importa:
php artisan make:command ImportArticles
Cambia el contenido de app/Console/Commands/ImportArticles.php por el siguiente:
<?php
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Laravel\Ai\Embeddings;
#[Signature('articles:import')]
#[Description('Importa los artículos y genera el embedding de cada título con Ollama')]
class ImportArticles extends Command
{
public function handle(): void
{
$articles = json_decode(file_get_contents(database_path('data/articles.json')), true);
$start = hrtime(true);
foreach (array_chunk($articles, 100) as $chunk) {
$response = Embeddings::for(array_column($chunk, 'title'))->generate();
foreach ($chunk as $i => $article) {
Article::updateOrCreate(
['slug' => $article['slug']],
['title' => $article['title'], 'embedding' => $response->embeddings[$i]],
);
}
}
$this->info(sprintf(
'%d artículos con vectores de %d dimensiones en %.1f s',
count($articles),
count($response->embeddings[0]),
(hrtime(true) - $start) / 1e9,
));
}
}
Si te fijas, los títulos se envían a Ollama de 100 en 100. Embeddings::for() genera los vectores de varios textos en una sola petición, pero el SDK la corta a los 30 s, y en CPU los 540 de golpe tardan más. La petición falla con Could not connect to AI provider [ollama], un mensaje que despista: la causa real es cURL error 28: Operation timed out after 30048 milliseconds. Con lotes de 100, cada petición tarda como mucho 11 s. También puedes subir el límite en la propia petición, en la línea de Embeddings::for() del comando:
$response = Embeddings::for(array_column($chunk, 'title'))->timeout(120)->generate();
Vamos a ejecutarlo:
php artisan articles:import
540 artículos con vectores de 1024 dimensiones en 50.4 s
Es lo que tarda en un portátil sin tarjeta gráfica. Esto se hace una vez: después, cada búsqueda solo necesita el vector de la consulta.
Buscar por significado
Vamos a crear otro comando para las búsquedas:
php artisan make:command SearchArticles
Cambia el contenido de app/Console/Commands/SearchArticles.php por el siguiente:
<?php
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
#[Signature('articles:search {query}')]
#[Description('Busca artículos por significado y compara el resultado con LIKE')]
class SearchArticles extends Command
{
public function handle(): void
{
$query = $this->argument('query');
$this->line('Con LIKE: '.Article::where('title', 'like', "%{$query}%")->count().' resultados');
$vector = Str::of($query)->toEmbeddings();
$articles = Article::query()
->select('title')
->selectVectorDistance('embedding', $vector, as: 'distance')
->whereVectorSimilarTo('embedding', $vector)
->limit(5)
->get();
$this->table(['Similitud', 'Título'], $articles->map(fn (Article $article) => [
number_format(1 - $article->distance, 2),
$article->title,
]));
}
}
Primero contamos lo que encuentra un LIKE con la misma frase. Después, toEmbeddings() convierte la consulta en un vector con el mismo modelo. whereVectorSimilarTo() se queda con las filas cuyo vector se parece al de la consulta, con una similitud de 0,6 o más, su valor por defecto, y las ordena de la más parecida a la menos. selectVectorDistance() añade la distancia del coseno, donde 0 es idéntico, así que la similitud es 1 menos la distancia. A whereVectorSimilarTo() también le puedes pasar el texto, pero como usamos el vector 2 veces, lo calculamos antes.
Vamos a probar 2 búsquedas:
$ php artisan articles:search "proteger una API con tokens"
Con LIKE: 0 resultados
+-----------+---------------------------------------------------------------------------+
| Similitud | Título |
+-----------+---------------------------------------------------------------------------+
| 0.74 | Guía Completa de JWT en Laravel: Seguridad y Autenticación API |
| 0.70 | ¿Qué es Laravel Sanctum? SPA y API Tokens |
| 0.67 | Cómo validar tu API contra su documentación OpenAPI con Laravel Spectator |
| 0.66 | Generar tokens en Laravel únicos y de forma automática |
| 0.64 | Cómo instalar la API en Laravel |
+-----------+---------------------------------------------------------------------------+
$ php artisan articles:search "pagos con tarjeta"
Con LIKE: 0 resultados
+-----------+-----------------------------------------------------+
| Similitud | Título |
+-----------+-----------------------------------------------------+
| 0.65 | Laravel Cashier 10 y Stripe SCA |
| 0.63 | Portal de facturación en Laravel Cashier con Stripe |
+-----------+-----------------------------------------------------+
Ninguna de las 2 frases aparece en un título, y "pago" o "tarjeta" sueltas tampoco, así que el LIKE no encuentra nada. La búsqueda vectorial relaciona "tokens" con JWT y Sanctum, y "pagos" con Cashier y Stripe. En "pagos con tarjeta" solo hay 2 resultados porque el siguiente, "Laravel Crater, sistema de facturación para Laravel", se queda en 0,59. Con los sinónimos funciona igual: "enviar emails" devuelve primero "Enviar correos en PHP con PHPMailer", con 0,82.
Con el modelo ya cargado, generar el vector de la consulta tarda unos 51 ms, y la consulta en MariaDB, 2,7 ms. Con el modelo sin cargar, la búsqueda tarda 1,5 s, y Ollama lo saca de la memoria a los 5 minutos sin uso.
Dónde se equivoca
Con una frase vaga, como "que mi aplicación vaya más rápida", el resultado es este:
$ php artisan articles:search "que mi aplicación vaya más rápida"
Con LIKE: 0 resultados
+-----------+--------------------------------------------------------------------------------------+
| Similitud | Título |
+-----------+--------------------------------------------------------------------------------------+
| 0.66 | Filament Blueprint: La Nueva Herramienta de IA para Planificar Aplicaciones Filament |
| 0.66 | Laravel Pulse: Monitoriza el Rendimiento de tu Aplicación |
| 0.64 | Optimizar el rendimiento de Ionic en WebStorm o IntelliJ IDEA |
| 0.64 | NativePHP Mobile v2: Novedades, plan gratuito y todo lo que necesitas saber |
| 0.63 | Desarrollo de aplicaciones Realtime con Laravel |
+-----------+--------------------------------------------------------------------------------------+
El artículo de Octane, que es el que buscaríamos, sale en el puesto 18, con 0,59. Con títulos tan cortos, la palabra "aplicación" pesa más que la idea de velocidad. Y con la palabra Laravel en la consulta, todo se parece: con "mejorar el rendimiento de Laravel", 17 títulos pasan de 0,79, todos con Laravel en el título, y Octane sale sexto.
Por eso, el umbral de 0,6 no sirve igual para todo. Aquí, un resultado que no tiene nada que ver llega a 0,66, y uno bueno se queda fuera: para "login con usuario y contraseña", "Autenticación personalizada en Laravel 5" saca 0,599. Antes de cambiar el umbral con minSimilarity, pruébalo con búsquedas reales de tus usuarios, y apóyate más en limit() que en el umbral.
Un último detalle: el índice vectorial es aproximado y, para ganar velocidad, a veces se salta una fila. Hemos reconstruido el índice 240 veces con los mismos vectores, y en 15 alguna búsqueda perdió o desordenó uno de sus 5 primeros resultados. La variable mhnsw_ef_search marca cuántas filas candidatas revisa MariaDB, y por defecto vale 20. Si la subes antes de buscar, los fallos bajan a 2. En app/Console/Commands/SearchArticles.php, importa la fachada DB y cambia la variable al principio de handle():
use Illuminate\Support\Facades\DB;
// ...
public function handle(): void
{
DB::statement('SET SESSION mhnsw_ef_search = 100');
$query = $this->argument('query');
Con esto ya tienes búsqueda por significado sin salir de tu base de datos ni de tu máquina, una búsqueda que acierta con los sinónimos y falla más con las frases vagas.
Espero que te sirva. Si quieres que un modelo responda preguntas a partir de tus documentos, tienes el curso RAG con Laravel y Ollama.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción