Volver al inicio
Anexo: Embeddings y búsqueda semántica

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

"¿cuánto cuesta el envío?" → [0.12, -0.44, 0.78, ...]
"precio del transporte" → [0.14, -0.41, 0.80, ...] ✅ casi igual
"¿qué es un erizo?" → [-0.89, 0.23, -0.05, ...] ❌ totalmente distinto

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.

dim_1 →
↑ dim_2
"¿cuánto cuesta el envío?"
"precio del transporte"
"tarifa de entrega"
"métodos de pago aceptados"
"¿puedo pagar con tarjeta?"
"el erizo es un mamífero"
"animales nocturnos"
Envío / coste
Pago
Animales
En la realidad el espacio tiene 1536 dimensiones. Aquí lo proyectamos a 2D para que se vea, pero la idea es exactamente esa.

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.

Texto
"¿cuánto cuesta el envío?"
Modelo
text-embedding-3-small
Vector (1536 dim)
[0.12, -0.44, 0.79, 0.01, -0.30, 0.61, -0.06, 0.34, ...]
Lo que ocurre dentro del modelo (simplificado):
  1. El texto se rompe en tokens (palabras o trozos de palabra).
  2. Cada token se procesa por una red neuronal (transformer) que ya ha "leído" miles de millones de textos.
  3. La red combina la información de todos los tokens en un único vector que representa el sentido global.
  4. 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).

≈ 1.0
Casi idénticos

Mismo significado, distinta redacción.

≈ 0.5
Tema relacionado

Comparten un tema o ámbito.

≈ 0.0
Sin relación

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.

AB
ángulo ≈ 10°
Vector A
"envío gratis"
Vector B
"transporte sin coste"
Similitud coseno
0.92
Muy similares
Va de -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?

Consulta del usuario
"quiero que me devuelvan el dinero"
Léxica (LIKE / full-text)

Busca palabras exactas. La query no incluye "devolución" ni "reembolso", así que falla.

"Política de devolución de pedidos"
"Cómo cancelar una compra y recuperar el dinero"
"Devolver un libro a la biblioteca"
"Reembolso por producto defectuoso"
Semántica (embeddings)

Compara significados. "Devolver el dinero" ≈ "reembolso" ≈ "cancelar compra y recuperar dinero".

"Política de devolución de pedidos"
"Cómo cancelar una compra y recuperar el dinero"
"Devolver un libro a la biblioteca"
"Reembolso por producto defectuoso"
esperando consulta...
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.

setup.sqlsql
1-- 1. Activar la extensión pgvector
2create 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 articulos
14 using ivfflat (embedding vector_cosine_ops)
15 with (lists = 100);

Versión JavaScript / Node (Astro)

1. Crear el embedding

embedding.jsjavascript
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

insertar.jsjavascript
1import { createClient } from '@supabase/supabase-js';
2
3const supabase = createClient(
4 process.env.SUPABASE_URL,
5 process.env.SUPABASE_SERVICE_ROLE_KEY
6);
7
8await supabase.from('articulos').insert({
9 titulo: 'Política de devolución',
10 contenido: 'Tienes 14 días para devolver...',
11 embedding: vector
12});

3. Buscar los más parecidos

buscar.jsjavascript
1// 1) Embedding de la pregunta del usuario
2const { 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_articulos
8const { data: resultados } = await supabase.rpc('match_articulos', {
9 query_embedding: emb[0].embedding,
10 match_count: 5
11});
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

pom.xmlxml
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

application.propertiesproperties
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 Settings
5spring.datasource.url=jdbc:postgresql://db.xxxxx.supabase.co:5432/postgres
6spring.datasource.username=postgres
7spring.datasource.password=${SUPABASE_DB_PASSWORD}

3. Servicio que llama a OpenAI

EmbeddingService.javajava
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@Service
11public 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", texto
22 );
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 OpenAI
35 public record Response(List<Item> data) {}
36 public record Item(float[] embedding) {}
37}

4. Repository: insertar y buscar con pgvector

ArticuloRepository.javajava
1package com.tuapp.embeddings;
2
3import org.springframework.jdbc.core.JdbcTemplate;
4import org.springframework.stereotype.Repository;
5
6import java.util.List;
7
8@Repository
9public 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 texto
19 // 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 similitud
35 FROM articulos
36 ORDER BY embedding <=> ?::vector
37 LIMIT ?
38 """,
39 (rs, n) -> new ResultadoBusqueda(
40 rs.getString("titulo"),
41 rs.getString("contenido"),
42 rs.getDouble("similitud")
43 ),
44 vector, vector, topK
45 );
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

BusquedaController.javajava
1package com.tuapp.embeddings;
2
3import org.springframework.web.bind.annotation.*;
4
5import java.util.List;
6
7@RestController
8@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 dinero
20 @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 @PostMapping
30 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

Un embedding es una lista de números que representa el significado de un texto.
Textos con significado parecido tienen vectores cercanos en el espacio.
Para medir cercanía se usa la similitud coseno (-1 lejano, 1 idéntico).
La búsqueda semántica con embeddings encuentra lo que un LIKE no encontraría jamás.
Casos de uso: RAG, recomendaciones, deduplicación, clustering, clasificación, búsqueda inteligente.
En la práctica: openai.embeddings.create() + pgvector. Cuatro líneas.
Limitaciones: no razonan, las negaciones les confunden, hay que cuidar el chunking.

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.