Capítulo 14 de 22 10 secciones 14 min

Compartir

Buscar por significado entre un millón de trozos

Del coseno a fuerza bruta al índice que mira un rincón. Y por qué juntar dos búsquedas a veces empeora.

Una base vectorial guarda cada trozo de texto como una lista de números y busca comparando ángulos en vez de palabras. Comparar contra todos es exacto y no escala, así que se agrupa y se mira solo el grupo más cercano. Acá los 20 trozos pasan de 20 comparaciones a 4,8 sin perder aciertos, y la búsqueda híbrida, que debería ayudar, sale peor que el vector solo 🔍

El capítulo 13 terminó con cada palabra convertida en cinco números. Bien. Ahora la pregunta que queda colgando y que nadie contesta: ¿qué haces con eso cuando no son 41 palabras sino un millón de párrafos de tus contratos? 🔤

Ese es el trabajo de una base de datos vectorial, y es el motor de cualquier sistema que diga "responde con tus documentos". Vamos a construir una chiquita y a medirla, incluida la parte donde sale mal.

Primero, partir el texto

No se guarda el documento entero: se parte en trozos, porque lo que se compara después es cada trozo por separado. Acá el manual de atención de una tienda, una regla por línea, que es el caso fácil:

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD

MANUAL = '''El plazo de entrega en Lima es de 48 horas desde confirmado el pedido.
Para provincia el plazo de entrega es de 5 dias habiles.
El cliente puede anular el pedido sin costo dentro de las 2 horas siguientes.
Pasadas las 2 horas la anulacion tiene un cargo del 10 por ciento.
La garantia de los productos electronicos es de 12 meses.
La garantia no cubre danio por mal uso ni por humedad.
Para hacer efectiva la garantia se necesita la boleta o factura original.
El cambio por talla se acepta dentro de los 15 dias, con la prenda sin uso.
No se aceptan cambios de ropa interior ni de trajes de banio.
La devolucion del dinero se hace por el mismo medio de pago.
La devolucion del dinero demora entre 7 y 10 dias habiles.
El codigo SKU-4471 corresponde al modelo descontinuado del ventilador.
Los pedidos mayores a 400 soles tienen envio gratuito a Lima.
El horario de atencion del canal telefonico es de 9 a 18 horas.
Los reclamos se registran en el libro de reclamaciones virtual.
El plazo legal para responder un reclamo es de 30 dias calendario.
Las facturas se emiten solo si el cliente entrega su RUC al comprar.
Una boleta no se puede convertir en factura despues de emitida.
El descuento por volumen aplica desde 50 unidades del mismo producto.
El descuento por volumen es del 12 por ciento y no se acumula con otros.'''

trozos = [t.strip() for t in MANUAL.split('\n') if t.strip()]
print('trozos:', len(trozos))
print('el primero:', trozos[0])
trozos: 20
el primero: El plazo de entrega en Lima es de 48 horas desde confirmado el pedido.

Fíjate en dos parejas que puse a propósito: los dos trozos de anulación y los dos de devolución. Cada uno solo cuenta media verdad, y esa es la trampa del troceo: si el corte separa la regla de su excepción, el sistema puede encontrar una y contestar sin la otra 📋

De trozos a vectores

Mismo truco del capítulo 13: contar palabras y comprimir con SVD. En producción esto lo hace una red entrenada, que entiende bastante más; acá basta para ver el mecanismo, y tiene la ventaja de que corre en tu máquina sin descargar nada.

vec = TfidfVectorizer()
X = vec.fit_transform(trozos)
svd = TruncatedSVD(n_components=8, random_state=0)
E = svd.fit_transform(X)
E = E / (np.linalg.norm(E, axis=1, keepdims=True) + 1e-12)   # todos de largo 1
print('palabras distintas :', X.shape[1])
print('numeros por trozo  :', E.shape[1])
palabras distintas : 123
numeros por trozo  : 8

De 123 columnas a 8. Eso es lo que hace que la búsqueda sea rápida y también lo que hace que a veces se equivoque: al comprimir se pierde algo.

Buscar es comparar ángulos

Los vectores están normalizados a largo 1, así que el producto punto entre dos es directamente el coseno del ángulo: 1 es misma dirección, 0 es nada que ver.

