Al final de 04-04 vam deixar tres preguntes incòmodes sense respondre: recupera el pipeline els chunks correctes?, el llindar de 0.45 és savi o casualitat?, les respostes són fidels a allò recuperat? A 02-04 vam aprendre a avaluar prompts amb un conjunt de 30-50 preguntes, una rúbrica binària i un CSV — però un sistema RAG afegeix una dificultat nova: té dos motors que poden fallar per separat. Si DocuBot respon malament, va ser perquè la recuperació va portar chunks equivocats o perquè la generació va ignorar uns chunks correctes? Sense separar les dues preguntes, cada resposta dolenta és un misteri i cada "millora" és una aposta a cegues. Aquesta lliçó estén la nostra infraestructura d'avaluació al món RAG: anotarem quins chunks hauria de recuperar cada pregunta del conjunt, implementarem les mètriques clàssiques de recuperació (recall@k, precision@k, MRR) en Python pla, estendrem la rúbrica de 02-04 amb els criteris específics de la generació amb context, i muntarem la taula de diagnòstic i els experiments (k, mida de chunk, solapament) que converteixen les decisions de disseny de 04-03 i 04-04 en decisions amb dades.

Contingut

  1. Dos motors, dues avaluacions
  2. Ampliar el conjunt de 02-04: anotar els chunks esperats
  3. Mètriques de recuperació: recall@k, precision@k i MRR
  4. Mètriques de generació en RAG: la rúbrica estesa
  5. Diagnòstic per components: la taula de símptomes
  6. Experiments: k, mida de chunk i solapament

Dos motors, dues avaluacions

Seguim una resposta dolenta fins al seu origen. Un usuari pregunta "quants membres admet el pla Pro?" i DocuBot respon alguna cosa vaga sobre invitacions. Hi ha dues històries possibles:

  • Va fallar la recuperació: el top-4 no incloïa el chunk de "Plans i facturació" amb el límit de 25 membres. El LLM va fer el correcte amb el que tenia — el problema és al chunking, el model d'embeddings, k o els filtres.
  • Va fallar la generació: el chunk correcte era al bloc <documentacio>, però el model el va ignorar, el va malinterpretar o el va adornar. El problema és al prompt, el model o el format del bloc.

Les cures són disjuntes: refer el chunking no arregla un problema de prompt, i afinar el prompt no arregla un top-k que no porta la resposta. D'aquí el principi d'aquesta lliçó:

Avalua la recuperació sense el LLM, i la generació amb recuperació coneguda. Només després avalua el conjunt de punta a punta.

La bona notícia és que la meitat de la feina ja està feta: el conjunt de 02-04 (30-50 preguntes amb els seus 4 tipus de casos), la rúbrica binària, el registre CSV i la disciplina de 3 execucions per cas continuen sent vàlids. Només cal estendre'ls.

Ampliar el conjunt de 02-04: anotar els chunks esperats

Per mesurar la recuperació necessitem la veritat terreny: per a cada pregunta, quins chunks del corpus contenen la seva resposta. És feina manual i és la inversió més rendible de la lliçó — es fa una vegada i sosté tots els experiments posteriors.

El procés pràctic: per a cada pregunta del conjunt, buscar (a mà, llegint la doc, ajudant-se de recuperar() com a suggeriment però verificant a ull) els ids dels chunks que responen. Els ids deterministes de 04-03 ho fan possible: són estables entre re-ingestes.

