Laravel 13 introduce whereVectorSimilarTo en una línea del query builder. Esa línea esconde una infraestructura completa que necesitas entender antes de desplegarla: cómo funcionan los embeddings, qué índice usar según el tamaño de la tabla, cómo combinar búsqueda semántica con filtros exactos y qué pasa con los costes cuando el volumen escala.
Esta guía cubre la implementación completa: desde instalar la extensión en PostgreSQL hasta una búsqueda híbrida en producción que combina texto y vectores.
Qué es la búsqueda semántica y cuándo usarla#
La búsqueda por texto exacto (LIKE, full-text search) encuentra documentos que contienen las palabras exactas de la query. La búsqueda semántica encuentra documentos con el mismo significado, aunque usen palabras distintas.
Ejemplo concreto: un usuario busca "cómo cancelar mi pedido". La búsqueda exacta devuelve artículos que contienen "cancelar" y "pedido". La búsqueda semántica también devuelve "anular compra", "deshacer un encargo" y "política de devoluciones" — porque tienen el mismo significado desde el punto de vista del usuario.
Casos de uso donde la búsqueda semántica gana claramente:
- Base de conocimiento / FAQ: las preguntas de usuarios raramente coinciden con el vocabulario exacto de los artículos
- Búsqueda en catálogo de productos: "algo para regalar a mi madre" debe encontrar productos relevantes aunque no contengan esas palabras
- Búsqueda en documentos largos: contratos, manuales, documentación técnica donde el usuario no sabe el término exacto
- Recomendaciones: encontrar contenido similar al que el usuario ya consume
Cuándo NO usar búsqueda semántica: búsquedas por identificadores exactos (número de pedido, email, ID), filtros de atributos (precio < 50, color = rojo), o cualquier caso donde la coincidencia exacta es lo que el usuario necesita.
Preparar el stack#
PostgreSQL con pgvector#
# En Ubuntu/Debian
sudo apt install postgresql-16-pgvector
# En macOS con Homebrew
brew install pgvector
# En Docker — imagen oficial con pgvector incluido
docker run -d \
--name postgres-pgvector \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
pgvector/pgvector:pg16Activar la extensión en la base de datos:
CREATE EXTENSION IF NOT EXISTS vector;Laravel 13 con el AI SDK#
composer require laravel/framework:^13.0
# Publicar la configuración del AI SDK
php artisan vendor:publish --tag=ai-config// config/ai.php
return [
'default' => env('AI_PROVIDER', 'openai'),
'providers' => [
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-4o'),
'embedding_model' => 'text-embedding-3-small', // 1536 dimensiones
],
'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY'),
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
// Anthropic usa voyage-3 para embeddings a través del AI SDK
'embedding_model' => 'voyage-3',
],
],
];Migración y modelo#
Crear la tabla con columna vector#
// database/migrations/2026_05_17_create_articles_table.php
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->string('category')->nullable();
$table->boolean('published')->default(false);
$table->timestamps();
});
// La columna vector se añade con SQL directo — Blueprint no tiene soporte nativo aún
DB::statement('ALTER TABLE articles ADD COLUMN embedding vector(1536)');
// Índice HNSW para búsquedas rápidas por similitud coseno
// Para tablas < 100K filas, un índice plano (ivfflat) puede ser suficiente
DB::statement('
CREATE INDEX articles_embedding_hnsw_idx
ON articles
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
');
}El modelo Eloquent#
// app/Models/Article.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\AI;
class Article extends Model
{
protected $fillable = ['title', 'content', 'category', 'published'];
protected $casts = [
'published' => 'boolean',
];
/**
* Genera y guarda el embedding para este artículo.
* Llama a esto cuando creas o actualizas el contenido.
*/
public function generateEmbedding(): void
{
// Combinamos título y contenido para un embedding más rico
$texto = $this->title . "\n\n" . $this->content;
$vector = AI::embed($texto);
// Guardar como array JSON que pgvector entiende
DB::table('articles')
->where('id', $this->id)
->update(['embedding' => json_encode($vector)]);
}
/**
* Scope para búsqueda por similitud semántica.
*/
public function scopeSimilarTo(
$query,
string $texto,
int $limite = 10,
float $umbralMinimo = 0.7
) {
$vector = AI::embed($texto);
$vectorStr = json_encode($vector);
return $query
->selectRaw('*, 1 - (embedding <=> ?) AS similitud', [$vectorStr])
->whereRaw('embedding IS NOT NULL')
->whereRaw('1 - (embedding <=> ?) >= ?', [$vectorStr, $umbralMinimo])
->orderByRaw('embedding <=> ?', [$vectorStr])
->limit($limite);
}
}Uso básico#
// Búsqueda semántica simple
$articulos = Article::query()
->where('published', true)
->similarTo('cómo cancelar mi pedido', limite: 5)
->get();
// O con whereVectorSimilarTo del AI SDK (nuevo en Laravel 13)
$articulos = DB::table('articles')
->where('published', true)
->whereVectorSimilarTo('embedding', 'cómo cancelar mi pedido')
->limit(5)
->get();Indexación de embeddings#
El punto más crítico de implementar búsqueda semántica correctamente es la estrategia de indexación. Indexar en el momento equivocado bloquea la UX; no indexar deja el embedding sin generar.
Opción 1: Job en cola para indexación asíncrona (recomendado)#
// app/Jobs/GenerarEmbeddingArticulo.php
namespace App\Jobs;
use App\Models\Article;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class GenerarEmbeddingArticulo implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60; // segundos entre reintentos
public function __construct(public readonly int $articleId) {}
public function handle(): void
{
$article = Article::find($this->articleId);
if (!$article) return;
$article->generateEmbedding();
}
}
// app/Observers/ArticleObserver.php
namespace App\Observers;
use App\Jobs\GenerarEmbeddingArticulo;
use App\Models\Article;
class ArticleObserver
{
public function created(Article $article): void
{
GenerarEmbeddingArticulo::dispatch($article->id);
}
public function updated(Article $article): void
{
// Solo re-indexar si cambió el contenido textual
if ($article->wasChanged(['title', 'content'])) {
GenerarEmbeddingArticulo::dispatch($article->id);
}
}
}
// app/Providers/AppServiceProvider.php
Article::observe(ArticleObserver::class);Opción 2: Indexación masiva de registros existentes#
// php artisan make:command IndexarEmbeddingsArticulos
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Command;
class IndexarEmbeddingsArticulos extends Command
{
protected $signature = 'embeddings:indexar {--batch=50} {--sin-embedding}';
protected $description = 'Genera embeddings para artículos';
public function handle(): void
{
$query = Article::query()->where('published', true);
if ($this->option('sin-embedding')) {
$query->whereNull('embedding');
}
$total = $query->count();
$batch = (int) $this->option('batch');
$this->info("Indexando {$total} artículos en batches de {$batch}...");
$bar = $this->output->createProgressBar($total);
$query->chunkById($batch, function ($articulos) use ($bar) {
foreach ($articulos as $articulo) {
try {
$articulo->generateEmbedding();
$bar->advance();
} catch (\Exception $e) {
$this->warn("\nError en artículo {$articulo->id}: {$e->getMessage()}");
}
// Respetar el rate limit de la API
usleep(50_000); // 50ms entre requests = ~20 req/s
}
});
$bar->finish();
$this->newLine();
$this->info('Indexación completada.');
}
}Búsqueda híbrida: semántica + texto exacto + filtros#
En producción, la búsqueda semántica pura raramente es suficiente. Los mejores resultados vienen de combinar similitud vectorial con búsqueda de texto exacto y filtros estructurados. Esto se llama búsqueda híbrida.
// app/Services/BusquedaService.php
namespace App\Services;
use App\Models\Article;
use Illuminate\Support\Facades\AI;
use Illuminate\Support\Facades\DB;
class BusquedaService
{
/**
* Búsqueda híbrida que combina:
* - Similitud vectorial (semántica)
* - Full-text search de PostgreSQL (exacta)
* - Filtros estructurados (categoría, estado)
*
* Usa Reciprocal Rank Fusion para combinar los scores.
*/
public function buscar(
string $query,
?string $categoria = null,
int $limite = 10
): \Illuminate\Support\Collection {
$vector = AI::embed($query);
$vectorStr = json_encode($vector);
// Los 60 constante del RRF es estándar — ajústalo entre 10-100
// según qué tan agresivamente quieres premiar posición vs score
$sql = "
WITH busqueda_semantica AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> :vector1) AS rank_semantico
FROM articles
WHERE published = true
AND embedding IS NOT NULL
" . ($categoria ? "AND category = :categoria1" : "") . "
ORDER BY embedding <=> :vector2
LIMIT 60
),
busqueda_texto AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY ts_rank(
to_tsvector('spanish', title || ' ' || content),
plainto_tsquery('spanish', :query1)
) DESC
) AS rank_texto
FROM articles
WHERE published = true
AND to_tsvector('spanish', title || ' ' || content)
@@ plainto_tsquery('spanish', :query2)
" . ($categoria ? "AND category = :categoria2" : "") . "
LIMIT 60
),
fusion AS (
SELECT
COALESCE(s.id, t.id) AS id,
(
COALESCE(1.0 / (60 + s.rank_semantico), 0) +
COALESCE(1.0 / (60 + t.rank_texto), 0)
) AS score_rrf
FROM busqueda_semantica s
FULL OUTER JOIN busqueda_texto t ON s.id = t.id
)
SELECT
a.id,
a.title,
a.content,
a.category,
f.score_rrf
FROM fusion f
JOIN articles a ON a.id = f.id
ORDER BY f.score_rrf DESC
LIMIT :limite
";
$bindings = [
'vector1' => $vectorStr,
'vector2' => $vectorStr,
'query1' => $query,
'query2' => $query,
'limite' => $limite,
];
if ($categoria) {
$bindings['categoria1'] = $categoria;
$bindings['categoria2'] = $categoria;
}
return DB::select($sql, $bindings);
}
}Controller#
// app/Http/Controllers/BusquedaController.php
namespace App\Http\Controllers;
use App\Services\BusquedaService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class BusquedaController extends Controller
{
public function __construct(private BusquedaService $busqueda) {}
public function buscar(Request $request)
{
$request->validate([
'q' => 'required|string|min:2|max:500',
'categoria' => 'nullable|string|max:100',
]);
$query = $request->string('q')->trim()->value();
$categoria = $request->string('categoria')->value() ?: null;
// Cachear resultados frecuentes — la llamada a la API de embeddings
// tiene latencia y coste. Queries repetidas no necesitan regenerarse.
$cacheKey = 'busqueda:' . md5($query . ':' . $categoria);
$resultados = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($query, $categoria) {
return $this->busqueda->buscar($query, $categoria);
});
return response()->json([
'resultados' => $resultados,
'total' => count($resultados),
]);
}
}Índices y rendimiento en producción#
Elegir el índice correcto#
pgvector ofrece dos tipos de índices:
IVFFlat — más rápido de construir, suficiente para la mayoría de tablas:
-- Para tablas hasta ~500K filas
-- lists = sqrt(num_filas) es el punto de partida
CREATE INDEX articles_embedding_ivfflat_idx
ON articles
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);HNSW — mejor recall, más lento de construir, recomendado para producción con alto volumen:
-- Para tablas grandes o cuando el recall importa más que el tiempo de build
-- m entre 8-64, ef_construction entre 64-200
CREATE INDEX articles_embedding_hnsw_idx
ON articles
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);Para tablas con menos de 10.000 filas, un índice no es necesario — el sequential scan es más rápido.
Monitorear el rendimiento#
-- Ver tiempo de ejecución de queries de búsqueda
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, 1 - (embedding <=> '[...]'::vector) AS similitud
FROM articles
WHERE published = true
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Ver si el índice HNSW está siendo usado
-- Busca "Index Scan using articles_embedding_hnsw_idx" en el planGestión de costes de la API de embeddings#
El modelo text-embedding-3-small de OpenAI cuesta 0,02$ por millón de tokens. Para la mayoría de proyectos el coste es despreciable. Para proyectos con mucho volumen de contenido o muchas búsquedas, considera:
// Usar caché para embeddings de búsqueda — las mismas queries se repiten
// No cachear embeddings de documentos — se generan una vez al indexar
// config/cache.php — TTL razonable para queries de búsqueda
'embedding_search_ttl' => env('EMBEDDING_SEARCH_TTL', 900), // 15 minutos
// En el BusquedaService
$vectorKey = 'embedding:query:' . md5($query);
$vector = Cache::remember(
$vectorKey,
config('cache.embedding_search_ttl'),
fn () => AI::embed($query)
);Errores comunes al implementar búsqueda semántica#
Generar el embedding en el request HTTP. La llamada a la API de embeddings tiene latencia (50-200ms). Si la haces dentro del request que guarda el contenido, bloqueas la respuesta. Usa un job en cola y muestra un estado "indexando" hasta que esté listo.
No tener índice vectorial en tablas grandes. Sin índice, pgvector hace sequential scan. Para tablas con más de 10.000 filas, el tiempo de respuesta se vuelve inaceptable. Crea el índice antes de que la tabla crezca, no después.
Usar el mismo modelo de embedding para generación e indexación. Si indexas con text-embedding-3-small (1536 dimensiones) y buscas con voyage-3 (1024 dimensiones), las comparaciones no tienen sentido. El modelo de embedding debe ser el mismo para generar los vectores almacenados y para generar el vector de búsqueda.
Ignorar el umbral de similitud mínima. whereVectorSimilarTo sin umbral devuelve siempre resultados aunque no sean relevantes. Una similitud de 0.65 o inferior suele significar que no hay resultado relevante para esa query. Configura un umbral y devuelve "sin resultados" cuando no se supere.
Re-indexar en cada actualización del registro. Si un post tiene un campo updated_at que se actualiza en cada vista, y tienes un observer en updated, estás regenerando el embedding con cada visita. Observa solo los cambios en los campos de contenido.
No considerar el idioma en full-text search. Si combinas búsqueda vectorial con full-text search de PostgreSQL, el diccionario importa. to_tsvector('spanish', texto) y plainto_tsquery('spanish', query) deben usar el mismo idioma.
Conclusión#
La búsqueda semántica con Laravel 13 y pgvector es accesible: el AI SDK genera los embeddings, whereVectorSimilarTo hace la query y pgvector gestiona la similitud. La complejidad real no está en la implementación básica, está en los detalles de producción: indexación asíncrona para no bloquear el UX, índice HNSW para tablas grandes, búsqueda híbrida para mejores resultados y caché para controlar costes y latencia.
El punto de partida más rápido es implementar búsqueda semántica simple en una tabla pequeña, medir el impacto real en la relevancia de resultados para tus usuarios y luego añadir la complejidad que se justifique. La búsqueda híbrida con RRF casi siempre da mejores resultados que la semántica pura, pero requiere tener el full-text search bien configurado para el idioma correcto. Empieza simple, mide, itera.
Más sobre PHP
Ver todos →