Tutoriales de Laravel • • 5-8 minutos

Búsqueda semántica en Laravel 13 con MariaDB Vector: columna, índice y consulta sin servicio externo

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Compartir:
Búsqueda semántica en Laravel 13 con MariaDB Vector: columna, índice y consulta sin servicio externo
Imagen generada con IA

Desde Laravel 13.28 la búsqueda por significado cabe en la base de datos que ya tienes: una columna vector, un índice y una consulta. El camino es corto, y se rompe en tres sitios muy concretos: el driver, las dimensiones y la métrica del índice.

Qué cambia con MariaDB Vector y qué no

Una columna más en la base de datos que ya tienes

Un embedding es la traducción de un texto a una dirección en un espacio de muchas dimensiones. Buscar por significado es preguntar qué documentos están cerca de la dirección de esta pregunta. Hasta hace poco eso obligaba a montar un motor aparte o a usar PostgreSQL con pgvector. Desde Laravel 13.28, con MariaDB 11.7 o superior, la columna, el índice y la consulta son parte del mismo motor: no hay segundo servicio ni extensión que instalar.

Qué resuelve y qué no resuelve la similitud semántica

Resuelve la búsqueda por concepto: alguien escribe "dónde comer bien en Napa" y aparece tu documento sobre viñedos, aunque no comparta ni una palabra. No resuelve la coincidencia exacta. Si el usuario busca "factura 2026-08" o un número de seguimiento, el vector no distingue ese código de otro parecido, y ahí sigue ganando el texto completo. Saber esto antes de empezar evita reemplazar un buscador que funcionaba.

Requisitos antes de escribir una línea

MariaDB 11.7 o superior: 11.8 LTS y 12.3 LTS para producción

El tipo VECTOR llegó con MariaDB 11.7 y es estable en 11.8 LTS, que es la versión que tiene sentido en producción (12.3 LTS es la otra opción a largo plazo). Muchos servidores con cPanel o distribuciones LTS siguen en la rama 10.x, y pasar de 10.x a 11.8 es un salto mayor: backup lógico con mariadb-dump, ventana de mantenimiento y pruebas en un entorno aparte antes de tocarlo.

Laravel 13.28 y el driver mariadb, no mysql

La gramática de MariaDB para vectores entró en la 13.27 y la corrección de vec_fromtext() con el cast AsVector llegó en la 13.28. En el archivo .env el driver tiene que ser DB_CONNECTION=mariadb. Este es el primer tropiezo y el más común: MariaDB habla el protocolo de MySQL, así que con mysql todo parece funcionar hasta que llega la primera consulta vectorial.

Comprobar versiones en el servidor y en CI

mariadb -e 'SELECT VERSION();'
php artisan --version

Si la primera línea no devuelve 11.7 o más, no sigas. Y revisa también el pipeline: si las pruebas corren contra un MySQL de CI, la suite pasará en verde y el despliegue fallará en producción por una diferencia de motor que nadie miró.

La migración: columna vector e índice

Las dimensiones son un contrato con el modelo de embeddings

La columna guarda un número fijo de dimensiones y ese número lo decide el modelo que genera los vectores: 1536 si usas un modelo de 1536 dimensiones, 3072 si usas otro. No es un ajuste de rendimiento que se pueda cambiar después. Si cambias de modelo y la dimensión cambia, necesitas columna nueva o migración más re-embedding de todas las filas. Por eso conviene guardar también qué modelo generó cada vector.

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();
});

Qué SQL emite Laravel por debajo

Un ->index() sobre una columna vector no crea un índice B-tree normal: emite la sintaxis del motor. En MariaDB el resultado se parece a esto, y conviene comprobarlo en tu versión concreta con una migración en local:

alter table documents
  add vector index documents_embedding_index (embedding)
  M=6 DISTANCE=cosine;

