Este es el capítulo que menos código tiene y el que más veces me han preguntado en reuniones 💼
La pregunta llega siempre igual: "queremos un asistente que responda sobre nuestros documentos, ¿hay que entrenar un modelo?". Y la respuesta casi siempre es que no.
Las tres opciones, y qué arregla cada una
| Si el problema es | La herramienta | Cuesta |
|---|---|---|
| El modelo puede hacerlo y no le explicaste bien | Prompt | Minutos, gratis |
| Le falta información que solo tienes tú | RAG | Días, barato |
| Le falta una forma de responder difícil de explicar | Ajuste fino | Semanas, caro |
| Le falta capacidad de razonar | Otro modelo | Cambiar de proveedor |
Fíjate en la primera columna, porque ahí está todo. La pregunta no es qué herramienta es mejor: es qué te falta.
El error que más veo es usar ajuste fino para meter información. Ajustar un modelo con tus documentos no es una forma de que se los aprenda: es una forma de que aprenda a sonar como ellos. Si lo que quieres es que cite tu política de devoluciones correctamente, eso es RAG 📄
RAG, que es lo que casi siempre toca
RAG son tres pasos:
- Buscar los documentos que tengan que ver con la pregunta
- Pegarlos en el prompt
- Y pedirle al modelo que responda usando eso
El paso interesante es el primero, y se puede escribir a mano. Vamos con ocho documentos de una distribuidora:
import numpy as np
DOCS = [
'la politica de devoluciones acepta productos sin abrir hasta 15 dias despues de la entrega',
'el flete es gratis para pedidos mayores a 2000 soles en lima y arequipa',
'los mayoristas tienen 30 dias de credito y las bodegas pagan al contado',
'el horario de atencion es de lunes a viernes de 8 a 6 y sabados de 9 a 1',
'para reclamos por producto roto hay que enviar foto dentro de las 48 horas',
'el descuento por volumen empieza en 10 por ciento a partir de 50 unidades',
'no se aceptan devoluciones de productos refrigerados por politica sanitaria',
'las entregas en cusco y piura demoran 3 dias habiles adicionales',
]
def tokens(t):
return t.lower().split()
vocabulario = sorted({w for d in DOCS for w in tokens(d)})
lugar = {w: i for i, w in enumerate(vocabulario)}
N = len(DOCS)
cuentas = np.zeros((N, len(vocabulario)))
for i, d in enumerate(DOCS):
for w in tokens(d):
cuentas[i, lugar[w]] += 1
en_cuantos = (cuentas > 0).sum(axis=0)
raras = np.log((1 + N) / (1 + en_cuantos)) + 1 # las palabras raras pesan más
X = cuentas * raras
X = X / np.linalg.norm(X, axis=1, keepdims=True)
print('documentos:', N, ' palabras distintas:', len(vocabulario))
print('la palabra más rara pesa', round(float(raras.max()), 3),
'y la más común', round(float(raras.min()), 3))
documentos: 8 palabras distintas: 77 la palabra más rara pesa 2.504 y la más común 1.251
Eso es TF-IDF y es de 1972. La idea: una palabra que sale en todos los documentos no distingue nada, y una que sale en uno solo distingue muchísimo. Por eso "de" pesa poco y "refrigerados" pesa mucho.
def busca(pregunta, k=2):
v = np.zeros(len(vocabulario))
for w in tokens(pregunta):
if w in lugar:
v[lugar[w]] += 1
v = v * raras
if np.linalg.norm(v) == 0:
return []
v = v / np.linalg.norm(v)
parecidos = X @ v
return [(float(parecidos[i]), DOCS[i]) for i in np.argsort(-parecidos)[:k]]
for p in ['cuanto demora una entrega en cusco', 'cuanto credito tienen los mayoristas']:
print('P:', p)
for s, d in busca(p):
print(f' {s:.3f} {d[:66]}')
P: cuanto demora una entrega en cusco 0.320 las entregas en cusco y piura demoran 3 dias habiles adicionales 0.165 la politica de devoluciones acepta productos sin abrir hasta 15 di P: cuanto credito tienen los mayoristas 0.611 los mayoristas tienen 30 dias de credito y las bodegas pagan al co 0.000 la politica de devoluciones acepta productos sin abrir hasta 15 di
Funciona 🎉
La pregunta de Cusco trae el documento de las entregas con 0,320 y la del crédito trae el correcto con 0,611. Eso pegado en un prompt es un asistente que responde sobre tus documentos, sin entrenar nada.
Falla número 1: no encuentra lo que sí está
print('P: puedo devolver un producto refrigerado')
for s, d in busca('puedo devolver un producto refrigerado'):
print(f' {s:.3f} {d[:66]}')
P: puedo devolver un producto refrigerado 0.289 para reclamos por producto roto hay que enviar foto dentro de las 0.000 la politica de devoluciones acepta productos sin abrir hasta 15 di
El documento correcto es el de "no se aceptan devoluciones de productos refrigerados", y saca 0,000. Cero. Ni aparece.
Y trae el de los productos rotos, que no tiene nada que ver 😖
La razón es de una tontez que da rabia: la pregunta dice devolver y el documento dice devoluciones. Dice refrigerado y el documento dice refrigerados. Para un buscador de palabras exactas, son palabras distintas.
Este es el motivo por el que existe la búsqueda por embeddings: con los vectores del capítulo 10, "devolver" y "devoluciones" caerían cerca. Pero mira primero la solución barata:
def raiz(w):
for terminacion in ['ciones', 'cion', 'ados', 'adas', 'ando', 'idos',
'idas', 'ar', 'er', 'ir', 'os', 'as', 'es', 'o', 'a', 's']:
if len(w) > 4 and w.endswith(terminacion):
return w[:-len(terminacion)]
return w
def tokens_raiz(t):
return [raiz(w) for w in t.lower().split()]
vocab2 = sorted({w for d in DOCS for w in tokens_raiz(d)})
lugar2 = {w: i for i, w in enumerate(vocab2)}
cuentas2 = np.zeros((N, len(vocab2)))
for i, d in enumerate(DOCS):
for w in tokens_raiz(d):
cuentas2[i, lugar2[w]] += 1
raras2 = np.log((1 + N) / (1 + (cuentas2 > 0).sum(axis=0))) + 1
X2 = cuentas2 * raras2
X2 = X2 / np.linalg.norm(X2, axis=1, keepdims=True)
def busca_raiz(pregunta, k=2):
v = np.zeros(len(vocab2))
for w in tokens_raiz(pregunta):
if w in lugar2:
v[lugar2[w]] += 1
v = v * raras2
if np.linalg.norm(v) == 0:
return []
parecidos = X2 @ (v / np.linalg.norm(v))
return [(float(parecidos[i]), DOCS[i]) for i in np.argsort(-parecidos)[:k]]
print('P: puedo devolver un producto refrigerado')
for s, d in busca_raiz('puedo devolver un producto refrigerado'):
print(f' {s:.3f} {d[:66]}')
P: puedo devolver un producto refrigerado 0.261 no se aceptan devoluciones de productos refrigerados por politica 0.213 para reclamos por producto roto hay que enviar foto dentro de las
Arreglado: ahora el documento correcto sale primero con 0,261 🎯
Y lo arregló una función de ocho líneas que corta terminaciones, no un modelo de mil millones de parámetros. Antes de traer artillería, prueba con lo barato, que en recuperación de texto suele llegar bastante lejos.
Falla número 2: siempre devuelve algo
print('P: aceptan tarjeta de credito')
for s, d in busca_raiz('aceptan tarjeta de credito'):
print(f' {s:.3f} {d[:66]}')
P: aceptan tarjeta de credito 0.300 no se aceptan devoluciones de productos refrigerados por politica 0.255 los mayoristas tienen 30 dias de credito y las bodegas pagan al co
Esta es la peligrosa de verdad 🚨
En los ocho documentos no hay nada sobre formas de pago. La respuesta correcta es "no lo sé". Y el buscador devuelve, con toda confianza, el documento de las devoluciones de refrigerados con 0,300.
Ahora imagina el sistema completo: eso se pega en el prompt con un "responde usando estos documentos", y el modelo hace lo que sabe hacer, que es escribir algo plausible con lo que tiene delante. El resultado es una respuesta inventada con una fuente al lado, que es peor que no responder.
La solución no es más modelo. Es un umbral:
UMBRAL = 0.35
def responde(pregunta):
encontrados = busca_raiz(pregunta, k=1)
if not encontrados or encontrados[0][0] < UMBRAL:
return 'No tengo ese dato en los documentos.'
return f'Según los documentos: {encontrados[0][1]}'
for p in ['cuanto credito tienen los mayoristas', 'aceptan tarjeta de credito',
'a que hora abren los sabados']:
print(f'P: {p}')
print(f'R: {responde(p)}')
print()
P: cuanto credito tienen los mayoristas R: Según los documentos: los mayoristas tienen 30 dias de credito y las bodegas pagan al contado P: aceptan tarjeta de credito R: No tengo ese dato en los documentos. P: a que hora abren los sabados R: No tengo ese dato en los documentos.
Tres líneas y el sistema aprende a decir que no sabe 🤐
Pero mira la tercera respuesta, que es la que me interesa: "a que hora abren los sabados" también se queda sin contestar, y ese dato sí está en los documentos.
O sea que el umbral acaba de cambiar un error por otro: antes inventaba, ahora se calla cuando sabía. No hay forma de tener las dos cosas, y elegir dónde ponerlo es una decisión de negocio, no técnica.
Es la misma idea del capítulo 13 del libro de machine learning, la predicción conforme: un sistema que sabe cuándo callarse vale más que uno que siempre contesta, y por eso yo prefiero perder una respuesta a inventar una.
Y el umbral se elige como todo en este libro: con ejemplos. Juntas treinta preguntas de las que tu equipo recibe, marcas cuáles tienen respuesta en los documentos y cuáles no, y buscas el corte. Igualito que el capítulo 9 del libro anterior.
Cuándo sí toca ajustar el modelo
Para que no parezca que estoy en contra. El ajuste fino sirve, y sirve para tres cosas concretas:
- ✍️ Una forma de escribir muy particular que no se puede explicar en un prompt pero sí enseñar con doscientos ejemplos.
- 🏷️ Una tarea muy repetida y muy acotada, donde ajustar un modelo chiquito sale mucho más barato por consulta que llamar a uno grande.
- 🗣️ Un vocabulario propio que el modelo no maneja: jerga técnica, códigos internos, nombres de producto.
Y no sirve para meter información. Repito eso porque es el error caro: el ajuste fino enseña comportamiento, no hechos. Ajustar con tus documentos hace que el modelo suene como ellos, no que los recuerde 📚
Y sobre el costo, una cosa que cambió bastante: hoy casi nadie ajusta el modelo entero. Se usa LoRA, que en vez de mover los millones de pesos originales le pega unas matrices chiquitas al lado y entrena solo esas.
La idea es la del capítulo 2 de este libro: si una matriz grande se puede aproximar multiplicando dos flacas, entrenas las dos flacas. Con eso un ajuste que pedía varias tarjetas gráficas cabe en una, y el resultado que guardas pesa megas en vez de gigas.
Baja el costo, no cambia el diagnóstico: LoRA barato sigue siendo ajuste fino, y sigue sin servir para meter información 🧵
Ejercicios
1. Qué palabras pesan más
Mira el peso que TF-IDF le da a cada palabra.
pesos = sorted(zip(raras, vocabulario))
print('las que menos distinguen:')
for p, w in pesos[:5]:
print(f' {w:14} {p:.3f}')
print('las que más distinguen:')
for p, w in pesos[-5:]:
print(f' {w:14} {p:.3f}')
las que menos distinguen: de 1.251 y 1.588 a 1.811 dias 1.811 el 1.811 las que más distinguen: soles 2.504 tienen 2.504 unidades 2.504 viernes 2.504 volumen 2.504
Abajo del todo están "de", "a", "y", "el": salen en casi todos los documentos y no sirven para distinguir nada.
Arriba están las palabras que salen en un solo documento. Y fíjate que nadie escribió una lista de palabras vacías: el método las descubre solo, contando 🔢
2. Buscar por trozo en vez de por documento
Los documentos de verdad son largos. Pártelos en pedazos y mira qué cambia.
largo = ('la politica de devoluciones acepta productos sin abrir hasta 15 dias '
'despues de la entrega pero no se aceptan devoluciones de productos '
'refrigerados por politica sanitaria y para reclamos por producto roto '
'hay que enviar foto dentro de las 48 horas')
palabras = largo.split()
trozos = [' '.join(palabras[i:i + 12]) for i in range(0, len(palabras), 12)]
for i, t in enumerate(trozos):
print(f'trozo {i}: {t[:64]}')
print()
print('un documento de', len(palabras), 'palabras se volvió', len(trozos), 'trozos')
trozo 0: la politica de devoluciones acepta productos sin abrir hasta 15 trozo 1: de la entrega pero no se aceptan devoluciones de productos refri trozo 2: politica sanitaria y para reclamos por producto roto hay que env trozo 3: dentro de las 48 horas un documento de 41 palabras se volvió 4 trozos
Partirlo importa muchísimo. Si metes el documento entero, la parte de los refrigerados queda diluida entre todo lo demás y el parecido baja.
Cómo partir es la decisión más práctica de un RAG: trozos chicos son precisos y pierden contexto, trozos grandes tienen contexto y diluyen. Igual que el pooling del capítulo 9, es un compromiso sin respuesta única ✂️
3. Cuántos documentos meter en el prompt
Mira qué trae con k=1, k=3 y k=8.
for k in [1, 3, 8]:
resultados = busca_raiz('cuanto demora una entrega en cusco', k=k)
utiles = sum(1 for s, _ in resultados if s > 0.1)
print(f'k={k}: {len(resultados)} documentos, {utiles} con parecido mayor a 0,1')
k=1: 1 documentos, 1 con parecido mayor a 0,1 k=3: 3 documentos, 3 con parecido mayor a 0,1 k=8: 8 documentos, 4 con parecido mayor a 0,1
Con k=8 traes los ocho documentos y solo cuatro tienen algún parecido. Los otros cuatro son ruido pegado en el prompt.
Y el ruido no es gratis: ocupa contexto (que cuesta plata, capítulo 12) y además distrae al modelo. Yo empiezo con k=3 y lo subo solo si mido que hace falta 📏
4. Elegir el umbral con ejemplos
Arma una lista de preguntas con y sin respuesta y busca el corte.
CON_RESPUESTA = ['cuanto credito tienen los mayoristas',
'cuanto demora una entrega en cusco',
'a que hora abren los sabados',
'desde cuantas unidades hay descuento']
SIN_RESPUESTA = ['aceptan tarjeta de credito',
'tienen sucursal en tacna',
'quien es el gerente general']
for umbral in [0.2, 0.35, 0.5]:
aciertos_con = sum(1 for p in CON_RESPUESTA
if busca_raiz(p, 1) and busca_raiz(p, 1)[0][0] >= umbral)
aciertos_sin = sum(1 for p in SIN_RESPUESTA
if not busca_raiz(p, 1) or busca_raiz(p, 1)[0][0] < umbral)
print(f'umbral {umbral}: contesta bien {aciertos_con}/4 '
f'se calla bien {aciertos_sin}/3')
umbral 0.2: contesta bien 4/4 se calla bien 0/3 umbral 0.35: contesta bien 2/4 se calla bien 3/3 umbral 0.5: contesta bien 2/4 se calla bien 3/3
Con 0,2 contesta las cuatro que sabe y no se calla en ninguna de las tres que no sabe: inventa las tres. Con 0,35 se calla en las tres, y a cambio pierde dos que sí sabía.
No hay ningún umbral que acierte las siete. Es exactamente la matriz de confusión del capítulo 8 del libro de machine learning, con otros nombres, y se elige igual: según cuál de los dos errores te cuesta más caro.
En un asistente que habla con clientes, inventar una política de devoluciones cuesta muchísimo más que decir "consulta con un asesor". Por eso yo me quedaría con 0,35 aunque pierda dos 🎚️
5. La pregunta con palabras que no existen
Pregunta algo cuyas palabras no salen en ningún documento.
print(busca_raiz('hay wifi en el almacen'))
print(responde('hay wifi en el almacen'))
[(0.2168163190237303, 'el flete es gratis para pedidos mayores a 2000 soles en lima y arequipa'), (0.21189158919116474, 'el descuento por volumen empieza en 10 por ciento a partir de 50 unidades')] No tengo ese dato en los documentos.
Ni "wifi" ni "almacen" están en ningún documento, y aun así el buscador devuelve dos cosas con 0,2168 y 0,2119.
¿De dónde sale ese parecido? De "hay", "en" y "el", que sí están. Palabras que no significan nada y que aun así arrastran puntaje 😑
El responde lo salva porque 0,2168 no llega al umbral de 0,35. Y
esa es toda la defensa que tienes: el buscador nunca te va a decir que no
encontró nada, así que la decisión de callarse la tomas tú, con un
número.
6. Lo que cuesta cada opción
Pon los números al lado para la conversación de presupuesto.
opciones = [
('prompt', 0, 'horas', 'cambias el texto y ya'),
('RAG', 0, 'días', 'hay que indexar y mantener los documentos'),
('ajuste fino', 200, 'semanas', 'hacen falta ejemplos etiquetados'),
]
for nombre, ejemplos, tiempo, nota in opciones:
print(f'{nombre:12} {ejemplos:4d} ejemplos {tiempo:8} {nota}')
prompt 0 ejemplos horas cambias el texto y ya RAG 0 ejemplos días hay que indexar y mantener los documentos ajuste fino 200 ejemplos semanas hacen falta ejemplos etiquetados
Esa tabla es la que yo llevo a la reunión donde alguien dice "entrenemos un modelo con nuestros datos".
No para decir que no, sino para que la decisión se tome sabiendo. Casi siempre hay una versión con prompt que se prueba esta semana, y si esa no alcanza, ya se verá 💛
7. El documento vacío que envenena el índice
Mete un documento en blanco al índice y mira qué sale.
vacio = np.zeros((1, len(vocabulario)))
normalizado = vacio / np.linalg.norm(vacio, axis=1, keepdims=True)
print('nan que quedaron:', int(np.isnan(normalizado).sum()), 'de', normalizado.size)
nan que quedaron: 77 de 77
Un documento vacío tiene norma cero, así que normalizarlo es dividir entre
cero. Y numpy no revienta: avisa con un
RuntimeWarning y devuelve nan en todas las casillas.
Y a partir de ahí ese nan contamina todos los parecidos, así que
el buscador deja de funcionar para todas las preguntas por culpa de un
documento en blanco.
Pasa constantemente cuando indexas una carpeta de verdad: un PDF escaneado sin texto, un archivo vacío, una página que solo tiene una imagen. Filtra los documentos sin palabras antes de indexar 🧹
8. El error de cruzar dos índices
Arma la consulta con un vocabulario y búscala en el índice del otro, que es lo que pasa al reindexar y olvidarse de algo.
consulta_vieja = np.zeros(len(vocabulario)) consulta_vieja[lugar['credito']] = 1 X2 @ consulta_vieja
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 77 is different from 75)
El índice con raíces tiene menos columnas que el original, porque al cortar terminaciones varias palabras se juntaron en una. Las formas no cuadran y revienta.
Menos mal. El caso de verdad peligroso es cuando sí cuadran: si añades un documento a la lista y no reconstruyes la matriz, no hay error de ninguna clase y ese documento simplemente no aparece nunca en ningún resultado, para siempre 😶
Por eso el índice y la lista de documentos se construyen juntos, en la misma función, y nunca por separado.
Comprueba que lo tienes
El sistema de RAG no encuentra un documento que sí está en la base. ¿Cuál es el primer sospechoso?
- Cómo se partió el texto en trozos
- El modelo de lenguaje
- El tamaño de la base de documentos
- La temperatura
Lo que te llevas
- 🎯 La pregunta no es qué herramienta es mejor, es qué te falta.
- 📚 Ajustar un modelo con tus documentos hace que suene como ellos, no que los recuerde. Para información, RAG.
- 🔢 TF-IDF es de 1972, se escribe en diez líneas y descubre solo qué palabras no distinguen nada.
- 😖 Falla 1: "devolver" no encuentra "devoluciones", y el documento correcto saca 0,000. Lo arregla una función de ocho líneas que corta terminaciones.
- 🚨 Falla 2: siempre devuelve algo. Para "aceptan tarjeta de credito", que no está en ningún documento, trae uno con 0,300.
- 🤐 Se arregla con un umbral, y el umbral se elige con ejemplos, igual que todo en el libro anterior.
- ✂️ Cómo partir los documentos es la decisión más práctica de un RAG: chico es preciso y pierde contexto, grande diluye.
- 🧹 Y un documento vacío en el índice rompe el buscador entero con un
nan.
En el capítulo 14 juntamos el libro en un proyecto de punta a punta.
Que tengas lindo día! 🌸