Embeddings y búsqueda semántica
Si entiendes embeddings, entiendes el 80% de la IA moderna aplicada a texto: RAG, recomendadores, deduplicación, clasificación, clustering, búsqueda inteligente... todo se apoya en la misma idea. En este anexo vamos al fondo del concepto, sin atajos.
1 El problema: el ordenador no entiende sinónimos
Imagina que tienes 10.000 artículos de la base de conocimiento de tu empresa y un usuario escribe en el chat: "¿cómo cancelo y recupero mi dinero?". Tú quieres encontrar el artículo que se titula "Política de devolución y reembolso".
SQL clásico (LIKE)
SELECT * FROM articulos
WHERE contenido LIKE '%cancelo%'
OR contenido LIKE '%dinero%';
El artículo no contiene la palabra cancelo ni dinero.
Cero resultados. Y sin embargo es el artículo perfecto.
Búsqueda semántica
Convertimos pregunta y artículos en vectores que representan significado, y comparamos vectores en lugar de palabras.
"Cancelar y recuperar dinero" ≈ "devolución y reembolso". Mismo significado, palabras distintas. Aparece a la primera.
La idea de fondo
Pasar del mundo de las palabras al mundo de los significados. Y para que un ordenador pueda comparar significados, los significados tienen que ser números.
2 ¿Qué es un embedding?
Un embedding es una lista de números (un vector)
que representa el significado de un texto. Para los modelos modernos como
text-embedding-3-small, esa lista tiene 1536 números.
Ejemplo conceptual
Los dos primeros textos hablan de lo mismo aunque las palabras sean diferentes. Sus vectores se parecen. El tercero habla de algo no relacionado, su vector está lejos.
Analogía: las coordenadas en un mapa
Piensa en cada texto como una ciudad y en su embedding como sus coordenadas (latitud, longitud). "Madrid" y "Toledo" tienen coordenadas parecidas porque están cerca. "Madrid" y "Tokio" tienen coordenadas muy diferentes. La diferencia con un embedding es que en lugar de 2 dimensiones (lat, lon) tenemos 1536 dimensiones: cada una mide un "aspecto" distinto del significado.
El espacio de embeddings: textos parecidos = vectores cercanos
Cada texto se convierte en un punto del espacio. La distancia entre puntos representa la diferencia de significado.
3 ¿Cómo se crea un embedding?
Un modelo de embedding es una red neuronal entrenada con miles de millones de textos. Aprende a colocar textos parecidos cerca y textos distintos lejos en su espacio interno. Tú le pasas un texto, y te devuelve el vector.
De texto a vector: lo que hace el modelo
El modelo de embedding "lee" el texto y lo comprime en una lista de números que captura su significado.
- El texto se rompe en tokens (palabras o trozos de palabra).
- Cada token se procesa por una red neuronal (transformer) que ya ha "leído" miles de millones de textos.
- La red combina la información de todos los tokens en un único vector que representa el sentido global.
- Ese vector es el embedding.
Modelos comerciales (API)
- • OpenAI:
text-embedding-3-small(1536 dim, barato) - • OpenAI:
text-embedding-3-large(3072 dim, más calidad) - • Cohere:
embed-multilingual-v3 - • Voyage:
voyage-3
Pagas por uso. Muy buena calidad. Cero infraestructura.
Modelos open source (locales)
- • sentence-transformers:
all-MiniLM-L6-v2(384 dim, ligero) - • BGE:
bge-base-en-v1.5 - • nomic-embed:
nomic-embed-text-v1.5
Gratis, privacidad total. Necesitas servidor con GPU para volúmenes altos.
Regla importante
Tienes que usar el mismo modelo para indexar y para consultar. Los embeddings de
text-embedding-3-small y los de
all-MiniLM-L6-v2 viven en espacios distintos:
compararlos no tiene sentido. Si cambias de modelo, hay que re-indexarlo todo.
4 Cómo medimos "lo cerca que están dos vectores": similitud coseno
Una vez tienes dos vectores, ¿cómo decides si los textos que representan son parecidos? La métrica más usada es la similitud coseno: mide el ángulo entre los dos vectores (no su longitud).
Mismo significado, distinta redacción.
Comparten un tema o ámbito.
Textos sobre cosas distintas.
Distancia coseno: el ángulo cuenta, no la longitud
Cuanto más pequeño el ángulo entre dos vectores, más parecidos son sus significados. Esto es la similitud coseno.
-1 (opuesto) a 1 (idéntico). Para textos casi siempre cae entre 0 y 1.La fórmula (no asusta)
similitud_coseno(A, B) = (A · B) / (|A| × |B|) A · B es el producto escalar (suma de cada par aᵢ × bᵢ).
|A| es la norma del vector. Da un número entre -1 y
1. En la práctica nunca lo calculas a mano: pgvector lo hace con
el operador <=>.
5 Búsqueda léxica vs búsqueda semántica
Esta es la diferencia clave entre la búsqueda "de toda la vida" y lo que permiten los embeddings.
Búsqueda léxica vs búsqueda semántica
El usuario escribe "quiero que me devuelvan el dinero". ¿Qué encuentra cada tipo de búsqueda?
Léxica (LIKE / full-text)
Busca palabras exactas. La query no incluye "devolución" ni "reembolso", así que falla.
Semántica (embeddings)
Compara significados. "Devolver el dinero" ≈ "reembolso" ≈ "cancelar compra y recuperar dinero".
| Búsqueda léxica | Búsqueda semántica | |
|---|---|---|
| Compara | Cadenas de caracteres | Vectores de significado |
| Tecnología | LIKE, ILIKE, full-text, BM25 | embeddings + pgvector / Pinecone |
| Soporta sinónimos | No (sin diccionario manual) | Sí, sin configurar nada |
| Soporta varios idiomas | No | Sí, si el modelo es multilingüe |
| Coste | Gratis, ya viene en la BD | Pagas por embedding (céntimos) |
| Cuándo usarla | Códigos de producto, IDs, búsqueda exacta | Lenguaje natural, FAQ, documentación |
En la práctica, ambas
Los buscadores serios combinan las dos (hybrid search): léxica para coincidencias exactas (referencias de producto, nombres propios) y semántica para lenguaje natural. Luego mezclan los dos rankings.
6 Casos de uso (mucho más allá de RAG)
RAG es el caso de uso famoso, pero los embeddings se usan en muchísimas cosas:
Búsqueda inteligente
Buscador interno de tu app que entiende preguntas en lenguaje natural, sinónimos y errores tipográficos. Sin diccionarios, sin reglas.
Deduplicación de contenido
Detectar artículos, productos o tickets duplicados aunque estén redactados de forma distinta. Si la similitud > 0.9, probablemente sea el mismo asunto.
Recomendaciones
"Productos parecidos a este", "artículos relacionados". Calculas el embedding del producto/artículo que el usuario está viendo y devuelves los más cercanos.
Clasificación
Asignar categoría a un texto comparándolo con embeddings prototipo de cada categoría. Sin entrenar un modelo propio.
Clustering
Agrupar automáticamente miles de tickets, comentarios o reviews por temas que aparecen solos. Útil para descubrir patrones que no sabías que existían.
RAG
Dar a un LLM contexto específico de tu empresa antes de responder. Es el caso estrella. Tenemos un anexo dedicado: RAG con Supabase.
7 Implementación práctica: el "hola mundo"
Vamos a verlo end-to-end de dos formas: desde JavaScript / Node (útil si lo llamas desde un endpoint Astro) y desde Java / Spring Boot (la opción más natural si tu back ya está en Spring). El SQL es el mismo en los dos casos.
A Setup común: la tabla en Supabase
Esto se ejecuta una sola vez en el SQL Editor de Supabase. Da igual el lenguaje del back.
1-- 1. Activar la extensión pgvector2create extension if not exists vector;3 4-- 2. Tabla con columna vector(1536)5create table articulos (6 id bigint primary key generated always as identity,7 titulo text,8 contenido text,9 embedding vector(1536)10);11 12-- 3. Índice para que la búsqueda sea rápida (operador coseno)13create index on articulos14 using ivfflat (embedding vector_cosine_ops)15 with (lists = 100);Versión JavaScript / Node (Astro)
1. Crear el embedding
1import OpenAI from 'openai';2 3const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });4 5const { data } = await openai.embeddings.create({6 model: 'text-embedding-3-small',7 input: '¿cómo cancelo mi pedido?'8});9 10const vector = data[0].embedding;11// → [0.124, -0.443, 0.785, ...] (1536 números)2. Insertar en Supabase
1import { createClient } from '@supabase/supabase-js';2 3const supabase = createClient(4 process.env.SUPABASE_URL,5 process.env.SUPABASE_SERVICE_ROLE_KEY6);7 8await supabase.from('articulos').insert({9 titulo: 'Política de devolución',10 contenido: 'Tienes 14 días para devolver...',11 embedding: vector12});3. Buscar los más parecidos
1// 1) Embedding de la pregunta del usuario2const { data: emb } = await openai.embeddings.create({3 model: 'text-embedding-3-small',4 input: '¿cómo recupero el dinero?'5});6 7// 2) RPC a Supabase con la función match_articulos8const { data: resultados } = await supabase.rpc('match_articulos', {9 query_embedding: emb[0].embedding,10 match_count: 511});12 13console.log(resultados);14// → [{ titulo, contenido, similitud }, ...]Versión Java / Spring Boot
Vamos sin librerías mágicas para que entiendas qué pasa: RestClient
para llamar a OpenAI y JdbcTemplate para hablar con Supabase.
Al final menciono Spring AI como alternativa moderna que abstrae todo esto.
1. Dependencias del pom.xml
1<dependencies>2 <!-- Web (incluye RestClient para llamar a OpenAI) -->3 <dependency>4 <groupId>org.springframework.boot</groupId>5 <artifactId>spring-boot-starter-web</artifactId>6 </dependency>7 8 <!-- JDBC + driver de PostgreSQL para hablar con Supabase -->9 <dependency>10 <groupId>org.springframework.boot</groupId>11 <artifactId>spring-boot-starter-jdbc</artifactId>12 </dependency>13 <dependency>14 <groupId>org.postgresql</groupId>15 <artifactId>postgresql</artifactId>16 <scope>runtime</scope>17 </dependency>18</dependencies>2. application.properties
1# OpenAI (variable de entorno OPENAI_API_KEY)2openai.api.key=${OPENAI_API_KEY}3 4# Conexión a Supabase (Postgres) — usa las credenciales del project Settings5spring.datasource.url=jdbc:postgresql://db.xxxxx.supabase.co:5432/postgres6spring.datasource.username=postgres7spring.datasource.password=${SUPABASE_DB_PASSWORD}3. Servicio que llama a OpenAI
1package com.tuapp.embeddings;2 3import org.springframework.beans.factory.annotation.Value;4import org.springframework.stereotype.Service;5import org.springframework.web.client.RestClient;6 7import java.util.List;8import java.util.Map;9 10@Service11public class EmbeddingService {12 13 private final RestClient http = RestClient.create("https://api.openai.com/v1");14 15 @Value("${openai.api.key}")16 private String apiKey;17 18 public float[] embed(String texto) {19 var body = Map.of(20 "model", "text-embedding-3-small",21 "input", texto22 );23 24 Response respuesta = http.post()25 .uri("/embeddings")26 .header("Authorization", "Bearer " + apiKey)27 .body(body)28 .retrieve()29 .body(Response.class);30 31 return respuesta.data().get(0).embedding();32 }33 34 // DTOs internos para mapear la respuesta JSON de OpenAI35 public record Response(List<Item> data) {}36 public record Item(float[] embedding) {}37}4. Repository: insertar y buscar con pgvector
1package com.tuapp.embeddings;2 3import org.springframework.jdbc.core.JdbcTemplate;4import org.springframework.stereotype.Repository;5 6import java.util.List;7 8@Repository9public class ArticuloRepository {10 11 private final JdbcTemplate jdbc;12 13 public ArticuloRepository(JdbcTemplate jdbc) {14 this.jdbc = jdbc;15 }16 17 public void guardar(String titulo, String contenido, float[] embedding) {18 // pgvector espera el formato literal "[0.1,0.2,...]" como texto19 // y se castea con ::vector dentro del SQL.20 jdbc.update(21 "INSERT INTO articulos (titulo, contenido, embedding) " +22 "VALUES (?, ?, ?::vector)",23 titulo, contenido, toVectorLiteral(embedding)24 );25 }26 27 public List<ResultadoBusqueda> buscarParecidos(float[] consulta, int topK) {28 String vector = toVectorLiteral(consulta);29 30 // <=> es el operador de distancia coseno de pgvector.31 // 1 - distancia = similitud (más cerca de 1 = más parecido).32 return jdbc.query("""33 SELECT titulo, contenido,34 1 - (embedding <=> ?::vector) AS similitud35 FROM articulos36 ORDER BY embedding <=> ?::vector37 LIMIT ?38 """,39 (rs, n) -> new ResultadoBusqueda(40 rs.getString("titulo"),41 rs.getString("contenido"),42 rs.getDouble("similitud")43 ),44 vector, vector, topK45 );46 }47 48 private static String toVectorLiteral(float[] v) {49 StringBuilder sb = new StringBuilder("[");50 for (int i = 0; i < v.length; i++) {51 if (i > 0) sb.append(",");52 sb.append(v[i]);53 }54 return sb.append("]").toString();55 }56 57 public record ResultadoBusqueda(String titulo, String contenido, double similitud) {}58}5. Endpoint REST que junta todo
1package com.tuapp.embeddings;2 3import org.springframework.web.bind.annotation.*;4 5import java.util.List;6 7@RestController8@RequestMapping("/api/articulos")9public class BusquedaController {10 11 private final EmbeddingService embeddings;12 private final ArticuloRepository repo;13 14 public BusquedaController(EmbeddingService embeddings, ArticuloRepository repo) {15 this.embeddings = embeddings;16 this.repo = repo;17 }18 19 // GET /api/articulos/buscar?q=devolver dinero20 @GetMapping("/buscar")21 public List<ArticuloRepository.ResultadoBusqueda> buscar(22 @RequestParam String q,23 @RequestParam(defaultValue = "5") int topK) {24 float[] vector = embeddings.embed(q);25 return repo.buscarParecidos(vector, topK);26 }27 28 // POST /api/articulos (para indexar contenido nuevo)29 @PostMapping30 public void indexar(@RequestBody NuevoArticulo nuevo) {31 float[] vector = embeddings.embed(nuevo.contenido());32 repo.guardar(nuevo.titulo(), nuevo.contenido(), vector);33 }34 35 public record NuevoArticulo(String titulo, String contenido) {}36}Alternativa moderna: Spring AI
Spring AI
es el módulo oficial que abstrae todo esto: tienes EmbeddingClient
y VectorStore con implementación PgVector
incluida. Cero RestClient a mano,
cero toVectorLiteral. Si tu Spring Boot es 3.2+, vale la pena.
Pero entender cómo se hace por debajo (lo de arriba) sigue siendo importante.
¿Quieres ver el ejemplo completo aplicado a RAG?
En el anexo RAG con Supabase
tienes el script completo de indexado, la función SQL match_documents
y la integración con OpenAI para generar respuestas citadas (versión Node, fácilmente portable a Spring AI).
8 Limitaciones (porque ningún modelo es perfecto)
No "razonan", solo comparan
Si tu pregunta es "qué cuesta MÁS, el envío estándar o el express", los embeddings te traen artículos que hablan de costes de envío, pero no calculan la respuesta. Eso lo hace un LLM después.
Negaciones y opuestos confunden
"Producto en stock" y "producto sin stock" tienen embeddings muy parecidos: hablan del mismo tema. La búsqueda semántica los devuelve como si fueran lo mismo, aunque significan lo contrario. Para esto sigue haciendo falta lógica de negocio o un LLM que filtre.
El chunking importa más de lo que crees
Si un documento tiene 50 páginas, tienes que partirlo en trozos (chunks) ANTES de calcular embeddings. Trozos demasiado grandes pierden precisión, trozos demasiado pequeños pierden contexto. La regla empírica: ~500 tokens con 50-100 de solapamiento. Aquí cada caso es un mundo.
No es gratis (con APIs comerciales)
Cada embedding cuesta. Para 100.000 chunks de 500 tokens, son unos 50 millones de tokens — con
text-embedding-3-small sale por unos
$1. Razonable, pero hay que tenerlo en cuenta y cachear: no recalcules embeddings que ya tienes.
9 Resumen
LIKE no encontraría jamás.
openai.embeddings.create() + pgvector. Cuatro líneas.
10 En una frase
"Un embedding es una huella numérica del significado: si dos huellas se parecen, los textos hablan de lo mismo."
Una vez interiorizas esto, el resto (RAG, recomendaciones, búsqueda semántica) es solo cuestión de en qué lo quieres aplicar.