def busca(pregunta, k=3):
    q = svd.transform(vec.transform([pregunta]))
    q = q / (np.linalg.norm(q) + 1e-12)
    puntajes = (E @ q.T).ravel()
    return [(round(float(puntajes[i]), 3), trozos[i])
            for i in np.argsort(-puntajes)[:k]]

for p in ['cuanto demora en llegar a provincia', 'me devuelven la plata?']:
    print('pregunta:', p)
    for s, t in busca(p):
        print(f'  {s}  {t}')
    print()
pregunta: cuanto demora en llegar a provincia
  0.695  Para provincia el plazo de entrega es de 5 dias habiles.
  0.65  La devolucion del dinero demora entre 7 y 10 dias habiles.
  0.565  La devolucion del dinero se hace por el mismo medio de pago.

pregunta: me devuelven la plata?
  0.759  Para hacer efectiva la garantia se necesita la boleta o factura original.
  0.667  La devolucion del dinero demora entre 7 y 10 dias habiles.
  0.61  El cambio por talla se acepta dentro de los 15 dias, con la prenda sin uso.

La primera sale perfecta y ninguna palabra de la pregunta está en la respuesta: ni "demora", ni "llegar". Eso es exactamente lo que compras al buscar por significado 🔤

La segunda sale mal, y la dejo publicada porque es la mitad del capítulo. "Me devuelven la plata" tenía que traer la devolución del dinero y trae la garantía, con 0,759 de confianza. El puntaje alto no significa que acertó: significa que de lo que había, eso fue lo más parecido.

Entonces, ¿el vector le gana a buscar por palabra?

Vamos a medirlo en vez de suponerlo. Seis preguntas, cada una con el trozo que debería salir, y las dos búsquedas compitiendo:

palabras = vec.build_analyzer()

def literal(pregunta, k=3):
    ps = set(palabras(pregunta))
    return sorted(((len(ps & set(palabras(t))), t) for t in trozos),
                  reverse=True)[:k]

PRUEBAS = [
    ('cuanto demora en llegar a provincia', 1),
    ('me devuelven la plata?', 10),
    ('SKU-4471', 11),
    ('puedo anular?', 2),
    ('hasta cuando cambio una polo', 7),
    ('cuanto tiempo tengo para reclamar', 15),
]

print(f'{"pregunta":36} {"vector":>7} {"palabra":>8}')
for p, correcto in PRUEBAS:
    v = busca(p, 1)[0][1] == trozos[correcto]
    l = literal(p, 1)[0][1] == trozos[correcto]
    print(f'{p:36} {"si" if v else "NO":>7} {"si" if l else "NO":>8}')
pregunta                              vector  palabra
cuanto demora en llegar a provincia       si       NO
me devuelven la plata?                    NO       NO
SKU-4471                                  si       si
puedo anular?                             si       si
hasta cuando cambio una polo              NO       NO
cuanto tiempo tengo para reclamar         si       NO

Cuatro contra dos. Y mira las dos filas donde los dos fallan: me devuelven la plata y hasta cuando cambio una polo. En la segunda la palabra "polo" no está en ningún trozo del manual, que habla de "prenda" y de "talla". Ningún método inventa lo que no existe en el texto 📋

El SKU lo encuentran los dos, y eso merece una nota honesta: con un modelo de embeddings de verdad, los códigos exactos suelen diluirse y ahí la búsqueda por palabra gana claro. Acá no pasa porque nuestro vector se construye contando palabras, así que por dentro sigue siendo un poco literal.

Traer más y ordenar después

Hasta ahora miramos solo el primer resultado. ¿Y si traemos tres, o cinco?

for k in (1, 3, 5):
    v = sum(trozos[c] in [t for _, t in busca(p, k)] for p, c in PRUEBAS)
    l = sum(trozos[c] in [t for _, t in literal(p, k)] for p, c in PRUEBAS)
    print(f'en los primeros {k}:  vector {v}/6   palabra {l}/6')
en los primeros 1:  vector 4/6   palabra 2/6
en los primeros 3:  vector 5/6   palabra 5/6
en los primeros 5:  vector 6/6   palabra 5/6

Ahí está el hallazgo que cambia cómo se arma un sistema de estos: trayendo cinco, el vector encuentra las seis. La respuesta correcta ya estaba, solo que no arriba.

Eso es lo que justifica un re-ranker: traer 20 o 50 baratos y después reordenarlos con algo más caro y más fino, que puede ser un modelo que mira pregunta y trozo juntos. No busca más: ordena mejor lo que ya se encontró 🔍

