Ir al contenido
wannacode

Búsqueda semántica: los k más cercanos

En resumen: La búsqueda semántica compara el vector de la pregunta con los vectores de los textos y devuelve los k más cercanos por coseno, incluso sin ninguna palabra en común.

Problema

Una respuesta no basta

La base de respuestas del soporte ya tiene seis textos:

// la base de respuestas del soporte
const DOCS = [
{ topic: "entrega", text: "la entrega tarda hasta dos días" },
{ topic: "devolver", text: "devuelve el producto en 14 días y recibe el dinero" },
{ topic: "pago", text: "paga con tarjeta o contra reembolso" },
{ topic: "precio", text: "la entrega a una dirección cuesta 5 euros y a una oficina 3 euros" },
{ topic: "rechazo", text: "si el banco rechaza el pago prueba otra tarjeta o paga contra reembolso" },
{ topic: "garantía", text: "la garantía es de dos años a partir de la compra" },
];
// las preguntas de los clientes
const QUESTIONS = [
"cuándo recibo mi paquete",
"no quiero el producto",
"cuánto cuesta la entrega a una oficina",
];

El bot de la lección sobre vectores convierte cada texto en un vector y devuelve solo el más cercano a la pregunta:

Salida
cuándo recibo mi paquete → entrega: la entrega tarda hasta dos días
no quiero el producto → entrega: la entrega tarda hasta dos días
cuánto cuesta la entrega a una oficina → precio: la entrega a una dirección cuesta 5 euros y a una oficina 3 euros

La primera y la tercera pregunta están bien. El segundo cliente quiere devolver el pedido y recibe el plazo de entrega. El texto de la devolución no está lejos: es el segundo, con 0,688 frente a 0,707. Pero el bot mira solo el primero.

La idea

Los k más cercanos

La búsqueda no debe apostarlo todo a un solo texto. Que devuelva los primeros k: ordenados desde el más cercano, junto con la similitud.

Esto es la búsqueda semántica:

  1. Cada texto de la base se convierte en un vector. Los vectores se calculan una vez y se guardan junto a los textos. Esto es el índice.
  2. La pregunta se convierte en un vector del mismo espacio.
  3. El coseno de la pregunta con cada entrada del índice.
  4. Ordenar desde el coseno más grande y quedarse con los primeros k.

Son los vecinos más cercanos de la lección sobre k-NN. Allí los vecinos eran pedidos pasados, aquí son textos. La medida es el coseno, no la distancia.

Los k textos encontrados no son la respuesta. Van al modelo de lenguaje, que los lee y responde a partir de ellos. Ese es el siguiente paso.

Míralo

El índice

Los vectores salen del mismo espacio que en la lección sobre vectores: las ocho palabras más frecuentes del texto de la tienda. El índice es una lista de entradas: el texto y su vector.

// el índice: el vector de cada texto, calculado una vez
function buildIndex(space) {
return DOCS.map((doc) => ({ doc, vector: space.ofText(doc.text) }));
}
const space = new Space(TEXT, STOP, 8);
const index = buildIndex(space);
for (const e of index)
console.log(e.doc.topic.padEnd(10) + `[${e.vector.join(", ")}]`);
Salida
entrega [1, 1, 1, 2, 2, 0, 2, 1]
devolver [4, 1, 0, 3, 1, 1, 3, 1]
pago [0, 0, 0, 0, 0, 2, 0, 0]
precio [0, 2, 2, 1, 12, 0, 1, 0]
rechazo [0, 0, 0, 0, 0, 7, 0, 0]
garantía [0, 0, 0, 0, 0, 0, 0, 0]

«pago» y «rechazo» tienen solo el eje «tarjeta». «garantía» es solo ceros: sus palabras no están en el texto de la tienda (o cuentan como palabras vacías), y el modelo pequeño no sabe de qué trata. Volveremos a esto en «Cuándo duele».

Míralo

Los primeros k

La búsqueda toma el vector de la pregunta, calcula el coseno con cada entrada, ordena desde la más cercana y devuelve las primeras k:

