← Ruta Perico
Etapa 13

El RAG completo

Especial Publicada ~1 h · céntimos
13

Todo lo anterior era material. Esto es la aplicación, y cabe en una línea:

el pipeline entero
texto → embedding → recuperar de Supabase → montar el prompt → Claude → salida

Las dos mitades ya existen y ya están medidas: el corpus de la etapa 11 y la recuperación de la etapa 12. Lo que falta son unas sesenta líneas que las unan. Y en esas sesenta líneas hay una idea que no es obvia.

Dos fuentes de estilo, y no hacen lo mismo

Al prompt van dos cosas distintas, y confundirlas es el error natural aquí:

Qué mandaCuándo va
El núcleoCómo habla: cincuenta frases suyasEn todas las llamadas
Lo recuperadoQué dice sobre este asuntoDoce frases, distintas en cada petición

La pregunta es por qué hace falta el núcleo si ya hay recuperación. La respuesta salió medida del experimento de ejes: la recuperación no siempre puede ayudar. El corpus va de ciclismo y de su vida; una petición como «la cena estaba buenísima» no tiene vecinos temáticos ahí dentro, así que la búsqueda devuelve lo menos irrelevante que encuentra, que no es lo mismo que devolver algo útil.

Sin núcleo, esas peticiones llegan a un modelo sin un solo ejemplo delante. Y lo que sale entonces es un español genérico correcto que no reconocería nadie: el registro por defecto de cualquier modelo, no el suyo.

Cómo se elige el núcleo, incluido el primer intento fallido

Cincuenta frases de tres mil quinientas. La primera versión las puntuaba por número de recursos estilísticos: cuantos más lleve una frase, mejor.

Salió mal, y de tres maneras a la vez:

  • Frases de treinta palabras de media. Un relato largo roza cuatro recursos de pasada y gana a una línea corta que es un ejemplo limpio de uno solo.
  • Casi duplicados: la misma frase con distinto arranque, porque las ventanas solapadas de la etapa 11 dejan una versión corta y otra más larga que la contiene.
  • Solo 9 refranes, que son justo el recurso más raro y más reconocible.

Cincuenta frases de treinta palabras son mil quinientas palabras de narración. El modelo no imita el estilo: imita el divagar.

El arreglo es puntuar estilo por palabra en vez de por frase:

pick_core.py
# Proverbs are the rarest device and the most characteristic — the thing a fan
# recognises. Weight them so the core does not fill up with tics, which are
# both commoner and easier for a model to pick up unaided.
DEVICE_WEIGHT = {"proverb": 4, "blunder": 3, "metaphor": 2, "digression": 2, "tic": 1}


def score(row):
    """Style per word, not style per phrase.

    Counting devices alone rewards a long ramble that brushes several of them
    in passing over a short line that is a clean example of one. What the core
    needs is density: the most style in the fewest tokens.
    """
    weight = sum(DEVICE_WEIGHT.get(d, 1) for d in row.get("devices", []))
    confidence = {"high": 2, "medium": 1, "low": 0}.get(row.get("confidence"), 0)
    words = max(len(row["text"].split()), 1)
    return (weight + confidence) / (words ** 0.5)

Dividir por la raíz del número de palabras es lo que convierte «cuántos recursos tiene» en «cuánta densidad tiene». Y hay un suelo por recurso al elegir, para que el núcleo no acabe siendo cincuenta muletillas: los tics son los más abundantes y los más fáciles de imitar sin ayuda, así que si mandan ellos el núcleo enseña un truco cincuenta veces.

Con eso, el núcleo pasó de treinta palabras de media a ocho, de 9 refranes a 23, y sin duplicados. Algunas de las que entraron:

«Tela, tela. Tela marinera.»
«Que Dios me pille confesado.»
«Quedan dos repechos de esos que no puntúan, pero apuntalan.»

Frases del núcleo, las que van en todas las llamadas

Dónde va cada cosa, y por qué importa

