Embeddings y recuperación: darle al modelo lo que no sabe
33 min de lectura
Tres lecciones han cambiado cómo responde un modelo de pesos fijos sin tocar lo que sabe. La especificación le quitó las lecturas que no querías, la cadena de pensamiento le dio pasadas para decidir y la generación restringida le impuso la forma. Lo que sabe es lo que el entrenamiento dejó en , y eso tiene fecha y límites: el contrato que firmaste ayer, o el manual interno de tu empresa, no estaban en ningún texto que leyera. Pero hay una puerta por la que entra texto nuevo en cada llamada: el prompt. Un modelo que sabe leer puede contestar con lo que lee ahí.
Construir alrededor de esa puerta se llama generación aumentada por recuperación (retrieval-augmented generation, RAG), y lo difícil no es escribir en el prompt, sino elegir qué. Un corpus de verdad tiene miles de pasajes, la ventana de contexto admite unos pocos, y darle cada uno al modelo para que juzgue si sirve costaría una llamada por pasaje. El harness tiene que decidir sin el modelo, con algo barato, qué pasaje responde a qué pregunta. El curso anterior dejó un instrumento para eso, un vector por texto y el coseno entre dos; falta saber de dónde sale el vector.
Sigue al harness paso a paso. Antes de que llegue ninguna pregunta, parte el corpus en pasajes, calcula el vector de cada uno y lo guarda. Cuando llega una, calcula el suyo, lo compara con todos los guardados y se queda con los pocos más cercanos, que van al prompt. Del resto del corpus, el modelo no sabe nada.
Lo delicado es qué quiere decir «cercano». Toma la pregunta ¿Qué edad tiene la Nela? y dos frases seguidas del tercer capítulo de Marianela. En la primera, Golfín le pregunta a la Nela qué edad tiene; en la segunda, ella contesta: Dicen que tengo diez y seis años. La primera se parece mucho más a tu pregunta, porque es la misma pregunta. La que la responde es la segunda, y de tu pregunta sólo repite el nombre. Parecerse y responder son cosas distintas, y un vector sólo mide lo primero.
Recuperar es quedarse con los pasajes más cercanos
Partamos el corpus en pasajes, trozos que el harness copia enteros en el prompt (en las celdas, una frase de la novela cada uno), y supongamos una función que le da un vector a cualquier texto. A ese vector lo llamaremos el embedding del texto: el del curso anterior era una fila de la tabla, uno por entrada del vocabulario, y éste es uno por texto entero. Sea el del pasaje y el de la pregunta. Esta no es la consulta de una cabeza de atención ni la distribución del muestreo, que no salen en esta lección.
Compararemos dos vectores con la similitud coseno de la lección sobre TF-IDF del curso anterior,
que no cambia al multiplicar un vector por un número positivo: compara direcciones, no longitudes. La recuperación de pasajes los ordena por , de mayor a menor, y se queda con los primeros. Es el corte de top-k de la lección sobre el muestreo sobre otra lista, pasajes por coseno en lugar de entradas por probabilidad, y por eso la letra es la misma.
Como pieza del harness, el recuperador promete poco. Calcula los una vez por corpus, y una pregunta le cuesta un vector y un producto escalar por pasaje, sin llamar al modelo. Devuelve pasajes, ordenados. Y devuelve aunque ninguno responda: el corte no tiene forma de decir que no hay nada, y lo que traiga irá al prompt igual. El desafío del final le da esa forma.
Qué significa «cerca» depende de quién hizo el vector
Sin entrenar nada, hay dos sitios de donde sacar los vectores. El primero es TF-IDF (term frequency–inverse document frequency), de la misma lección que el coseno: una coordenada por palabra del corpus, que vale cuántas veces sale la palabra en el texto por el logaritmo de lo rara que es entre los pasajes. Dos textos quedan cerca cuando comparten palabras raras, y sólo entonces: edad y años son dos coordenadas distintas, tan lejos una de otra como dos palabras cualesquiera, igual que en la codificación one-hot de la que sale.
El segundo es el propio mini-GPT. En cada posición, su última capa deja un vector de 64 números, el que la tabla multiplica para dar los logits; nombrado por lo que leyó, es el de la posición que ha leído los primeros tokens de un texto . Es la representación contextual de la lección sobre BERT y GPT, en un modelo causal. Su media sobre las posiciones,
es un embedding que sale gratis de un modelo ya entrenado, la misma lectura de un texto entero que hacía el modelo de recompensa con una respuesta.
La media de un modelo causal oye más el principio que el final
Esa media hereda una asimetría de la máscara causal. El vector depende de y de nada más, así que el token entra en los sumandos con , que son de los . El primer token entra en todos; el último, en uno. En español eso tiene una consecuencia que la segunda celda mide, porque una pregunta empieza por un signo: en una de doce tokens, la media oye el ¿ doce veces y el ? una.
Un modelo bidireccional no tiene esa asimetría: con la tercera firma de aquella lección, el vector de cada posición ha leído la frase entera, y cada token entra en todos los sumandos. Por eso los primeros recuperadores entrenados eran encoders bidireccionales como BERT (Bidirectional Encoder Representations from Transformers), y quien saca embeddings de un modelo causal pondera más las últimas posiciones, las únicas que han leído el texto entero.
Un vector entrenado para que cerca sea responder
Nadie entrenó ninguno de los dos para recuperar. Un recuperador entrenado aprende sus vectores de pares de pregunta y pasaje que la responde, para acercar cada par. Con un batch de pares, numerando los pasajes de modo que sea el de la pregunta , la pérdida de esa pregunta es
la entropía cruzada de siempre, con los pasajes del batch como vocabulario, los cosenos como logits y como la temperatura del muestreo. Los otros pasajes, que responden a otras preguntas, hacen de pasajes equivocados sin que nadie los escriba. Con esa pérdida, «cerca» significa «responde» por construcción, y una pregunta con edad acaba cerca de un pasaje con años si el entrenamiento los ha visto emparejados. Karpukhin y sus coautores entrenaron así, con el producto escalar en lugar del coseno, un recuperador que trae el pasaje entre los veinte primeros de 9 a 19 puntos más a menudo que BM25 (Best Matching 25), la variante de TF-IDF de los buscadores. Ésa es la versión de producción; aquí mides los dos que tienes.
Lo que el modelo lee y lo que sabe
Con un recuperador delante, una respuesta sale de uno de dos sitios. Si el pasaje que responde está entre los , el modelo puede leerlo; si no, le queda lo que sabe, más pasajes que hablan de otra cosa. Llamemos recuperada a una pregunta del primer tipo, y escribamos para la proporción, entre las preguntas que recibe el harness, de las que cumplen algo, con el sorteo del modelo dentro. La probabilidad total separa los dos casos:
Cada factor es de alguien. es del recuperador, y la mides sin llamar al modelo, con preguntas cuyo pasaje conoces: es lo que hace la segunda celda. es lo bien que lee el modelo, y , lo que sabe; ahí recuperar no suma, y puede restar si el modelo se fía de pasajes que hablan de otra cosa. Lo que la recuperación aporta va multiplicado por : un recuperador que no trae el pasaje deja al modelo con lo que sabía, lea lo bien que lea.
Lo leído y lo sabido difieren en más cosas. Lo leído lo cambias editando el corpus, sin tocar , y el harness sabe qué pasajes puso, así que puede citarlos; pero cada pasaje lo pagas en tokens en cada llamada, al precio de la lección sobre la caché. Lo sabido no cuesta nada por llamada, pero cambiarlo es entrenar, y nadie puede señalar de qué texto salió. Y un pasaje no es verdad por estar en el prompt: el modelo lee lo que el harness le da.
TF-IDF y el mini-GPT, sobre la novela
Las celdas corren sobre tu mini-GPT del bloque 1, pero no le piden que escriba nada: usan su tokenizador y los vectores de su última capa. Responder, con pasaje o sin él, no sabría. Los pasajes son las frases de los tres primeros capítulos de Marianela que tienen al menos seis palabras y caben en su ventana de 64 tokens. La primera celda construye los dos recuperadores y les hace la misma pregunta dos veces, con dos palabras distintas.
from collections import Counter
from pyodide.http import open_url
exec(open_url("/courses/llm-agents/modelo.py").read()) # red, F, codificar, quitar_cabecera
texto = quitar_cabecera(open_url("/courses/llm-agents/corpus.txt").read())
lineas = [l for l in texto.split("\n") if l.strip()]
tres = lineas[:lineas.index("La familia de piedra")] # los tres primeros capítulos
frases = [f for l in tres for f in re.split(r"(?<=[.?!…])(?<! D\.)\s+", l)]
PASAJES = [f for f in frases if len(f.split()) >= 6 and len(codificar(f, F)) <= 64]
def palabras(t):
return re.findall(r"\w+", t.lower()) # sin signos: ni ¿ ni ? ni guiones
DF = Counter(w for p in PASAJES for w in set(palabras(p))) # en cuántos pasajes sale
COL = {w: i for i, w in enumerate(DF)} # una coordenada por palabra
def tfidf(t):
v = np.zeros(len(COL))
for w, n in Counter(palabras(t)).items():
if w in COL: # una palabra nueva no tiene columna
v[COL[w]] = n * math.log(len(PASAJES) / DF[w])
return v
def media(t):
"""La media de los vectores de la última capa del mini-GPT, uno por posición."""
_, (_, _, _, H) = red.adelante(np.array([codificar(t, F)]))
return H[0].mean(axis=0)
def cosenos(q, D):
return D @ q / (np.linalg.norm(D, axis=1) * np.linalg.norm(q) + 1e-12)
def recuperar(q, D, k):
"""Los índices de los k pasajes de mayor coseno con q, de mayor a menor."""
return np.argsort(-cosenos(q, D), kind="stable")[:k]
D_tfidf = np.stack([tfidf(p) for p in PASAJES]) # (pasajes, palabras)
D_media = np.stack([media(p) for p in PASAJES]) # (pasajes, 64)
print("%d pasajes, %d palabras distintas\n" % (len(PASAJES), len(COL)))
for x in ["¿Cuántos años tiene la Nela?", "¿Qué edad tiene la Nela?"]:
print(x)
for nombre, D, emb in [("TF-IDF", D_tfidf, tfidf), ("mini-GPT", D_media, media)]:
for i in recuperar(emb(x), D, 2):
print(" %-8s %s" % (nombre, PASAJES[i][:64]))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Con años, TF-IDF trae primero el pasaje que responde. Con edad, trae una frase que sólo comparte tiene y qué con la pregunta y, detrás, la de Golfín, que se le parece y no la responde. El mini-GPT trae preguntas las dos veces, y ninguna habla de la edad.
Dos preguntas no son una medida. La segunda celda hace doce, cada una con un trozo del pasaje que la responde, y apunta en qué puesto llega ese pasaje con cada recuperador. Después le quita a cada pregunta el ¿ o el ?, para ver qué oye el mini-GPT, y mide cuánto ocupan los pasajes.
ORO = { # cada pregunta, con un trozo del pasaje que la responde
"¿Cómo se llamaba la madre de la Nela?": "la señá María Canela",
"¿Quién es Carlos Golfín?": "Yo soy hermano de Carlos Golfín",
"¿Cuántos años tiene la Nela?": "diez y seis años",
"¿Qué edad tiene la Nela?": "diez y seis años",
"¿Dónde vive Francisco Penáguilas?": "hijo único de D. Francisco",
"¿Quién es el padre de Pablo?": "hijo único de D. Francisco",
"¿Qué mineral se agotó en la Terrible?": "el mineral de calamina",
"¿Quién es el lazarillo de Pablo?": "es mi lazarillo",
"¿Es Pablo ciego de nacimiento?": "Sí, señor, de nacimiento",
"¿A qué estación llegó Teodoro?": "la estación de Villamojada",
"¿Con quién vive la Nela?": "Con el señor Centeno",
"¿Quién toca el piano?": "la señorita Sofía, que toca",
}
def puesto(x, D, emb, trozo):
"""El puesto, contando desde 1, del primer pasaje que contiene el trozo."""
orden = list(recuperar(emb(x), D, len(PASAJES)))
return min(orden.index(i) for i, p in enumerate(PASAJES) if trozo in p) + 1
for nombre, D, emb in [("TF-IDF", D_tfidf, tfidf), ("mini-GPT", D_media, media)]:
p = [puesto(x, D, emb, trozo) for x, trozo in ORO.items()]
print("%-8s puestos %s\n primero: %d de 12, entre los 3 primeros: %d de 12"
% (nombre, p, sum(n == 1 for n in p), sum(n <= 3 for n in p)))
pregunta = np.array([p.lstrip("—").startswith("¿") for p in PASAJES])
print("\nde los %d pasajes, %d son preguntas" % (len(PASAJES), pregunta.sum()))
for cambio, f in [("tal cual", lambda x: x), ("sin el ¿", lambda x: x[1:]),
("sin el ?", lambda x: x[:-1])]:
n = sum(pregunta[recuperar(media(f(x)), D_media, 10)].sum() for x in ORO)
print("mini-GPT, %-8s: %3d de sus 120 vecinos son preguntas" % (cambio, n))
tokens = [len(codificar(p, F)) for p in PASAJES]
print("\nun pasaje ocupa de media %.1f tokens; tres, %.0f de los %d de la ventana"
% (np.mean(tokens), 3 * np.mean(tokens), red.cfg["T_ctx"]))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
TF-IDF trae el pasaje el primero en seis de las doce y entre los tres primeros en nueve. Falla en tres, por dos motivos: edad frente a años, y dos pasajes que sólo responden leídos con la pregunta que los precede en la novela (Con el señor Centeno, capataz de ganado en las minas. no dice quién vive con él). El mini-GPT no trae ninguno entre los veinte primeros, y el pasaje de la edad lo deja entre los cuatro últimos de 288 para las dos preguntas. Cuánto vale un número sacado de doce casos lo trata más adelante la lección sobre evaluar.
Las tres líneas siguientes dicen por qué. Sólo 20 de los 288 pasajes son preguntas, y con las doce tal cual, 100 de los 120 vecinos que trae el mini-GPT lo son. Sin el ? del final quedan 97; sin el ¿ del principio, 10, más o menos lo que daría el azar. Es la cuenta de la máscara causal: el ¿ entra en todos los sumandos de la media, el ? en uno. El vector del mini-GPT dice antes que nada que el texto es una pregunta, y el coseno lo oye. Nadie lo entrenó para otra cosa: su pérdida le pidió el token siguiente, y para eso saber que está dentro de una pregunta sirve mucho. En esta novela, y con lo que cabe en el navegador, el recuperador honrado es TF-IDF; el de producción es un modelo entrenado con la pérdida de arriba.
La última línea es el precio. Un pasaje ocupa 32.8 tokens de media, así que tres, 98, no caben en la ventana del mini-GPT ni sin la pregunta. Con una ventana de miles de tokens sí caben, pero los pagas en cada llamada.
Comprueba tu intuición
Cuatro preguntas: los dos sitios de los que sale una respuesta, el signo ¿, los fallos de TF-IDF y una edición nueva de la novela. Después, un recuperador que sabe no traer nada.
Un recuperador trae el pasaje que responde en el 80 % de las preguntas que recibe el harness. Con el pasaje en el prompt, el modelo acierta el 90 % de las veces; sin él, el 30 %, con lo que sabe. ¿Qué proporción de las preguntas acierta el harness? Dos decimales.
Se acepta un margen de ±0.005.
Sin el ¿ del principio, casi ninguno de los vecinos que el mini-GPT trae para las doce preguntas es una pregunta; sin el ? del final, casi todos lo siguen siendo. ¿Por qué?
TF-IDF no trae entre los tres primeros el pasaje que responde a tres de las doce preguntas de la celda. ¿Qué lo explica en esos casos?
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Una edición nueva de la novela cambia una frase: ahora la Nela dice que tiene diecisiete años. ¿Qué hace falta para que el harness responda diecisiete cuando le pregunten cuántos años tiene?
El recuperador de la lección devuelve siempre pasajes, aunque ninguno se parezca a la pregunta. Implementa recuperar(q, D, k, minimo=0.0), que devuelve los índices de, como mucho, k filas de D (una por pasaje), de mayor a menor coseno con q, y sólo las de coseno estrictamente mayor que minimo. A igual coseno va antes el índice menor. Una fila nula no tiene dirección: cuenta como coseno 0, sin dividir por cero. Si ninguna pasa, la lista vacía.
La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.
Recuperar decide qué lee el modelo, y en el mejor caso le trae lo que no sabía. Pero cada pasaje que trae ocupa un sitio que también quieren el prompt de sistema, los ejemplos, la conversación y la respuesta que queda por escribir, y la ventana no crece porque haga falta. Meter más tampoco sale gratis, porque cada token que entra encarece todos los que el modelo escribe detrás.
La lección siguiente, sobre la ventana de contexto, la trata como un presupuesto: cuánto cuesta cada token según cuántos tiene delante, qué hacer cuando no cabe todo y si al modelo le da igual el sitio que ocupa cada pasaje.
Para profundizar4 fuentes · 4 papers
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
El artículo que da nombre a RAG. Llama memoria paramétrica a lo que el modelo sabe y no paramétrica a lo que lee, y compara un modelo con las dos frente a uno que sólo tiene la primera.
- Dense Passage Retrieval for Open-Domain Question Answering
El recuperador entrenado de la lección: pares de pregunta y pasaje, los demás pasajes del batch como los equivocados y el producto escalar como puntuación. Trae el pasaje entre los veinte primeros de 9 a 19 puntos más a menudo que BM25.
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks
Su introducción encuentra en BERT lo que la celda encuentra en el mini-GPT: la media de los vectores de la última capa da malos embeddings de frase, a menudo peores que promediar los de GloVe. El remedio es entrenarlos para comparar.
- SGPT: GPT Sentence Embeddings for Semantic Search
Saca embeddings de modelos causales partiendo de la cuenta de la lección: sólo la última posición ha leído todo el texto. Pondera cada posición por su índice antes de la media y entrena con la pérdida contrastiva con coseno.