La híbrida, que debería ayudar y aquí no

Lo estándar es juntar las dos búsquedas sumando posiciones, algo así como darle a cada trozo puntos por lo alto que quedó en cada lista:

def hibrida(pregunta, k=3):
    rank = {}
    for f in (busca, literal):
        for pos, (_, t) in enumerate(f(pregunta, len(trozos))):
            rank[t] = rank.get(t, 0) + 1 / (10 + pos)
    return sorted(((s, t) for t, s in rank.items()), reverse=True)[:k]

for k in (1, 3):
    h = sum(trozos[c] in [t for _, t in hibrida(p, k)] for p, c in PRUEBAS)
    v = sum(trozos[c] in [t for _, t in busca(p, k)] for p, c in PRUEBAS)
    print(f'en los primeros {k}:  hibrida {h}/6   vector solo {v}/6')
en los primeros 1:  hibrida 3/6   vector solo 4/6
en los primeros 3:  hibrida 5/6   vector solo 5/6

Sale peor arriba del todo: 3 contra 4.

Y tiene explicación, no es ruido. Juntar dos buscadores ayuda cuando los dos son parecidos de buenos y se equivocan en cosas distintas. Acá uno acierta 4 y el otro 2, así que promediar con el flojo arrastra hacia abajo. La híbrida no es gratis: hay que medirla, y si no gana, no va.

Te lo dejo publicado con el número feo a propósito. En las propuestas la búsqueda híbrida se vende como una mejora automática y no lo es 📋

Y ahora el problema de verdad: un millón de trozos

Todo lo anterior compara la pregunta contra los 20 trozos, uno por uno. Con un millón de trozos son un millón de comparaciones por cada pregunta, y ahí se acabó la fiesta.

La salida es agrupar primero y mirar solo el grupo que toca. Es la idea de los índices que usan las bases vectoriales de verdad, con más maña; con k-means se ve el mecanismo entero:

from sklearn.cluster import KMeans

km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(E)
print('tamanio de cada grupo:', [int((km.labels_ == g).sum()) for g in range(4)])

def busca_indice(pregunta, k=3, grupos=1):
    q = svd.transform(vec.transform([pregunta]))
    q = q / (np.linalg.norm(q) + 1e-12)
    cerca = np.argsort(((km.cluster_centers_ - q) ** 2).sum(axis=1))[:grupos]
    cand = np.where(np.isin(km.labels_, cerca))[0]
    puntajes = (E[cand] @ q.T).ravel()
    return len(cand), [trozos[i] for i in cand[np.argsort(-puntajes)[:k]]]

for grupos in (1, 2, 4):
    comp = sum(busca_indice(p, 3, grupos)[0] for p, _ in PRUEBAS) / len(PRUEBAS)
    ok = sum(trozos[c] in busca_indice(p, 3, grupos)[1] for p, c in PRUEBAS)
    print(f'{grupos} grupo(s): {comp:.1f} comparaciones de {len(trozos)}, acierta {ok}/6')
tamanio de cada grupo: [7, 6, 4, 3]
1 grupo(s): 4.8 comparaciones de 20, acierta 5/6
2 grupo(s): 9.7 comparaciones de 20, acierta 5/6
4 grupo(s): 20.0 comparaciones de 20, acierta 5/6

De 20 comparaciones a 4,8, con los mismos aciertos. Mirando los cuatro grupos se vuelve a comparar contra todo, que es la fuerza bruta del principio: el índice no es magia, es elegir cuánto mirar.

Con 20 trozos no se pierde nada y por eso el ejemplo es cómodo. Lo que se paga en serio aparece con volumen: si la respuesta correcta cae en el grupo de al lado, no la vas a ver nunca, porque su grupo ni se abrió. Por eso a esto se le llama búsqueda aproximada, y por eso las bases vectoriales te dejan subir cuántos grupos mirar cuando notas que se te escapan cosas.

Ejercicios

1. La regla partida de su excepción

Pregunta por la anulación de un pedido y mira los dos primeros resultados. ¿Qué problema ves para quien va a contestarle al cliente?

Los dos trozos de anulación dicen cosas opuestas según el momento: sin costo dentro de las 2 horas, con 10% después. Si la búsqueda trae solo uno, la respuesta es falsa aunque el trozo sea correcto.

Es el argumento de fondo para que los trozos se solapen un poco entre ellos, o para pedirle siempre al menos tres al sistema en vez de uno.