// los primeros k textos más cercanos al vector de la pregunta
function search(index, v, k) {
const hits = [];
for (const e of index)
hits.push({ doc: e.doc, score: cosine(v, e.vector) });
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, k);
}
for (const q of QUESTIONS) {
console.log(q);
const v = space.ofText(q);
for (const h of search(index, v, 3)) {
const topic = h.doc.topic.padEnd(10);
console.log(` ${topic}${h.score.toFixed(3)}`);
}
}
Salida
cuándo recibo mi paquete
entrega 0.722
devolver 0.562
precio 0.186
no quiero el producto
entrega 0.707
devolver 0.688
precio 0.114
cuánto cuesta la entrega a una oficina
precio 0.954
entrega 0.722
devolver 0.328

Para la devolución el primer texto sigue siendo «entrega», pero el segundo es «devolver». Con k = 3 está entre los resultados. Aquí está lo mismo en una imagen. Los vectores tienen 8 dimensiones, así que la imagen conserva solo qué tan lejos está cada texto de la pregunta:

// los primeros k textos más cercanos al vector de la pregunta
function search(index, v, k) {
const hits = [];
for (const e of index)
hits.push({ doc: e.doc, score: cosine(v, e.vector) });
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, k);
}
const v = space.ofText("no quiero el producto");
for (const h of search(index, v, 3)) {
const topic = h.doc.topic.padEnd(10);
console.log(` ${topic}${h.score.toFixed(3)}`);
}
Paso a paso, en palabras
  1. La pregunta «no quiero el producto» se convierte en un vector: [0, 0, 0, 1, 0, 0, 1, 0]. Es el punto del centro.
  2. El coseno con cada entrada del índice. Cuanto mayor es el coseno, más cerca está el punto. Un texto sin ningún eje en común está en el círculo exterior.
  3. Ordenados desde el más cercano: «entrega», «devolver», «precio»…
  4. k = 1: solo «entrega» (0,707). El cliente quiere devolver el producto y recibe el plazo de entrega.
  5. k = 2: entra «devolver» (0,688). El texto correcto está entre los resultados.
  6. k = 3: también «precio» (0,114). Más textos: más probabilidad de que esté el correcto, pero también más ruido.
  7. Los k = 3 más cercanos: «entrega», «devolver», «precio». El texto correcto, «devolver», es el segundo, por eso la búsqueda devuelve k textos, no uno.

Pruébalo

Predice la salida

El cliente escribe «quiero otro color». Ninguna de las palabras está en el texto de la tienda. ¿Qué imprime el programa?

const v = space.ofText("quiero otro color");
for (const h of search(index, v, 3))
console.log(`${h.doc.topic} ${h.score.toFixed(3)}`);
Respuesta

Pruébalo

Completa

La búsqueda tiene que ordenar de la mayor similitud a la menor. ¿Qué va en lugar de ___?

// los primeros k textos más cercanos al vector de la pregunta
function search(index, v, k) {
  const hits = [];
  for (const e of index)
    hits.push({ doc: e.doc, score: cosine(v, e.vector) });
  hits.sort((a, b) => );
  return hits.slice(0, k);
}

Pruébalo

Encuentra el error

Un compañero escribe la búsqueda y para «no quiero el producto» obtiene «pago», «rechazo» y «garantía»: todos textos con similitud 0,000. ¿En qué línea está el error?

Elige la línea con el error.

La idea

De qué tamaño debe ser k

Con k = 1 el bot pierde la devolución. Con k = 3 el texto correcto está, pero llegan otros también: para «cuándo recibo mi paquete» vienen además «devolver» (0,562) y «precio» (0,186), que no responden la pregunta.

  • Una k pequeña pierde el texto correcto cuando no es el primero.
  • Una k grande trae ruido. Cada texto entra en el prompt: son tokens que cuestan dinero y espacio en la ventana de contexto. Y el modelo puede aferrarse al texto equivocado.
  • Un umbral. Un texto por debajo de una similitud fijada no se devuelve en absoluto. Así, una pregunta como «quiero otro color» no recibe tres textos al azar, sino «no estoy seguro».

Los sistemas reales suelen tomar entre 3 y 10 textos, pero eso es solo un ejemplo. Cuántos exactamente se mide con un conjunto de pruebas de preguntas para las que conoces el texto correcto. Eso es evaluar la búsqueda.

La idea