El prompt se parte en dos bloques, y el sitio de cada cosa no es cuestión de orden sino de dinero.

translate.py
def build_system(core):
    """System block: identical every call, so it is what gets cached."""
    examples = "\n".join(f"- {phrase}" for phrase in core)
    return [{
        "type": "text",
        "text": f"{STYLE}\n\nAsí hablas tú:\n\n{examples}",
        "cache_control": {"type": "ephemeral"},
    }]


def build_user(text, retrieved):
    """User block: changes every call, so it comes after the cache breakpoint."""
    if not retrieved:
        return f"Texto:\n\n{text}"
    lines = "\n".join(f"- {phrase}" for phrase, _ in retrieved)
    return (f"Cosas que has dicho sobre asuntos parecidos:\n\n{lines}\n\n"
            f"Texto:\n\n{text}")

La caché de prompt guarda un prefijo del prompt y cobra por leerlo una fracción de lo que cuesta mandarlo entero. La condición es que sea idéntico byte a byte: en cuanto una sola palabra cambia antes del punto de corte, la caché no sirve y se paga todo otra vez.

La descripción del estilo y las cincuenta frases del núcleo no cambian nunca. Van en system, que es donde está el punto de corte. Las doce frases recuperadas y el texto del usuario cambian en cada petición, así que tienen que ir después.

Este error no da ningún error

Meter las frases recuperadas en system, que es donde parece que pegan porque son «contexto», invalida la caché en todas las llamadas.

Y no pasa nada visible. No hay excepción, no hay aviso, la app funciona exactamente igual de bien y las salidas son idénticas. Solo cuesta del orden de doce veces más, para siempre.

Se comprueba en una línea: lanzar la misma petición dos veces y mirar usage.cache_read_input_tokens. Medido aquí: 0 en la primera llamada y 1.725 en la segunda. Si la segunda también da 0, hay algo cambiando antes del punto de corte.

Una salida de verdad

Entrada:

terminal
python -m perico.generate.translate "Hoy hace un frío que pela y no me apetece nada salir de casa."

Salida:

«Madre mía, menudo frío pela hoy, ¿eh? De esos que se te meten hasta el tuétano y no hay maillot que valga. Yo hoy no salía ni atado, oye, que las piernas piden manta y sofá, no carretera. Que el que avisa no es traidor: hoy mejor quedarse calentito, que para sufrir frío ya sufrí yo bastante en los puertos, ¡no necesito más!»

Primera salida real del pipeline completo

Merece la pena mirar qué ha hecho ahí, porque no es una sola cosa:

  • Un refrán deformado que se ha inventado él: «no hay maillot que valga», sobre el molde de «no hay tu tía que valga». Ese es el recurso más característico y el más difícil de pedir.
  • Un refrán real usado bien: «el que avisa no es traidor», colocado donde encaja.
  • La metáfora ciclista llegando sola: «para sufrir frío ya sufrí yo bastante en los puertos». El texto de entrada no mencionaba bicicletas.

Y ninguna de las frases del núcleo aparece copiada. Eso es lo que se pedía: que sirvan de ejemplo, no de catálogo.

Epílogo: el núcleo fijo hacía que la app se repitiera

Esta etapa fijó dos cosas que parecían inofensivas: un núcleo de 50 frases, el mismo en todas las llamadas, y las 12 frases más parecidas, que para un texto dado son siempre las mismas. Semanas después, usando la app de verdad, el dueño del proyecto dio con la consecuencia:

qué pesadilla con el tela marinera… además solo sale esa, la del hombre del mazo no me ha salido nunca. da la sensación de que el traductor está vago, siempre recuperando lo mismo

El dueño del proyecto, tras varias traducciones seguidas

Se midió sobre las 288 traducciones que ya estaban guardadas del barrido de la etapa 15, así que no costó nada:

Con la configuración de producción
«tela marinera»en el 40 % de las traducciones
«como digo yo»15 de 48
«el tío del mazo»11 de 48
Frases del núcleo que llegan a asomar24 de 50