# conjunt_rag.py — el conjunt de 02-04, estès amb anotació de recuperació
CONJUNT_RAG = [
    {
        "id": "cas-01",
        "pregunta": "Com restableixo la contrasenya?",
        "tipus": "facil",                    # els 4 tipus de 02-04 es conserven
        "chunks_esperats": ["wiki-administracio-seguretat-i-acces--restablir-contrasenya--00"],
    },
    {
        "id": "cas-07",
        "pregunta": "No puc entrar al meu compte",
        "tipus": "ambigu",                   # pot ser contrasenya oblidada O compte bloquejat
        "chunks_esperats": [
            "wiki-administracio-seguretat-i-acces--restablir-contrasenya--00",
            "wiki-administracio-seguretat-i-acces--bloqueig-de-compte--00",
        ],
    },
    {
        "id": "cas-19",
        "pregunta": "Puc pagar amb criptomonedes?",
        "tipus": "sense_resposta",
        "chunks_esperats": [],               # el correcte és NO recuperar res de rellevant
    },
    # ... resta del conjunt (30-50 casos)
]

Tres notes d'anotació que eviten disgustos:

  • Els casos "sense resposta" (~20% del conjunt) porten llista buida — i són els que calibren el LLINDAR_RELLEVANCIA de 04-04: en ells, la similitud del millor chunk hauria de quedar per sota del llindar.
  • Els ambigus solen tenir diversos chunks esperats: qualsevol subconjunt raonable pot bastar per respondre; les mètriques ho tindran en compte.
  • L'anotació caduca amb el chunking: si canvies chunking.py (i per tant els ids), cal revisar l'anotació. Un altre motiu per versionar l'estratègia de chunking com vam fer amb PROMPT_VERSION.

Mètriques de recuperació: recall@k, precision@k i MRR

Amb la veritat terreny anotada, avaluar la recuperació no necessita LLM: s'executa recuperar() i es comparen ids. Les tres mètriques estàndard, cadascuna responent una pregunta diferent:

Mètrica Pregunta que respon Rang Quan preocupar-se
recall@k Dels chunks que havien de sortir, quina fracció va sortir al top-k? 0–1 És LA mètrica de RAG: si el chunk no surt, el LLM no el pot fer servir. Recall baix = sostre dur de qualitat
precision@k Dels k chunks que van sortir, quina fracció era rellevant? 0–1 Precisió baixa = soroll al context: cost extra i distracció del model
MRR Com d'amunt surt el primer chunk rellevant? (mitjana d'1/posició) 0–1 Importa perquè ordenem "millor primer" (04-04): MRR alt = allò bo arriba al capdavant

Implementació en Python simple, sense dependències:

# eval_rag.py — mètriques de recuperació sobre el conjunt anotat
from rag import recuperar
from conjunt_rag import CONJUNT_RAG

def recall_at_k(esperats: list[str], recuperats: list[str]) -> float:
    """Fracció dels chunks esperats que apareixen entre els recuperats."""
    if not esperats:
        return 1.0 if not recuperats else 1.0  # els casos sense resposta es mesuren a part
    trobats = sum(1 for e in esperats if e in recuperats)
    return trobats / len(esperats)

def precision_at_k(esperats: list[str], recuperats: list[str]) -> float:
    """Fracció dels chunks recuperats que eren esperats."""
    if not recuperats:
        return 0.0
    rellevants = sum(1 for r in recuperats if r in esperats)
    return rellevants / len(recuperats)

def reciprocal_rank(esperats: list[str], recuperats: list[str]) -> float:
    """1/posició del primer chunk rellevant (0 si no n'apareix cap)."""
    for posicio, r in enumerate(recuperats, start=1):
        if r in esperats:
            return 1.0 / posicio
    return 0.0

def avaluar_recuperacio(k: int = 4) -> dict:
    recalls, precisions, rrs = [], [], []
    for cas in CONJUNT_RAG:
        if not cas["chunks_esperats"]:
            continue                      # els 'sense_resposta' s'avaluen amb el llindar
        chunks = recuperar(cas["pregunta"], k=k)
        ids = [c["id"] for c in chunks]   # requereix exposar l'id a recuperar()
        recalls.append(recall_at_k(cas["chunks_esperats"], ids))
        precisions.append(precision_at_k(cas["chunks_esperats"], ids))
        rrs.append(reciprocal_rank(cas["chunks_esperats"], ids))
    n = len(recalls)
    return {
        "k": k,
        "recall@k": sum(recalls) / n,
        "precision@k": sum(precisions) / n,
        "MRR": sum(rrs) / n,
        "casos": n,
    }