2. Sube los números por trozo

Cambia n_components=8 por 4 y por 15, y vuelve a correr la tabla de las seis preguntas. ¿Mejora siempre con más?

Con 4 se comprime demasiado y trozos distintos empiezan a parecerse. Con 15 se guarda casi toda la matriz original y la búsqueda se vuelve más literal, así que se pierde parte de la gracia de encontrar sin compartir palabras.

No hay un número bueno universal: se prueba con tus preguntas, que es exactamente lo que estás haciendo acá.

3. Una pregunta que no tiene respuesta

Pregunta algo que el manual no cubre, como "aceptan pago con Yape". Mira el puntaje del primero.

Devuelve algo igual, porque la búsqueda siempre devuelve lo más parecido. Lo interesante es el número: suele quedar bastante más bajo que el de una pregunta que sí tiene respuesta.

Ese umbral es la defensa práctica contra inventar: si el mejor puntaje no llega a cierto valor, el sistema debería contestar que no lo tiene en vez de armar algo con el trozo más cercano.

4. El error de pasarle una frase suelta

Búscale el parecido a una pregunta pasándola tal cual, sin meterla en una lista.

vec.transform('cuanto demora en llegar a provincia')
ValueError: Iterable over raw text documents expected, string object received.

El vectorizador espera una colección de documentos, no uno. Y una cadena de texto sí es iterable, así que la confusión es razonable: iteraría letra por letra, que no es lo que quieres, y por eso sklearn corta antes con este mensaje.

Es el error más repetido de toda la librería y se arregla con dos corchetes: vec.transform(['cuanto demora...']).

5. El otro error, el de las formas

Multiplica los vectores por la pregunta sin transponerla.

E @ svd.transform(vec.transform(['cuanto demora']))
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 1 is different from 8)

E es de 20 por 8 y la pregunta sale de 1 por 8. Para multiplicarlas, la pregunta tiene que quedar de 8 por 1, que es lo que hace .T.

Lee el mensaje despacio porque siempre dice lo mismo: 1 es distinto de 8. Casi todos los errores de matrices se arreglan mirando las dos formas antes de adivinar.

6. El re-ranker más tonto posible

Trae los 5 primeros por vector y reordénalos por cuántas palabras comparten con la pregunta. ¿Sube de 4 aciertos?

Es la idea del re-ranking con la herramienta más barata que hay. Sabiendo que a 5 el vector acierta 6 de 6, todo lo que haga falta es que el reordenamiento suba la correcta al primer puesto.

Y sirve para ver lo otro: reordenar con algo tan pobre como contar palabras compartidas también puede empeorar, igual que pasó con la híbrida. Mídelo.

Lo que te llevas

  • Una base vectorial guarda trozos como vectores y busca por coseno. Los vectores normalizados hacen que el producto punto sea el parecido.
  • El puntaje alto no significa que acertó: significa que fue lo más parecido de lo que había. Acá 0,759 salió equivocado.
  • Buscar por significado le ganó a buscar por palabra 4 a 2, y con un modelo de embeddings de verdad la diferencia en códigos exactos va al revés.
  • La respuesta correcta suele estar entre las primeras aunque no sea la primera. A 5 aciertos 6 de 6. Por eso existe el re-ranking.
  • La búsqueda híbrida no mejora sola: acá salió 3 contra 4 porque uno de los dos buscadores era bastante peor.
  • El índice cambia exactitud por velocidad. De 20 comparaciones a 4,8 sin perder nada acá, y con volumen sí se pierde lo que cae en el grupo de al lado.
  • Si el corte separa una regla de su excepción, la búsqueda encuentra media verdad y la contesta entera.

Comprueba que se entendió

Comprueba que lo tienes

Mides tu buscador con seis preguntas cuya respuesta conoces. Trayendo un solo resultado acierta 4 de 6, y trayendo cinco acierta 6 de 6. ¿Qué te dice eso del sistema?

  • Que la respuesta correcta casi siempre está entre las primeras, y lo que falla es el orden
  • Que hay que cambiar el modelo de embeddings
  • Que faltan documentos por cargar
  • Que el sistema está bien y 4 de 6 es un buen número

Con esto ya sabes cómo se encuentra el trozo. El capítulo 18 contesta la otra mitad: cuándo te conviene buscar así y cuándo te conviene otra cosa 🐥

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?