Y la prueba de que la culpa era del núcleo y no de la recuperación: en la configuración sin núcleo del barrido, «tela marinera» aparece en el 0 %.

Lo que ninguna métrica miraba, otra vez

El proyecto tenía dos pruebas de repetición y las dos miraban las frases recuperadas, que cambian en cada petición. Ninguna miraba el núcleo, que es justo el que no cambia nunca. Es el mismo patrón de la etapa 16 con la longitud: lo que no se mide se estropea en silencio, y lo encuentra la primera persona que usa la cosa.

Y hubo una segunda sorpresa dentro de la primera: el modelo no repartía. Con 50 frases delante, se quedaba con tres o cuatro pegadizas y las demás no salían nunca.

El arreglo: sortear en vez de fijar

El corpus tenía 1.103 frases que pasan todos los filtros de calidad (entre 4 y 18 palabras, sin restos de transcripción, con recurso identificado y confianza alta o media). Se estaban usando 50.

Dos cambios, uno en cada mitad del prompt:

  1. Los ejemplos se sortean en cada petición. 25 frases de las 1.103, con el reparto equilibrado entre los cinco recursos: si se sortease a lo bruto, saldrían casi solo metáforas y muletillas, que son las más numerosas, y los refranes deformados —el recurso más raro y el que un aficionado reconoce— desaparecerían.
  2. La recuperación deja de ser «las 12 más parecidas». Ahora busca las 30 más parecidas y sortea 12 de ellas, con probabilidad proporcional a la similitud elevada a ocho. Entre una frase de 0,70 y una de 0,45, la primera tiene unas 35 veces más opciones de salir: se conserva la relevancia y cambian las caras. Esto no cuesta un céntimo más, porque al prompt siguen yendo 12.

Comprobado en vivo con el mismo texto tres veces: 0 ejemplos repetidos de 25 y 3 frases recuperadas repetidas de 12.

Lo que consiguió el sorteo, medido

Se escribió una prueba nueva, perico.evals.variety, porque ninguna de las que había miraba esto: cuarenta traducciones, cuánto se repite la expresión más frecuente, cuánto se parecen dos respuestas entre sí y cuántas veces asoman las coletillas que el dueño tenía atragantadas.

Núcleo fijo de 50Sorteo de 1.103
«tela marinera»40 % de las traducciones0 %
«no pain no gain»15 %0 %
Expresión más repetida31 %8 %
Parecido entre dos traducciones4,6 %2,7 %

Y lo que importa tanto como eso: la recuperación sigue llegando a la salida. Los dos pases de solapamiento dieron +16,3 y +15,9 puntos sobre el azar, ganando en 16 y 15 de 24 consultas. Sortear no rompió lo que costó semanas construir.

Dos preguntas del dueño, contestadas con números

Con el sorteo funcionando aparecieron dos preguntas razonables: si 12 de las 30 más parecidas se queda corto, y por qué 25 ejemplos y no 50.

ConfiguraciónExpresión más repetidaParecidoPalabras distintas
30 candidatas, 25 ejemplos8 %6,0 %225
100 candidatas, 25 ejemplos10 %4,8 %229
30 candidatas, 50 ejemplos10 %5,2 %222

Las tres son la misma cifra. Ampliar la banda de candidatas de 30 a 100 no da más variedad (y tampoco quita relevancia: +15,3 puntos de solapamiento, igual que con 30). Doblar los ejemplos de 25 a 50 tampoco, y en cambio dobla lo que se paga por ellos, porque desde que se sortean van fuera de la caché.

Así que se quedan los valores que ya había, pero ahora por una razón medida y no por costumbre: 12 de 30, y 25 ejemplos. Es la misma lección de la etapa 15 con el RETRIEVE_N, aprendida antes de tropezar: cuando dos opciones son indistinguibles, gana la barata.

Recuperar la caché: el bloque fijo se quedaba 178 tokens corto