if __name__ == "__main__":
    print(avaluar_recuperacio(k=4))
    # {'k': 4, 'recall@k': 0.91, 'precision@k': 0.47, 'MRR': 0.86, 'casos': 32}

Com llegir aquests números d'exemple:

  • recall@4 = 0.91: en el 91% dels casos (ponderat per chunks) allò necessari va arribar al context. Els casos fallits són la llista de feina: mirar-los d'un en un sol revelar patrons (tots de la mateixa pàgina? tots amb vocabulari d'usuari molt allunyat de la doc?).
  • precision@4 = 0.47 sembla baix, però és normal: si una pregunta necessita 1 chunk i en recuperem 4, la precisió màxima és 0.25. La precisió es compara entre configuracions (puja en baixar k?), no contra un ideal d'1.0.
  • MRR = 0.86: el primer chunk rellevant sol sortir 1r o 2n — bé per al nostre ordre "millor primer".
  • I els casos sense resposta tenen la seva pròpia mesura: quina fracció d'ells queda per sota del LLINDAR_RELLEVANCIA (haurien de ser: tots) davant de quina fracció dels casos fàcils queda per sobre (haurien de ser: tots). Aquestes dues xifres són la calibració del llindar que 04-04 va deixar pendent.

Mètriques de generació en RAG: la rúbrica estesa

Suposem recall alt: allò correcte arriba al context. Falta avaluar què fa el model amb això. Els tres criteris específics de la generació amb context recuperat:

  1. Fidelitat al context (groundedness): tot allò afirmat a la resposta està respatllat pels chunks del bloc <documentacio>? Una resposta pot ser veritable i tot i així infidel (el model ho sabia "de memòria" — inacceptable per a DocuBot, regla 2 de SYSTEM_DOCUBOT).
  2. Rellevància de la resposta: respon a aquesta pregunta, o resumeix correctament els chunks sense contestar el que es va preguntar?
  3. Ús correcte de fonts: el camp fonts apunta als chunks realment usats i la resposta no atribueix a una font una cosa que no diu? (Des de 04-04 les URLs les posa el sistema, així que aquí es vigila l'atribució del contingut, no l'existència de la URL.)

En lloc d'inventar una rúbrica nova, estenem la de 02-04: als seus 6 criteris binaris s'hi afegeixen aquests 3, i l'avaluació segueix el mateix protocol — judici humà, binari (compleix/no compleix, sense mitges tintes), 3 execucions per cas pel no determinisme (01-04), registre en CSV:

# Criteri Origen
1–6 Els 6 criteris de 02-04 (format JSON vàlid, categoria correcta, to, frase anti-al·lucinació quan toca, etc.) 02-04
7 Fidel al context: res afirmat sense suport als chunks nou (RAG)
8 Rellevant: respon la pregunta formulada nou (RAG)
9 Fonts ben usades: atribució correcta del contingut nou (RAG)

Truc d'avaluació que estalvia hores: com que el pipeline registra quins chunks va recuperar (el consell final de 04-04), l'avaluador humà pot jutjar la fidelitat llegint només aquests chunks, no tota la documentació. Avaluar el criteri 7 d'un cas passa de minuts a segons.

Aquest procés és manual i no escala a centenars de casos — ho sabem. A 06-03 veurem com automatitzar-lo fent servir un LLM com a jutge (LLM-as-judge) amb aquesta mateixa rúbrica com a base; la versió manual d'avui no és feina rebutjable sinó el patró or contra el qual es validarà aquell jutge automàtic.

Diagnòstic per components: la taula de símptomes

Amb mètriques de recuperació i de generació separades, cada patologia apunta al seu component. Aquesta taula és l'eina de treball diari:

Símptoma (mètriques) Diagnòstic probable On actuar
recall@k baix, generació fidel La recuperació no porta allò necessari Chunking (04-03): seccions mal tallades?; k més gran; revisar model d'embeddings; filtres massa agressius (04-02)?
recall@k alt, criteri 7 falla El context arriba però el model no s'hi cenyeix Prompt (02-01/02-02): reforçar la regla 2; bloc <documentacio> confús?; model massa "creatiu"? (temperature, 01-02)
recall@k alt, criteri 8 falla Recupera i resumeix, però no contesta prompt_resposta(): demanar resposta directa; chunks rellevants però tangencials? — revisar anotació
MRR baix amb recall acceptable Allò bo arriba, però al final del top-k Ordre i k (04-04); considerar reranking (mencionat a 04-04) si persisteix
precision@k molt baixa + cost alt Molt soroll al context Baixar k; pujar llindar; millorar chunking (chunks més enfocats)
Casos sense_resposta responen amb contingut Llindar massa baix (o chunks "semblants" enganyosos) Calibrar LLINDAR_RELLEVANCIA amb les dues xifres de l'apartat 3
Casos fàcils reben la frase anti-al·lucinació Llindar massa alt, o recall trencat per a aquests casos Ídem, mirant primer el recall d'aquests casos concrets

La disciplina que la taula imposa: davant d'una resposta dolenta, mira primer les mètriques de recuperació del cas. És un cop d'ull al CSV (eren els chunks esperats al top-k?) i bifurca el diagnòstic en dos camins que no es barregen. Sense aquesta disciplina, l'instint és sempre "tocar el prompt" — que només cura la meitat de les malalties.

Experiments: k, mida de chunk i solapament

Les decisions de 04-03 i 04-04 (chunks de ~450 tokens, solapament de 300 caràcters, k=4, llindar 0.45) van ser punts de partida raonats. Ara poden ser conclusions. El mètode experimental és el de 02-04 — canviar una variable cada vegada, mesurar sobre el conjunt complet, registrar en CSV:

# experiments_rag.py — escombratge de k amb registre CSV (patró de 02-04)
import csv
from datetime import date
from eval_rag import avaluar_recuperacio
from prompts import PROMPT_VERSION

CHUNKING_VERSION = "encapcalaments-v1-1800-300"   # mida i solapament actuals (04-03)