El parámetro M ajusta el equilibrio entre precisión, tamaño del índice y velocidad de búsqueda, y DISTANCE admite euclidean o cosine. Laravel emite cosine porque sus consultas comparan por similitud del coseno. Si algún día cambias la métrica del índice, tienes que reconstruirlo: no se recalcula solo.

El helper de pgvector que aquí no se llama

Muchos ejemplos de la documentación de búsqueda vectorial empiezan con Schema::ensureVectorExtensionExists(). En MariaDB esa línea lanza una excepción con el mensaje "Extensions are only supported by Postgres". Es un helper de pgvector y aquí no se usa: en MariaDB el tipo VECTOR ya está en el servidor, no hay extensión que activar.

El cast y la consulta por similitud

Por qué hace falta el cast AsVector

En MariaDB la columna vuelve como bytes float32 empaquetados, así que un cast a array o a JSON no funciona. Si envías un JSON a una columna VECTOR, el servidor responde con 1292 Incorrect vector value. El cast AsVector se encarga de las dos direcciones: lee el binario al consultar y escribe con vec_fromtext() al guardar.

use Illuminate\Database\Eloquent\Casts\AsVector;

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

whereVectorSimilarTo, minSimilarity y el resto de métodos

Con el cast en su sitio, la consulta es una línea. El primer argumento es la columna y el segundo el texto de búsqueda; si el SDK de IA está instalado, el texto se convierte en embedding por el camino.

$results = Document::query()
    ->whereVectorSimilarTo('embedding', 'mejores viñedos de Napa')
    ->limit(10)
    ->get();

// Con umbral de similitud mínima
$results = Document::query()
    ->whereVectorSimilarTo('embedding', $texto, minSimilarity: 0.7)
    ->get();

Del mismo grupo hay tres métodos que aparecen en cuanto se ajusta el orden o el filtro: whereVectorDistanceLessThan para filtrar por distancia máxima, orderByVectorDistance para ordenar sin filtrar y selectVectorDistance para traer la distancia como columna y poder mostrarla o depurar.

De dónde salen los embeddings

El vector lo genera un modelo externo, y ahí conviven dos costes: el de la API y el del tiempo. Para el relleno inicial, un job en cola con chunkById y un rate limit explícito es la diferencia entre terminar en una noche y que te bloqueen la cuenta. Si ya montaste agentes con el SDK de IA en PHP, la pieza de embeddings es la misma que se explica en la guía de agentes y sub-agentes del SDK.

Rellenar la tabla sin tumbar la base ni la cuota

Job en cola, por lotes y con rate limit

Nunca en una migración ni en un tinker. Un job por lote, con chunkById para no cargar la tabla en memoria y con una pausa entre llamadas al proveedor. El proceso tiene que poder cortarse y retomarse sin dejar filas a medio escribir.

Idempotencia: hash del texto y versión del modelo

Guardar un hash del texto de origen y el nombre del modelo junto al vector permite dos cosas: re-embeddear solo lo que cambió y detectar filas con vectores de un modelo anterior. Mezclar embeddings de modelos distintos en la misma columna hace que las distancias no signifiquen nada, y ese error no da ningún aviso: simplemente devuelve resultados malos.

Cuánto pesa un vector y cuánto cuesta el índice

Float32 son 4 bytes por dimensión. Una fila de 1536 dimensiones ocupa 6.144 bytes, unos 6 KB, solo en la columna. Un millón de filas son unos 6 GB de vectores, más la estructura del índice. Ese cálculo, hecho antes de empezar, evita la sorpresa de descubrir que la tabla no cabe en el disco que tienes.

Búsqueda híbrida: texto y vectores en la misma consulta

MATCH AGAINST con distancia de coseno

MariaDB permite combinar las dos señales en una sola sentencia: relevancia textual por un lado y cercanía vectorial por el otro, con un peso para cada una. El patrón es puntuar con las dos y ordenar por la suma ponderada, ajustando los pesos según lo que busque tu usuario.

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;

Cuándo basta con texto completo