Sortear los ejemplos había roto la caché, y la factura subió de 0,18 a 0,50 céntimos por traducción. Antes de rediseñar nada, dos comprobaciones.

La primera, contar. El contador de tokens de la API es gratis, y dio una sorpresa:

Parte de la peticiónTokens
Bloque de estilo (lo único fijo)846
25 ejemplos sorteados907
12 frases recuperadas241
Petición completa2.054

La segunda, verificar el umbral en la documentación en vez de fiarse de la memoria: Sonnet 5 no cachea nada por debajo de 1.024 tokens, y —esto es lo importante— cuando el bloque se queda corto no cachea y no devuelve ningún error. Llevábamos semanas mandando un cache_control que no hacía absolutamente nada.

O sea que el bloque fijo se quedaba 178 tokens corto. No hacía falta rediseñar el sorteo: hacía falta que el bloque fijo tuviera algo más que decir.

Y había algo que decir, precisamente por el cambio anterior. Los ejemplos ahora llegan agrupados por recurso, pero el prompt nunca explicaba qué es cada recurso. Añadir esa definición —qué es un refrán deformado, qué es un despiste, cuál usa más y cuál menos— es contenido útil que además es idéntico en cada llamada. El bloque pasó a 1.141 tokens y la caché arrancó:

EntradaLeídos de cachéCoste
Primera petición1.5310 (escribe 1.136)0,663 cént.
Siguientes1.7021.1360,443 cént.
La caché arreglada ahorra mucho menos de lo que parece

De 0,50 a 0,44 céntimos: un 12 %. Nada que ver con el 0,18 de cuando el núcleo era fijo.

El motivo está en la tabla de arriba. Lo que se cachea son 1.141 tokens de estilo; lo que no se puede cachear son los 907 de ejemplos sorteados y los 241 de frases recuperadas, que cambian en cada petición porque esa es justamente la característica que da variedad.

Así que el intercambio real no era «caché o no caché», sino variedad o caché, y la variedad ya estaba pagada y medida. Recuperar la caché fue arreglar un descuido; el ahorro grande no vuelve.

Las coletillas del prompt estaban inventadas

Con la variedad arreglada, el dueño del proyecto hizo una pregunta incómoda: si hay 3.144 frases suyas en la base de datos, ¿por qué las coletillas del prompt las escribió alguien a mano?

Porque nadie las comprobó. Esta línea llevaba en el prompt desde el primer día:

prompt de estilo, antes
- Sueltas exclamaciones y coletillas: «¿eh?», «ostras», «madre mía», «fíjate».

Contarlas en sus propias frases cuesta un minuto y no vale un céntimo. Una muletilla vive al principio de la frase o tras una coma, así que se cuentan ahí y no sueltas por el medio:

ExpresiónComo muletillaEn % de sus frases
«pues…»2868,0 %
«bueno…»2758,1 %
«o sea…»1975,4 %
«claro»692,1 %
«ostras»571,7 %
«madre mía»300,9 %
«fíjate»280,9 %
Cierres «¿eh?», «¿no?»16 %

Tres de las cuatro coletillas que pedía el prompt son de las que menos usa, y faltaban las tres que más. Y hay una consecuencia medida: en las pruebas de variedad, «madre mía» aparecía en el 22-28 % de las traducciones. Él la dice en el 0,9 % de sus frases. La estábamos multiplicando por veinticinco, y solo porque alguien la escribió en una lista.

Nombrar una coletilla es multiplicarla

Es el mismo mecanismo que hizo famoso el «tela marinera»: lo que aparece en el prompt sale mucho más de lo que sale en la realidad. Por eso la línea nueva no solo dice cuáles usa, sino cuáles no: «"madre mía" y "fíjate" las dices poco: no abuses de ellas».

Las etiquetas que la base de datos tenía y el prompt tiraba

La segunda parte de la misma pregunta. Cada frase del corpus lleva desde la etapa 7 su recurso etiquetado —refrán, metáfora, despiste, muletilla, digresión— y esas etiquetas solo se usaban para equilibrar el sorteo. Al modelo le llegaban los 25 ejemplos en una lista plana, sin decirle qué ilustraba cada uno.