with open("experiments_rag.csv", "a", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    # data, versions, variable, i les tres mètriques
    for k in (2, 3, 4, 6, 8):
        m = avaluar_recuperacio(k=k)
        w.writerow([date.today(), PROMPT_VERSION, CHUNKING_VERSION, f"k={k}",
                    round(m["recall@k"], 3), round(m["precision@k"], 3), round(m["MRR"], 3)])

Resultats típics d'un escombratge de k (els teus números variaran):

k recall@k precision@k Lectura
2 0.72 0.61 Barat però es perd el 28% d'allò necessari
4 0.91 0.47 El colze: gairebé tot el recall a cost raonable
6 0.94 0.35 +3 punts de recall a canvi de +50% de context (i cost, 03-04)
8 0.95 0.28 Rendiments decreixents clars

El patró és gairebé universal: el recall creix amb k però s'aplana (el "colze"), la precisió cau monòtonament, i el cost per pregunta creix linealment amb k. La decisió és un compromís informat — i amb aquesta taula, k=4 deixa de ser "el que va dir el curs" per ser "el colze de la nostra corba".

Els altres dos experiments canònics segueixen la mateixa mecànica, amb una diferència operativa important:

  • Mida de chunk (TAM_MAX: provar p. ex. 1000 / 1800 / 3000) i amb/sense solapament (SOLAPAMENT: 0 / 300): cada variació exigeix re-executar ingesta.py (nou trossejament → nous embeddings → re-poblar la col·lecció) i, si canvien els ids, revisar l'anotació. Són experiments cars — per això es fan menys sovint i es registren amb CHUNKING_VERSION, per no repetir-los per oblit.
  • La variable de generació (prompt, temperature) s'experimenta amb la rúbrica estesa, no amb recall/precision — cada motor amb les seves mètriques.

Errors Comuns i Consells

  • Avaluar només de punta a punta. "La resposta és dolenta" sense saber si va fallar recuperació o generació porta a arreglar el component equivocat. Les mètriques separades existeixen per bifurcar el diagnòstic.
  • Optimitzar precision@k en abstracte. Amb 1 chunk rellevant i k=4, la precisió màxima és 0.25 i no vol dir que res vagi malament. Compara-la entre configuracions; el recall és qui mana en RAG.
  • Anotar els chunks esperats fent servir només recuperar() com a oracle. Si anotes el que el sistema ja retorna, mesuraràs que el sistema retorna el que retorna: recall d'1.0 garantit i fals. L'anotació exigeix llegir la documentació.
  • Canviar dues variables alhora (k i el prompt, o chunk i llindar): la millora —o l'empitjorament— quedarà sense causa assignable. Una variable, una mesura, una fila de CSV. La lliçó la vam aprendre a 02-04 i en RAG fa el doble de mal perquè hi ha el doble de comandaments.
  • Oblidar els casos sense_resposta en avaluar. Un sistema que respon alguna cosa al 100% de les preguntes té mètriques de recuperació estupendes i un problema greu d'honestedat. Les dues xifres de calibració del llindar són part del quadre de comandament, no un extra.
  • Consell: fixa llindars d'alarma sobre el conjunt (p. ex. "recall@4 ≥ 0.85 i zero casos sense_resposta respostos amb contingut") i re-executa l'avaluació després de cada re-ingesta o canvi de versió. És la versió RAG del pressupost amb alerta de 03-04 — i a 06-04 veurem com vigilar aquests senyals en producció de manera contínua.

Exercicis

  1. Completa la mesura del llindar. Escriu avaluar_llindar(llindar) que recorri el conjunt i retorni dues xifres: fracció de casos sense_resposta correctament rebutjats (millor chunk per sota del llindar) i fracció de casos facil correctament acceptats (per sobre). Executa-la per a llindars 0.30, 0.45 i 0.60 i tria amb dades.
  2. MRR a mà. Sense executar codi: tres preguntes els chunks rellevants de les quals apareixen a les posicions 1, 3 i (no apareix) del top-4. Calcula el MRR i explica quina acció de la taula de diagnòstic dispararies si aquest fos el teu valor global.
  3. El cas que enganya. La pregunta "quantes tasques admet el pla Pro?" obté: recall@4 = 1.0 (el chunk de límits és al top-4, posició 2) però la resposta diu "fins a 500 tasques i 50 membres" quan la doc diu 25 membres. Quins criteris de la rúbrica fallen, quin és el diagnòstic segons la taula, i quines dues accions concretes provaries primer?

Solucions

  1. Estructura de la funció:
def avaluar_llindar(llindar: float, k: int = 4) -> dict:
    rebutjats_ok, acceptats_ok, n_sense, n_facil = 0, 0, 0, 0
    for cas in CONJUNT_RAG:
        millor = 0.0
        chunks = recuperar(cas["pregunta"], k=k)
        if chunks:
            millor = chunks[0]["similitud"]
        if cas["tipus"] == "sense_resposta":
            n_sense += 1
            rebutjats_ok += (millor < llindar)
        elif cas["tipus"] == "facil":
            n_facil += 1
            acceptats_ok += (millor >= llindar)
    return {"llindar": llindar,
            "sense_resposta_rebutjats": rebutjats_ok / n_sense,
            "facils_acceptats": acceptats_ok / n_facil}

Resultat típic: amb 0.30 s'accepten tots els fàcils però se'n "colen" diversos sense_resposta; amb 0.60 es rebutja tot allò dubtós però comencen a caure fàcils legítims; 0.45 sol deixar totes dues xifres altes. Si cap llindar no separa bé les dues poblacions, el problema no és el llindar: és que hi ha chunks enganyosament semblants a preguntes sense resposta — torna al chunking o afegeix a la doc una pàgina explícita ("Mètodes de pagament admesos") que doni al sistema alguna cosa correcta a recuperar.

  1. MRR = (1/1 + 1/3 + 0) / 3 = 0.444. És baix, però la taula exigeix mirar el perquè abans d'actuar: el tercer cas no és un problema d'ordre sinó de recall (el chunk no apareix en absolut), així que l'acció correcta comença a la fila "recall@k baix" (chunking, k, embeddings) per a aquell cas; el segon cas (posició 3) sí que és d'ordre i apuntaria a reranking només si el patró es repeteix. Moralitat: el MRR mai no es diagnostica sol — sempre al costat del recall.

  2. Fallen el criteri 7 (fidelitat: "50 membres" no està respatllat pel chunk, que diu 25) i possiblement el 9 (atribueix a la font una cosa que no diu); el 8 es compleix (respon la pregunta). Amb recall perfecte i fidelitat trencada, la taula assenyala generació: el context va arribar i el model el va alterar. Dues primeres accions: (a) reforçar a prompt_resposta() la instrucció de copiar xifres i límits literalment de la documentació (i re-avaluar sobre el conjunt complet, no només aquest cas — regla de 02-04); (b) comprovar la temperature de la crida (decisió de 01-03: baixa per a DocuBot) per si algun canvi la va pujar. Si persistís, mirar si el chunk barreja els límits de diversos plans en una taula que el model llegeix malament — cosa que retornaria el problema, curiosament, al chunking: els diagnòstics són probables, no infal·libles, i per això es registra tot.

Conclusió

Amb aquesta lliçó, el mòdul 4 queda complet — i DocuBot deixa de ser un pipeline que funciona a les demos per ser un sistema RAG que sabem mesurar i diagnosticar: el conjunt de 02-04 té ara anotats els chunks esperats de cada pregunta (amb els casos sense_resposta com a calibradors del llindar), eval_rag.py mesura la recuperació sense gastar un token de LLM (recall@k com a mètrica reina, precision@k i MRR com a acompanyants que es llegeixen juntes), la rúbrica de 6 criteris va créixer a 9 amb la fidelitat al context, la rellevància i l'ús de fonts, la taula de símptomes bifurca cada resposta dolenta cap al seu veritable culpable —recuperació o generació, cadascun amb les seves cures—, i els experiments amb registre CSV van convertir k=4, la mida de chunk i el solapament de valors heretats en el colze mesurat de la nostra pròpia corba. Mirant enrere, el mòdul 4 va complir la promesa que tancava el mòdul 3: els embeddings van passar d'intuïció a eina (04-01, amb el cache semàntic de regal), la col·lecció docs_nubelia va donar memòria persistent i filtrable a la cerca (04-02), ingesta.py va convertir centenars de pàgines en chunks amb ofici i fonts traçables (04-03), i respondre_amb_rag() va omplir per fi, automàticament, aquell bloc <documentacio> que el SYSTEM_DOCUBOT exigia des de 02-01 — enterrant el DocuBot ingenu que inventava exportacions a PDF. Però fixa't en el verb que defineix tot allò construït: DocuBot sap. Sap el que diu la documentació i ho explica amb fonts. El que encara no pot és fer: si un usuari pregunta "quantes tasques té ara mateix el meu projecte Atlas?", la resposta no és a cap wiki — és a l'API de Nubelia, a una crida de distància que DocuBot no pot realitzar. Donar-li mans a més de memòria és el salt del mòdul 5: començant pel function calling (05-01) — el mecanisme pel qual el LLM deixa de només redactar respostes i comença a invocar les nostres funcions —, i seguint fins a convertir DocuBot en un agent que raona, actua i observa en bucle. La documentació ja la domina; ara aprendrà a fer servir el producte.

© Copyright 2026. Tots els drets reservats