Embeddings búsqueda de significado: explicación detallada con ejemplos
1. Descripción general
Cuando le preguntas a un chatbot “¿Me cancelaron el vuelo, se me devuelve el dinero del hotel que reservé?”, el sistema RAG necesita encontrar el párrafo correcto de la política que habla sobre “interrupción del viaje” — aunque la pregunta no contiene palabras que coincidan 100% con el texto original. Este artículo explica el mecanismo de embedding que permite hacer esto: transformar texto en vectores, hacer coincidencias por distancia semántica, y almacenarlos de modo que el LLM aún pueda recuperar el texto original. Al terminar, podrás escribir tu propio pipeline de búsqueda semántica simple en Java, junto con pruebas que demuestren que funciona correctamente.
2. ¿Qué es embedding?
Un modelo de embedding recibe un fragmento de texto y devuelve un vector — una secuencia de números reales, típicamente de varios cientos a varios miles de dimensiones. Este vector no es “palabras clave comprimidas”, sino un punto en un espacio multidimensional donde los fragmentos de texto con significado similar tendrán vectores cercanos entre sí.
public record EmbeddingVector(float[] values) {
public int dimension() {
return values.length;
}
}
EmbeddingVector es solo un wrapper para float[], pero tenerlo te ayuda a evitar pasar por error float[] confundidos entre diferentes modelos — un error muy fácil de cometer cuando el sistema crece.
2.1. Por qué no debes pensar en embedding como un “mapa” con etiquetas claras
En un mapa real, tienes ejes de longitud y latitud, con etiquetas como “Hanoi”, “Da Nang”. Con embedding, cada dimensión individual no tiene etiqueta como “hotel”, “vuelo”, “reembolso”. El significado solo emerge a través del patrón general de todo el vector. Por lo tanto, no puedes “leer” una dimensión para saber qué representa — solo puedes comparar vectores completos con otros vectores.
Esto es importante porque explica por qué no debes truncar arbitrariamente vectores (por ejemplo, guardar solo 100 de 1536 dimensiones para ahorrar memoria) — hacerlo rompe el patrón y reduce drásticamente la precisión de búsqueda.
3. Coincidencia por distancia semántica
Dos vectores “cercanos” generalmente se miden con cosine similarity — cuanto más cerca de 1, más similar es el significado.
public final class VectorMath {
public static double cosineSimilarity(float[] a, float[] b) {
if (a.length != b.length) {
throw new IllegalArgumentException(
"Desajuste de dimensión de vector: %d vs %d".formatted(a.length, b.length));
}
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
- La línea
if (a.length != b.length): previene inmediatamente un error común — comparar vectores de dos modelos diferentes (por ejemplo, uno de 768 dimensiones y otro de 1536) generará un error en tiempo de ejecución difícil de depurar si no lo verificas temprano. dot,normA,normB: calcula el producto punto y la magnitud de cada vector usando la fórmula de cosine estándar.
Nota: dos modelos con el mismo número de dimensiones no significa que sean compatibles. El modelo A y el modelo B ambos generan vectores de 1536 dimensiones, pero su espacio semántico es diferente — compararlos cruzadamente dará resultados sin sentido aunque el código se ejecute sin errores.
4. Simular un modelo de embedding para practicar sin llamar API reales
Para que el ejemplo se ejecute de inmediato, uso un EmbeddingClient simulado basado en la frecuencia de palabras clave dentro de “temas” (travel, password, billing). En la práctica lo reemplazarías con OpenAI, Cohere o un modelo de código abierto, pero la interfaz se mantiene igual.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {
private static final List<String> TRAVEL_WORDS =
List.of("vuelo", "cancelar", "hotel", "viaje", "aeropuerto");
private static final List<String> PASSWORD_WORDS =
List.of("contraseña", "inicio de sesión", "cuenta", "restablecer", "olvido");
private static final List<String> BILLING_WORDS =
List.of("reembolso", "pago", "factura", "costo", "tarifa");
@Override
public EmbeddingVector embed(String text) {
String normalized = text.toLowerCase();
float travel = score(normalized, TRAVEL_WORDS);
float password = score(normalized, PASSWORD_WORDS);
float billing = score(normalized, BILLING_WORDS);
return new EmbeddingVector(new float[] { travel, password, billing });
}
private float score(String text, List<String> keywords) {
long matches = keywords.stream().filter(text::contains).count();
return matches / (float) keywords.size();
}
}
KeywordEmbeddingClient usa solo 3 dimensiones (travel/password/billing) para fácil observación — los embeddings reales usan cientos de dimensiones aprendidas de los datos, pero la idea “texto con significado similar → vectores cercanos” es la misma.
5. Almacenar embedding con metadata para que el LLM recupere el texto original
Los vectores solo ayudan a ubicar el fragmento de texto, pero el LLM aún necesita el contenido real para responder. Por lo tanto, cada registro en el vector store debe vincular el embedding con el chunk original y los metadatos.
public record DocumentChunk(
String chunkId,
String documentId,
String text,
String section,
LocalDate effectiveDate,
int version,
EmbeddingVector embedding
) {}
chunkIdydocumentId: permiten recuperar el documento completo cuando necesites mostrar la fuente o actualizar a una versión más nueva.section,effectiveDate,version: ayudan a filtrar antes de hacer coincidencias semánticas — por ejemplo, buscar solo en políticas vigentes, descartando versiones antiguas expiradas.text: es obligatorio guardarlo también, porque el LLM no puede “leer” vectores — necesita el texto real para generar respuestas.
public class InMemoryVectorStore {
private final List<DocumentChunk> chunks = new ArrayList<>();
public void add(DocumentChunk chunk) {
chunks.add(chunk);
}
public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
return chunks.stream()
.sorted(Comparator.comparingDouble(
(DocumentChunk c) -> -VectorMath.cosineSimilarity(
query.values(), c.embedding().values())))
.limit(topK)
.toList();
}
}
findNearest ordena los chunks por cosine similarity en orden descendente (el signo - invierte el Comparator) y toma los topK resultados más cercanos — este es exactamente el paso “retrieval” en RAG.
6. Ejemplo completo: pregunta diferente en palabras pero correcta en significado
public class SemanticSearchDemo {
public static void main(String[] args) {
EmbeddingClient embedder = new KeywordEmbeddingClient();
InMemoryVectorStore store = new InMemoryVectorStore();
store.add(new DocumentChunk(
"chunk-1", "policy-travel", "Política de reembolso cuando un vuelo se cancela o el viaje se interrumpe.",
"Travel Disruption", LocalDate.of(2026, 1, 1), 2,
embedder.embed("Política de reembolso cuando un vuelo se cancela o el viaje se interrumpe.")
));
store.add(new DocumentChunk(
"chunk-2", "policy-account", "Guía para restablecer contraseña cuando olvida la cuenta de inicio de sesión.",
"Account Recovery", LocalDate.of(2025, 6, 1), 1,
embedder.embed("Guía para restablecer contraseña cuando olvida la cuenta de inicio de sesión.")
));
String question = "¿Me cancelaron el vuelo, se me devuelve el dinero del hotel que reservé?";
EmbeddingVector queryVector = embedder.embed(question);
List<DocumentChunk> results = store.findNearest(queryVector, 1);
System.out.println(results.get(0).text());
}
}
Ejecutar este programa imprime:
Política de reembolso cuando un vuelo se cancela o el viaje se interrumpe.
La pregunta no contiene las palabras “política” o “viaje”, pero como la pregunta y el texto están cerca uno del otro en el espacio semántico (la dimensión “travel” es alta en ambos), el sistema elige el chunk correcto — ignorando completamente el chunk sobre restablecer contraseña.
7. Verificación con pruebas
class SemanticSearchTest {
private final EmbeddingClient embedder = new KeywordEmbeddingClient();
private final InMemoryVectorStore store = new InMemoryVectorStore();
@BeforeEach
void setUp() {
store.add(new DocumentChunk(
"chunk-1", "policy-travel",
"Política de reembolso cuando un vuelo se cancela o el viaje se interrumpe.",
"Travel Disruption", LocalDate.of(2026, 1, 1), 2,
embedder.embed("Política de reembolso cuando un vuelo se cancela o el viaje se interrumpe.")
));
store.add(new DocumentChunk(
"chunk-2", "policy-account",
"Guía para restablecer contraseña cuando olvida la cuenta de inicio de sesión.",
"Account Recovery", LocalDate.of(2025, 6, 1), 1,
embedder.embed("Guía para restablecer contraseña cuando olvida la cuenta de inicio de sesión.")
));
}
@Test
void shouldMatchTravelPolicyEvenWithoutSharedWords() {
String question = "¿Me cancelaron el vuelo, se me devuelve el dinero del hotel que reservé?";
EmbeddingVector queryVector = embedder.embed(question);
List<DocumentChunk> results = store.findNearest(queryVector, 1);
assertThat(results)
.hasSize(1)
.first()
.extracting(DocumentChunk::documentId)
.isEqualTo("policy-travel");
}
@Test
void shouldRejectComparingDifferentDimensions() {
float[] a = { 1f, 0f, 0f };
float[] b = { 1f, 0f };
assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dimension mismatch");
}
}
La primera prueba afirma el comportamiento central: una pregunta con redacción diferente al texto original aún encuentra el chunk correcto. La segunda prueba asegura que no comparas accidentalmente dos vectores incompatibles — este error es muy fácil que ocurra cuando cambias de modelo de embedding y olvidas re-embedar todos los datos antiguos.
8. Errores comunes y cuándo no debes usar embedding
- Cambiar el modelo de embedding sin re-embedar los datos antiguos: los vectores antiguos y nuevos no están en el mismo espacio, la coincidencia dará resultados incorrectos silenciosamente — ninguna excepción te lo notificará.
- Chunks demasiado largos o demasiado cortos: chunks largos hacen que el vector “mezcle” demasiadas ideas, reduciendo la precisión; chunks demasiado cortos pierden contexto. Típicamente 200–500 tokens es un punto de partida razonable, necesitas ajustarlo según el dominio.
- Olvidar guardar metadatos: si solo guardas el vector sin guardar
documentId/version, no podrás eliminar o actualizar correctamente el documento cuando la política cambie. - Usar embedding para datos que necesitan coincidencia exacta: si necesitas encontrar el código de orden exacto
ORD-2026-00123, la búsqueda de texto completo o la indexación por clave exacta sigue siendo más rápida y correcta que embedding — embedding es fuerte en búsqueda por significado, no por cadena de caracteres exacta.
Nota: con datasets pequeños (unos pocos cientos de chunks), el
InMemoryVectorStorede arriba es suficiente. Cuando escalas a millones de chunks, necesitas una base de datos vectorial especializada (pgvector, Pinecone, Milvus…) que soporte indexación aproximada (ANN) para evitar comparar secuencialmente cada vector.
9. Conclusión
Embedding transforma texto en vectores para que las máquinas puedan comparar “significado” en lugar de solo hacer coincidir palabras clave — esa es la base de todo sistema RAG. Los puntos clave que debes recordar: la consulta y el documento deben usar el mismo encoder compatible, cada vector debe ir acompañado de metadatos para recuperar el texto original, y tener el mismo número de dimensiones no garantiza que dos modelos sean compatibles. Desde el ejemplo de Java anterior, puedes reemplazar KeywordEmbeddingClient con un modelo de embedding real (a través de Spring AI o API directo) mientras todo lo demás — cosine similarity, vector store, metadatos — se mantiene igual.