Ahora llegan agrupados bajo su etiqueta:

lo que ve el modelo
Así hablas tú:

Refranes y dichos, deformados o aplicados donde no tocan:
- Hay alguien siempre que guarda una bala.
- Todo lo que supere el 20% es mejor bajarse de la bici.

Muletillas:
- Yo me admiro, ¿eh?

Cuesta cero tokens de más y aprovecha un trabajo que ya estaba hecho y guardado. Una frase etiquetada con dos recursos se muestra bajo el más raro: si algo es refrán y metáfora a la vez, se enseña como refrán, que es lo que un aficionado reconoce y lo que menos abunda en el corpus (330 refranes frente a 1.591 metáforas).

Las dos cosas juntas, medidas con las cuatro pruebas:

Coletillas a manoColetillas medidas y ejemplos etiquetados
«madre mía» en las traducciones22-28 %0 %
«tela marinera»0-5 %0 %
Parecido entre dos traducciones4,6-6,0 %1,1 %
Palabras distintas213-229233
Fidelidad71 %68 %
Longitud de una frase×2,5×2,6

La variedad es la mejor cifra de todo el proyecto. Dos traducciones cualesquiera comparten ya solo el 1,1 % de su vocabulario, y las coletillas que el dueño tenía atragantadas han desaparecido. La que más se repite ahora es «pues nada, oye», que es suya: es la primera vez que lo que se repite es lo que él repite.

La fidelidad se queda igual (68 % frente a 71 %, dentro del ruido) y la longitud también.

Y una cifra que no cuadra, dicha tal cual

El solapamiento, que mide si lo recuperado llega a la salida, dio +13,0 puntos en un pase y +5,7 en el otro, con 17 y 14 consultas ganadas de 24. Antes daba +14,0 y +12,7.

Un pase está bien y el otro flojo, así que la respuesta honesta es que no se sabe: puede ser ruido del modelo o una bajada real. Lo que no se puede hacer es quedarse con el +13,0 y contarlo como que no ha cambiado nada. Queda apuntado para volver con más pases cuando haya motivo.

Traer doce frases siempre, valgan lo que valgan

La recuperación devolvía las doce más parecidas, pasara lo que pasara. El dueño del proyecto lo puso en duda con un ejemplo que lo desmonta:

si le meto una frase sobre estanterías de ikea, está claro que la recuperación no va a traer nada porque Perico no habla de eso en la tele

El dueño del proyecto, sobre qué debería hacer la recuperación

Y tenía otra corrección de fondo, más importante: la premisa del proyecto no es que la recuperación se use, sino que el traductor funcione. Un corpus de verdad tenía que mejorar el resultado frente a cuatro ejemplos inventados, y eso no obliga a que aporte algo en cada frase.

Se midió el parecido de las doce primeras para tres tipos de texto:

Tipo de textoParecido máximoMediana
Claramente suyo: puertos, pinchazos, agotamiento0,68–0,830,56–0,62
Cotidiano: el perro, el alquiler, el vino0,47–0,650,42–0,52
Ajeno: IKEA, la renta, JavaScript, el violín0,31–0,530,27–0,46

Para la estantería de IKEA, la frase «más parecida» que devolvía el corpus era «No, ahora, ¿cómo la recupero yo? Ya me había habituado.» Ruido, presentado al modelo bajo una etiqueta que afirmaba: «cosas que has dicho sobre asuntos parecidos».

Las bandas se solapan, y eso limita lo que un umbral puede hacer

Mirando la tabla, la tentación es poner un corte y separar lo relevante de lo irrelevante. No se puede: IKEA llega a 0,53 y el perro con el calcetín se queda en 0,47. Un umbral único deja fuera el texto legítimo antes que el ajeno.