Cuándo duele

  • Cosas exactas. «dónde está el pedido 10234»: el número es una palabra desconocida, y su vector es todo ceros. Los vectores captan el significado, no las cadenas exactas. Un número de pedido, un código de producto o un correo se busca por igualdad en la base de datos. Para nombres y palabras raras ayuda una búsqueda por palabras, y los sistemas reales suelen mezclar las dos. Los buscadores sobre palabras están en la lección sobre búsqueda de texto.
  • Palabras que el modelo no conoce. «garantía» tiene similitud 0,000 con cualquier pregunta y nunca se encontrará. Un modelo real conoce muchas más palabras, pero lo mismo pasa con la jerga propia de una empresa y con los nombres de productos nuevos.
  • Cada pregunta recorre todo el índice. Con seis textos son seis cosenos. Con un millón de textos y vectores de 1024 números cada uno son unos mil millones de multiplicaciones por pregunta (un número de ejemplo). Por eso las bases de datos vectoriales buscan de forma aproximada: revisan una parte pequeña de las entradas y casi siempre encuentran los mismos k. Están en la lección sobre bases de datos vectoriales. Operar y mantener una base así es un trabajo para ZeroToOps.
  • El índice envejece. Si cambias un texto, su vector se calcula de nuevo. Si cambias el modelo de vectores, se calcula de nuevo todo el índice: los vectores de dos modelos no se comparan entre sí.
  • Un documento largo, un solo vector. El vector de una página entera es una mezcla de todos sus temas. Cómo se corta un documento en fragmentos está en la lección siguiente.
Más precisamente, para curiosos

Las herramientas reales. Los vectores salen de un modelo de vectores: se llama a través de una API como los otros modelos, o se ejecuta en local, casi siempre desde Python. Se guardan en una base de datos vectorial (por ejemplo pgvector en PostgreSQL), que tiene un índice para la búsqueda aproximada. En Python el cálculo con vectores suele hacerse con NumPy. La práctica de ML ocurre sobre todo en Python, pero la búsqueda se llama desde cualquier lenguaje: la consulta a la base de datos es la misma. Aquí todo está hecho a mano, para que cada número sea visible.

Ordenar todo o solo los primeros k. Ordenar n entradas cuesta O(n log n). Si k es pequeña, basta un montículo (heap) de k elementos: cada entrada se compara con la más débil que hay en él, y son O(n log k) pasos. El montículo está en la lección sobre la cola de prioridad.

Similitudes iguales. La ordenación es estable en los cuatro lenguajes: los valores iguales conservan el orden que tienen en la base. Por eso «Predice la salida» imprime los tres primeros textos. No te apoyes en este orden para el significado: similitudes iguales quieren decir que el modelo no puede distinguir los textos.

La distancia en la imagen. El punto de cada texto está a √(2 − 2·cos) de la pregunta. Es la distancia entre las puntas de los dos vectores si ambos tienen longitud 1. Ordenar por ella da el mismo orden que ordenar por el coseno.

Ejercicio

Como máximo k, pero solo los cercanos

Resuelve la tarea en tu editor.

Escribe una función que devuelva como máximo k textos, pero solo los que tengan una similitud de al menos 0,5. Si no queda ninguno, devuelve «no estoy seguro, se lo paso a un compañero». Pruébala con k = 3 para «cuándo recibo mi paquete», «no quiero el producto» y «quiero otro color».

Salida
cuándo recibo mi paquete: entrega 0.722, devolver 0.562
no quiero el producto: entrega 0.707, devolver 0.688
quiero otro color: no estoy seguro, se lo paso a un compañero

Las dos primeras preguntas conservan «entrega» y «devolver», y la tercera no conserva nada y pasa a una persona.

Más tareas

  • Un montículo en lugar de una ordenación. Haz la búsqueda con un montículo de k elementos. La salida tiene que ser la misma.
  • La diferencia entre el primero y el segundo. Imprime la diferencia de similitud entre el primer y el segundo texto. Una diferencia pequeña significa que la búsqueda no está segura.

Test

Ponte a prueba

1. ¿Por qué la búsqueda devuelve k textos y no solo el más cercano?
2. ¿Qué guarda el índice?
3. La pregunta no tiene ni una sola palabra que el modelo conozca. ¿Qué devuelve la búsqueda con k = 3?
4. El cliente escribe «dónde está el pedido 10234». ¿Por qué aquí no ayuda la búsqueda semántica?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·