Semantic Search con Supabase y NextJS
Semantic Search con Supabase y NextJS
En este tutorial, vamos a crear un buscador inteligente de películas utilizando Supabase y su capacidad de búsqueda semántica. A diferencia de una búsqueda tradicional basada en palabras clave, la búsqueda semántica nos permite encontrar resultados basados en el significado y contexto de nuestra consulta.
Esto quiere decir por ejemplo, que podremos escribir una busqueda "pelicula donde salen naves" y la base de datos nos devolvera "Interstellar", a pesar de que no tengamos ese texto o esa descripcion en la base de datos.
¿Qué vamos a construir?
Crearemos una aplicación web que:
- Almacenará información sobre películas en Supabase
- Utilizará embeddings para convertir el texto en vectores (utilizaremos OpenAI para crear los embeddings)
- Implementará búsqueda semántica para encontrar películas similares
- Tendrá una interfaz moderna con Next.js y Tailwind CSS
Requisitos Previos
- Node.js 18 o superior
- (opcional) pnpm (npm i -g pnpm)
- Una cuenta en Supabase
- Una cuenta en OpenAI (para generar embeddings)
- Conocimientos básicos de TypeScript y React
Configuración del Proyecto
Antes de continuar: los comandos que ejecuto utilizan pnpm
y pnpm dlx
. Si usas npm
, reemplaza pnpm
por npm
y pnpm dlx
por npmx
.
Crear un nuevo proyecto en Next.js
pnpm dlx create-next-app@latest movies-semantic-search --typescript --tailwind --eslint --app --src-dir --turbopack --import-alias="@/*"
cd movies-semantic-search
Instalar las dependencias necesarias
pnpm install openai @supabase/ssr
- Necesitamos
openai
para poder convertir nuestros datos a embeddings, de forma que luego los podamos almacenar en la base de datos y supabase pueda hacer la busqueda semantica. - Necesitamos
@supabase/ssr
podremos authenticarnos en Supabase desde el lado del cliente y del lado del servidor.
Configuración de Supabase
- Crear un nuevo proyecto en Supabase
Desde nuestra cuenta de Supabase, creamos un nuevo proyecto.
- Habilitar la extensión pgvector
Para ello desde el SQL Editor ejecutamos:
create extension vector
with
schema extensions;
- Crear la tabla para nuestras películas
create table movies (
id bigint primary key generated always as identity,
title text,
description text,
year integer,
embedding vector(1536)
);
Fijate en el campo embedding que es de tipo vector. Ahi es donde almacenaremos el vector que representa nuestra película.
- Crear la función para búsqueda semántica
create or replace function match_movies (
query_embedding vector(1536),
match_threshold float,
match_count int
)
returns table (
id bigint,
title text,
description text,
year integer,
similarity float
)
language plpgsql
as $$
begin
return query
select
movies.id,
movies.title,
movies.description,
movies.year,
1 - (movies.embedding <=> query_embedding) as similarity
from movies
where 1 - (movies.embedding <=> query_embedding) > match_threshold
order by movies.embedding <=> query_embedding
limit match_count;
end;
$$;
Estructura del Proyecto
movies-semantic-search/
├── ...
├── scripts/
│ └── seed-movies.ts
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── search/
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── components/
│ │ ├── ui/
│ │ │ └── card.tsx
│ │ ├── movie-list.tsx
│ │ └── search-bar.tsx
│ ├── lib/
│ │ ├── openai/
│ │ │ └── openai.ts
│ │ └── supabase/
│ │ └── server.ts
│ ├── types/
│ │ └── movie.ts
└── ...
Implementación
- Configuración de variables de entorno (
.env.local
)
NEXT_PUBLIC_SUPABASE_URL=tu-url-de-supabase
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu-anon-key
OPENAI_API_KEY=tu-api-key-de-openai
La url de Supabase y la anon key puedes encontrarlas en la pagina principal del proyecto, haciendo un poco de scroll hacia abajo. Para la key de OpenAI debes de ir a platform.openai.com y autenticarte. En la barra de navegación de la izquierda tienes la sección API Keys. Crea una nueva key y copiala en .env.local.
- Configuración de la conexión con Supabase
Crearemos el del lado del servidor (lib/supabase/server.ts
):
import { createServerClient as supabaseCreateServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createServerClient = async () => {
const cookieStore = await cookies()
return supabaseCreateServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
console.error('Error setting cookies');
}
},
},
}
)
}
- Configuración de OpenAI
Creamos el fichero (src/lib/openai/openai.ts
):
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function getEmbedding(text: string) {
const response = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: text,
});
return response.data[0].embedding;
}
Esto nos permitirá conectarnos a OpenAI y generar embeddings para almacenarlos luego en Supabase
- API Route para búsqueda (
src/app/api/search/route.ts
)
import { NextResponse } from 'next/server';
import { getEmbedding } from '@/lib/openai/openai';
import { createServerClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
const supabase = await createServerClient();
try {
const { query } = await request.json();
// Generar embedding para la consulta
const embedding = await getEmbedding(query);
// Realizar búsqueda semántica
const { data: movies, error } = await supabase.rpc('match_movies', {
query_embedding: embedding,
match_threshold: 0.7,
match_count: 10
});
if (error) throw error;
return NextResponse.json({ movies });
} catch (error) {
return NextResponse.json(
{ error: 'Error en la búsqueda: ' + error },
{ status: 500 }
);
}
}
Cuando el usuario haga una petición a /api/search se ejecutará esta función. Lo que hacemos es convertir nuestra búsqueda en un embedding y a continuaciín llamar a funcion match_movies que creamos en el paso 4. Supabase comparará el embeding de nuestra busqueda con los embedings que tiene almacenados.
Dicho de otro modo, convertimos nuestra búsqueda a un vector y supabase comparará nuestro vector de búsqueda con los vectores asociados a las peliculas, devolviendo aquellos que sean más similares.
- Componente de búsqueda (
src/components/search-bar.tsx
)
'use client';
import { useState } from 'react';
export function SearchBar({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Busca una película..."
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="absolute right-2 top-2 px-4 py-1 bg-blue-500 text-white rounded-md"
>
Buscar
</button>
</div>
</form>
);
}
Este componente lo utilizaremos para buscar las películas. Lo que hace es llamar a la función onSearch cuando enviemos el formulario.
- Movie List(src/components/movie-list.tsx)
import { Movie } from '@/types/movie'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function MovieList({ movies }: { movies: Movie[] }) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{movies.map((movie) => (
<Card key={movie.id}>
<CardHeader>
<CardTitle>{movie.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Año: {movie.year}
</p>
<p className="text-sm">
{movie.description}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)
}
Necesitamos configurar shadcn, ya que vamos a utilizar su componente card. Para ello ejecuta:
pnpm dlx shadcn@latest init
Elige cualquier tema de color, por ejemplo Neutral, y a continuación, añade el componente card:
pnpm dlx shadcn@latest add card
Verás que automaticamente en la carpeta src/components/ui
se ha añadido card.tsx
.
- Tipos (types/movie.ts)
export type Movie = {
id: string;
title: string;
description: string;
year: number;
embedding: string;
}
- Página principal (
app/page.tsx
)
'use client';
import { useState } from 'react';
import { SearchBar } from '@/components/search-bar';
import { MovieList } from '@/components/movie-list';
import type { Movie } from '@/types/movie';
export default function Home() {
const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (query: string) => {
setLoading(true);
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
const data = await response.json();
setMovies(data.movies);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen p-8 space-y-4">
<h1 className="text-4xl font-bold text-center mb-8">
Buscador Semántico de Películas
</h1>
<SearchBar onSearch={handleSearch} />
{loading ? (
<div className="text-center mt-8">Buscando...</div>
) : (
<MovieList movies={movies} />
)}
</main>
);
}
Aquí combinamos el SearchBar y el MovieList. Cuando el usuario busca en el search bar, se ejecuta el onSearch dentro del SearchBar.
Este onSearch es la funcion handleSearch en esta pagina, la cual hace la llamada a /api/search para buscar las peliculas. Cuando obtenemos los resultados, los almacenamos en el estado movies el cual es utilizado por MoviesList para mostrar las películas.
Script para Insertar Datos de Ejemplo
Para probar nuestra aplicación, necesitamos algunos datos. Aquí hay un script para insertar algunas películas. Crearemos este script en /scripts/seed-movies.ts
el cual meterá unas cuantas películas en la base de datos.
Para poder ejecutarlo, necesitamos tsx
y dotenv
como dev dependencias:
pnpm i -D tsx dotenv
tsx
es para transpilar y ejecutar el fichero typescript, y dotenv
es para poder leer las variables de entorno desde este fichero.
Cuando usamos las variables de entorno en Next este automáticamente las inyecta, pero como en este caso estamos ejecutando un fichero directamente fuera de next, debemos de usar dotenv
para poder acceder a estos valores.
Y también necesitamos añadir el SUPABASE_SERVICE_ROLE_KEY
en nuestras variables de entornos (.env.local). Esta key la puedes encontrar en Supabase, dentro de tu proyecto, en Settings > Data API
.
Es la que se llama service_role secret.
Cópiala y pegala en tu .env.local como SUPABASE_SERVICE_ROLE_KEY
.
Esta key es especial porque tiene acceso de administrador a nuestra base de datos, por ello únicamente la usaremos para hacer el seed.
A continuación pegamos el código:
import dotenv from "dotenv";
dotenv.config({ path: `.env.local`, override: true });
// OpenAI
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function getEmbedding(text: string) {
const response = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: text,
});
return response.data[0].embedding;
}
// Supabase
import {createClient} from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const movies = [
{
title: "Inception",
description: "Un ladrón experto en el arte de la extracción, roba información valiosa de las profundidades del subconsciente durante el estado del sueño.",
year: 2010
},
{
title: "The Matrix",
description: "Un hacker descubre la verdad sobre su realidad y su papel en la guerra contra sus controladores.",
year: 1999
},
{
title: "Interstellar",
description: "Un grupo de exploradores viaja a través de un agujero de gusano en el espacio en un intento de asegurar la supervivencia de la humanidad.",
year: 2014
},
{
title: "The Shawshank Redemption",
description: "Dos hombres encarcelados forjan un vínculo a lo largo de los años, encontrando consuelo y redención a través de actos de decencia común.",
year: 1994
},
{
title: "The Godfather",
description: "El envejecido patriarca de una dinastía criminal traslada el control de su imperio clandestino a su hijo reacio.",
year: 1972
}
];
async function insertMovies() {
for (const movie of movies) {
const embedding = await getEmbedding(`${movie.title} ${movie.description}`);
const { error } = await supabase
.from('movies')
.insert({
...movie,
embedding
});
if (error) console.error('Error inserting movie:', error);
}
}
insertMovies();
Si te fijas, antes de almacenar la pelicula en la base de datos, creamos un embedding con OpenAi. De esta forma Supabase luego puede cruzar este embedding con el de nuestra búsqueda para obtener similitudes.
Por último, crearemos un script en nuestro package.json para ejecutar el seed:
...
"seed-movies": "tsx scripts/seed-movies.ts"
...
Si ejecutamos:
pnpm run seed-movies
Verás como se insertan las películas en la tabla movies de tu base de datos, y lo más interesante, un embedding para cada uno de ellas.
Ahora prueba a ejecutar tu aplicacion:
pnpm run dev
Y escribe un texto por ejemplo "Me gustaría ver una película sobre el universo".
Si te fijas, la pelicula asociada al universo es Interstellar, pero en su descripción no aparece la palabra universo ni ninguna de las palabras de nuestra búsqueda. Aqui es donde la busqueda semantica usando vectores consigue encontrar similitudes y devolver Interstellar como respuesta.
Si te fijas, devuelve la lista de 5 películas pero la primera es Interstellar. Esto es porque en nuestra función de function match_movies
indicamos "limit match_count" lo que hace que devuelva 10 resultados ya que es lo que hemos indicado en nuestra ruta.
Si quiers reducir la cantidad de resultados, en nuestra api route (app/api/search/route.ts) cambia el match_count a 1.
Vuelve a buscar por "Me gustaria ver una película sobre el universo". Verás que devuelve solo Interstellar.
Ahora escribe "Dime una película que trate sobre mafiosos", y verás que devuelve The Goodfather.
Si quieres que la busqueda sea mas estricta, incrementa el match_threshold (aunque un valor entre 0.5 y 0.7 es lo recomendable).
¿Cómo Funciona?
Proceso de búsqueda
- Cuando un usuario realiza una búsqueda, el texto se convierte en un vector de embedding usando la API de OpenAI.
- Este vector se envía a Supabase, donde se compara con los embeddings de todas las películas almacenadas.
- La función
match_movies
utiliza la distancia coseno para encontrar las películas más similares semánticamente. - Los resultados se ordenan por similitud y se devuelven al frontend.
Ejemplos de Búsqueda
Con este sistema, puedes realizar búsquedas sin especificar palabras clave y el sistema entenderá el contexto y devolverá resultados relevantes, incluso si las palabras exactas no están en los títulos o descripciones. Controlando el limite puedes
Conclusión
La búsqueda semántica con Supabase nos permite crear experiencias de búsqueda más inteligentes y contextuales. Este ejemplo con películas es solo el comienzo - puedes aplicar esta técnica a cualquier tipo de contenido, desde documentos hasta productos o artículos.