Lo que sí separa limpio son los extremos: JavaScript y el violín se quedan en 0,31. Así que el suelo se puso en 0,45, y se le pide solo eso: que el corpus no disfrace de contexto lo que no tiene nada que ver. La zona media sigue sin resolver, y decirlo es parte del diseño.

El arreglo tiene tres partes. Se descartan las frases por debajo del suelo, quedándose las que pasen aunque sean cero. No se rellena el hueco con las siguientes de la lista, que son peores por definición. Y cuando pasan pocas, se compensa con más ejemplos sorteados: enseñan cómo habla, que sirve hable de lo que hable, mientras que una frase que encajó al 0,3 no sirve para nada.

TextoFrases recuperadasEjemplos
Subir un puerto por primera vez12 (0,70–0,50)25
Mi perro se ha comido un calcetín3 (0,47–0,45)34
Una estantería de IKEA12 (0,53–0,46)25
Un error de JavaScript035
El violín en el conservatorio035

Medido con el umbral puesto, contra la misma configuración sin él:

MedidaSin umbralCon umbral 0,45
Fidelidad69 %68 %
Parecido entre dos traducciones1,3 %1,0 %
Expresión más repetida12 %8 %
Palabras distintas241215
Solapamiento+6,6 (14/24)+7,2 (14/24)

El umbral no cuesta nada y limpia bastante. La fidelidad se queda igual, la repetición baja a su mejor marca y el solapamiento incluso sube un poco, que era lo esperable: al dejar de mandar frases que no venían a cuento, lo que llega al modelo se parece más a lo que acaba usando.

La única cifra que baja es el vocabulario, de 241 palabras distintas a 215. Tiene sentido: en las peticiones donde la recuperación no aporta nada, el modelo se apoya más en los ejemplos, y los ejemplos son 1.103 frases mientras que el corpus entero son 3.144. Es el precio de no decir tonterías cuando no hay nada que decir, y parece barato.

Y hay un ahorro que no sale en la tabla: en esas peticiones se dejan de enviar 241 tokens de frases inútiles.

Lo que se eligió, y qué fijó cada cosa

Se eligióY con eso quedó fijado
50 frases de núcleoCuánto estilo va en todas las llamadas — y cuánto se paga en caché una sola vez
Puntuar estilo por palabraQue el núcleo sean ejemplos cortos y no relatos. De 30 palabras de media a 8
Suelo por recurso al elegirQue haya refranes. Sin él salían 9 de 50; con él, 23
12 frases recuperadasCuánto contexto temático entra por petición — y, sin que nadie lo viera, el resultado del experimento de la etapa 14. Se barre en la etapa 15
Sonnet 5El generador, por lo que se midió en la etapa 9
El corte de caché tras el núcleoQue la parte fija se pague una vez y la variable no la invalide
Esto todavía no demuestra nada

Que la salida suene bien no demuestra que la recuperación esté aportando algo. Puede que todo el mérito sea del núcleo y de la descripción de estilo, y que las doce frases recuperadas no cambien nada.

Por eso translate.py lleva una bandera --no-rag, que salta la recuperación y deja solo el prompt de estilo. Es la línea base contra la que hay que comparar, y esa comparación es la etapa 14, no esta. Aquí la app funciona; si el RAG se gana su sitio es otra pregunta y se contesta midiendo.

Lo que se lleva uno de esta etapa

  1. Separa el estilo del contenido. El núcleo enseña cómo habla y siempre está; la recuperación aporta de qué hablar y sólo a veces puede.
  2. Cuando la recuperación no tenga nada que ofrecer, que haya un suelo. Si el corpus no cubre lo que pide el usuario, sin núcleo el modelo se queda sin nada delante.
  3. Puntúa por densidad, no por total. Contar recursos premia al que divaga; dividir por longitud premia al que es un buen ejemplo.
  4. Lo fijo antes del corte de caché, lo variable después. Equivocarse aquí no produce ningún error: produce una factura.
  5. Que funcione no es que sirva. Guarda la línea base desde el principio, porque sin ella la mejora no se puede medir.