Para nombres propios, códigos, referencias exactas y números de documento, el texto completo gana y además es más barato. Si tu caso es casi todo así, un motor dedicado sigue siendo mejor idea que añadir vectores, y el camino está explicado en la guía de búsqueda con Scout y Meilisearch.

Los tres tropiezos que cuestan una tarde

El driver mysql que medio funciona

Con DB_CONNECTION=mysql las consultas normales pasan porque MariaDB habla el protocolo de MySQL, pero las vectoriales y los índices vectoriales lanzan excepción. Pasa sobre todo en proyectos que migraron de MySQL a MariaDB y nunca cambiaron la línea del .env.

La métrica del índice que se ignora en silencio

MariaDB solo usa el índice vectorial cuando la función de distancia de la consulta coincide con la del índice. Si no coincide, no avisa: hace un recorrido completo de la tabla y responde bien, solo que lento. Es el fallo más caro de diagnosticar porque los resultados son correctos.

El error 4079 y por qué necesitas 13.28

SQLSTATE[HY000]: General error: 4079 Illegal parameter data type varchar for operation 'VEC_DISTANCE_COSINE'. Las funciones VEC_DISTANCE solo aceptan VECTOR y el query builder enviaba el embedding como texto JSON. La corrección emite vec_distance_cosine(col, vec_fromtext(?)) y añade el cast. Si ves ese error, tu versión es anterior a la 13.28.

SíntomaCausaArreglo
Todo funciona hasta la primera consulta vectorialDB_CONNECTION=mysqlCambiar a mariadb
"Extensions are only supported by Postgres"Se llamó ensureVectorExtensionExistsQuitar la línea
Error 4079 con VEC_DISTANCE_COSINEVersión anterior a 13.28Actualizar Laravel
Respuestas correctas pero lentasMétrica del índice distinta a la de la consultaReconstruir el índice con la métrica correcta
1292 Incorrect vector valueEnviar JSON sin el castUsar AsVector

Comprobar que el índice se usa de verdad

EXPLAIN y el escaneo completo disfrazado

Tener el índice creado no garantiza que el planificador lo use. Pasa EXPLAIN sobre la consulta real y busca el índice vectorial en el plan. Hay casos reportados de escaneo completo incluso con el índice presente, tanto en tablas pequeñas como en tablas de cientos de miles de filas, y en tablas pequeñas el planificador suele preferir recorrer todo porque le sale más barato. Prueba con volumen realista.

Qué medir después de desplegar

Latencia de la consulta con percentiles, no con una prueba suelta. Y coste por búsqueda, que suma la llamada al modelo de embeddings y la consulta a la base. Un cambio de modelo puede mejorar la calidad del primer resultado y multiplicar el coste, así que conviene medir las dos cosas a la vez.

Checklist y siguientes pasos

Diez casillas antes de dar esto por terminado:

  1. MariaDB 11.7 o superior confirmado en producción con SELECT VERSION().
  2. Laravel 13.28 o superior.
  3. DB_CONNECTION=mariadb, también en CI.
  4. Modelo de embeddings elegido antes de cargar datos.
  5. Columna vector con las dimensiones exactas del modelo.
  6. Cast AsVector en el modelo.
  7. Índice vectorial con la métrica que usa la consulta.
  8. Backfill por lotes, idempotente y con rate limit.
  9. EXPLAIN revisado con volumen realista.
  10. Latencia y coste por búsqueda medidos después del despliegue.

La base de datos ya sabe buscar por significado; lo que queda es trabajo de criterio: elegir el modelo, medir y decidir cuándo el vector no vale la pena. Si tu base es PostgreSQL, el equivalente es pgvector y el camino está en la guía de migración de MySQL a PostgreSQL. Y si el problema es que una consulta tarda cuatro segundos, antes de añadir vectores conviene mirar el plan, como se explica en cómo encontrar y arreglar una consulta lenta en MySQL 8.4.

Categorías