Aquest és el moment que el curs porta preparant des de 01-01. Tenim embeddings i cerca semàntica (04-01), una base vectorial persistent amb filtres (04-02) i un corpus ben trossejat amb metadades (04-03). I dels mòduls anteriors tenim una cosa igual de valuosa: un SYSTEM_DOCUBOT que des de 02-01 exigeix basar les respostes exclusivament en el bloc <documentacio>, una prompt_resposta() que el construeix, un contracte JSON amb camp fonts, i un respondre() robust amb reintents i degradació (03-02). Totes aquestes peces es van dissenyar amb un buit al centre: algú havia d'omplir el bloc <documentacio> — i fins avui aquest algú érem nosaltres, a mà. En aquesta lliçó tanquem el cercle: construïm rag.py, la funció respondre_amb_rag() que, donada una pregunta, recupera automàticament els chunks rellevants, munta el bloc de documentació amb les seves fonts reals i genera la resposta. També prendrem les decisions de disseny que no surten als diagrames de màrqueting (quants chunks?, en quin ordre?, i si no hi ha res de rellevant?), integrarem RAG amb les converses de 03-03 i saldarem un compte pendent de 01-04: la pregunta del PDF davant de la qual el DocuBot ingenu al·lucinava.

Contingut

  1. L'arquitectura completa: com encaixen les peces
  2. Recuperar: de la pregunta als chunks
  3. Augmentar: construir el bloc <documentacio> amb fonts
  4. Generar: rag.py i respondre_amb_rag()
  5. Decisions de disseny: k, ordre i el llindar anti-soroll
  6. RAG en converses: integració amb Conversa
  7. Abans i després: la pregunta del PDF de 01-04

L'arquitectura completa: com encaixen les peces

RAG (Retrieval-Augmented Generation, generació augmentada per recuperació) té tres fases amb nom propi: recuperar els fragments rellevants, augmentar el prompt amb ells, generar la resposta. Res de la tercera fase no és nou per a nosaltres — és exactament el DocuBot dels mòduls 2 i 3. La novetat és que les dues primeres deixen de ser manuals:

flowchart TD
    P[Pregunta de l'usuari] --> E["embed(pregunta, tipus='query')<br/>(embeddings.py, 04-01)"]
    E --> R["colleccio.query(top-k, filtres)<br/>(docs_nubelia, 04-02/04-03)"]
    R --> U{"similitud màxima<br/>≥ llindar?"}
    U -- no --> F["Resposta anti-al·lucinació<br/>(regla 3 de SYSTEM_DOCUBOT)"]
    U -- sí --> D["Construir bloc &lt;documentacio&gt;<br/>amb chunks + fonts"]
    D --> PR["prompt_resposta()<br/>(prompts.py, 02-01)"]
    PR --> G["respondre(pregunta, documentacio)<br/>(docubot.py, 03-02: reintents + degradació)"]
    G --> J["Contracte JSON validat<br/>{categoria, resposta, fonts, confianca}<br/>amb fonts REALS de les metadades"]

Val la pena aturar-se en el que aquest diagrama significa per al curs: no tocarem ni prompts.py, ni validacio.py, ni docubot.py. El system prompt de 02-01 ja parlava del bloc <documentacio> quan aquest bloc només podíem omplir-lo copiant i enganxant; el respondre() de 03-02 ja rebia documentacio com a paràmetre. El disseny per capes està a punt de pagar-se: RAG és una capa nova per sobre, no una reescriptura.

Recuperar: de la pregunta als chunks

La primera fase tradueix la pregunta a l'espai de significats i consulta la col·lecció:

# rag.py (part 1) — recuperació
from embeddings import embed
from vectordb import obtenir_colleccio

K_DEFECTE = 4          # quants chunks recuperar (decisió justificada més avall)
LLINDAR_RELLEVANCIA = 0.45   # similitud mínima del millor chunk (calibrar amb 04-05)

def recuperar(pregunta: str, k: int = K_DEFECTE, filtres: dict | None = None) -> list[dict]:
    """Retorna els k chunks més rellevants, com a llista de dicts uniformes."""
    colleccio = obtenir_colleccio()
    resultat = colleccio.query(
        query_embeddings=embed([pregunta], tipus="query"),
        n_results=k,
        where=filtres,          # None = cercar a tot el corpus
    )
    chunks = []
    for doc, meta, dist in zip(resultat["documents"][0],
                               resultat["metadatas"][0],
                               resultat["distances"][0]):
        chunks.append({
            "text": doc,
            "similitud": 1 - dist,     # distància cosinus -> similitud (04-02)
            "url": meta["url"],
            "titol": f"{meta['titol_pagina']} > {meta['titol_seccio']}",
            "versio": meta.get("versio", ""),
        })
    return chunks

Tot és material conegut: embed amb input_type="query" (04-01), la col·lecció docs_nubelia (04-02), la conversió distància→similitud, i metadades que venen de la ingesta (04-03). La funció normalitza el format "llista de llistes" de Chroma a una llista de dicts amb la qual la resta del pipeline treballa còmode.

Sobre el paràmetre filtres: aquí és on s'endollaria el filtre per categoria fent servir el classificador de 02-02, o el filtre per versió del producte. Com vam veure a l'exercici 2 de 04-02, un filtre basat en una classificació errònia saboteja la recuperació — per això el valor per defecte és None (cercar a tot arreu) i el filtre és una opció que s'activa amb evidència, no una suposició que es cableja.

Augmentar: construir el bloc <documentacio> amb fonts

La segona fase dona format als chunks per al prompt. Cada fragment va numerat i amb la seva font visible, perquè el LLM la pugui citar:

# rag.py (part 2) — construcció del bloc de documentació
def construir_documentacio(chunks: list[dict]) -> str:
    """Formata els chunks recuperats com el bloc <documentacio> espera."""
    parts = []
    for i, c in enumerate(chunks, start=1):
        parts.append(
            f"[Fragment {i} | font: {c['url']} | secció: {c['titol']}]\n{c['text']}"
        )
    return "\n\n---\n\n".join(parts)

Detalls amb intenció:

  • Numerar els fragments permet instruir el model (via prompt_resposta(), que ja demana citar fonts) perquè s'hi refereixi sense ambigüitat, i facilita el diagnòstic: quan una resposta sigui dolenta, sabrem exactament què va veure el model.
  • La font viatja dins del bloc, però el camp fonts del JSON final no dependrà que el model la copiï bé: l'omplirem nosaltres des de les metadades (apartat següent). El model cita per al text de la resposta; el sistema garanteix la traçabilitat.
  • El separador --- marca fronteres clares entre fragments: barat i eficaç contra la barreja accidental de continguts.

Generar: rag.py i respondre_amb_rag()

La funció que uneix les tres fases i que els mòduls 5 i 6 faran servir com "el DocuBot complet":

# rag.py (part 3) — el pipeline complet
from prompts import prompt_resposta, PROMPT_VERSION
from docubot import respondre            # 03-02: reintents, streaming, degradació
from validacio import RESPOSTA_FALLBACK

FRASE_SENSE_INFO = "No trobo aquesta informació a la documentació disponible."

def respondre_amb_rag(pregunta: str, k: int = K_DEFECTE,
                      filtres: dict | None = None) -> dict:
    """Pipeline RAG complet: recuperar -> augmentar -> generar.

    Retorna el contracte JSON de DocuBot:
    {"categoria", "resposta", "fonts", "confianca"}
    """
    # 1) RECUPERAR
    chunks = recuperar(pregunta, k=k, filtres=filtres)

    # Guarda-rail: si ni el millor chunk és rellevant, no generem sobre soroll
    if not chunks or chunks[0]["similitud"] < LLINDAR_RELLEVANCIA:
        return {
            "categoria": "altres",
            "resposta": FRASE_SENSE_INFO,
            "fonts": [],
            "confianca": "baixa",
        }

    # 2) AUGMENTAR
    documentacio = construir_documentacio(chunks)

    # 3) GENERAR — el respondre() robust de 03-02, sense tocar
    resposta = respondre(pregunta, documentacio)

    # 4) Fonts REALS: del sistema, no de la memòria del model
    if resposta != RESPOSTA_FALLBACK:
        resposta["fonts"] = sorted({c["url"] for c in chunks})

    return resposta

Els quatre passos, comentats:

  • Pas 1 i el seu guarda-rail. Recuperar sempre retorna alguna cosa — els k veïns més propers existeixen encara que la pregunta sigui "puc pagar amb criptomonedes?". La pregunta correcta no és "hi ha resultats?" sinó "són rellevants?". Si el millor chunk no arriba al llindar, responem directament amb la frase anti-al·lucinació de la regla 3 de SYSTEM_DOCUBOT — sense gastar una crida al LLM i sense donar-li l'oportunitat d'estirar un chunk irrellevant fins a convertir-lo en una resposta plausible i falsa. Fixa't en l'ordre dels beneficis: primer honestedat, després estalvi.
  • El pas 3 hereta tota la robustesa de 03-02 gratis: reintents amb backoff+jitter, degradació a RESPOSTA_FALLBACK si tot falla, validació del contracte amb netejar_json/validar_contracte. No dupliquem ni una línia d'aquesta lògica.
  • El pas 4 és el salt de fiabilitat anunciat a 04-03: fonts deixa de ser el que el model diu haver fet servir i passa a ser el que el sistema sap que li va donar. Un set deduplicat i ordenat d'URLs de les metadades. Si demà un usuari dubta d'una resposta, aquestes URLs són verificables amb un clic.
  • I el cache? respondre_amb_rag és el lloc natural per consultar CacheSemantic (04-01) abans del pas 1 i desar després del pas 4 — ho deixem fora del llistat per claredat, i com a exercici.

Decisions de disseny: k, ordre i el llindar anti-soroll

Tres decisions que tot pipeline RAG pren, les faci explícites o no. Millor explícites:

Decisió La nostra elecció Raonament Risc si et passes / et quedes curt
Quants chunks (k)? k=4 Amb chunks de ~450 tokens (04-03), són ~1.800 tokens de context: suficient per a preguntes que toquen 2 seccions, assumible en cost (03-04) k alt: cost, dilució, "lost in the middle" (01-04). k baix: la resposta pot ser al chunk núm. 5
En quin ordre? Més rellevant primer Els models atenen millor al principi del context; el fragment 1 és el que gairebé segur es fa servir Ordre aleatori o invers regala precisió sense guanyar res
I si res no és rellevant? Llindar sobre la similitud del millor chunk → frase anti-al·lucinació Recuperar soroll i generar-hi a sobre és fabricar al·lucinacions "amb fonts"; millor un no honest Llindar alt: DocuBot es torna inútilment tímid. Llindar baix: tornen les al·lucinacions amb cita

Sobre el valor LLINDAR_RELLEVANCIA = 0.45: és un punt de partida, no una veritat. Com vam aprendre a 04-01, l'escala de similituds depèn del model d'embeddings; el valor definitiu ha de sortir de mesurar sobre el conjunt d'avaluació de 02-04 — que, recorda, té un ~20% de casos "sense resposta" precisament per a això. A 04-05 convertirem aquesta calibració en un experiment amb números.

Una menció honesta per tancar l'apartat: existeix una tècnica anomenada reranking — recuperar més candidats (p. ex. 20) i reordenar-los amb un model específic que compara pregunta i chunk amb més finor que el cosinus, quedant-se amb els 4 millors. Millora sistemes madurs, afegeix una peça i una latència més, i per a la mida del corpus de Nubelia no és la primera palanca a tocar. Sabem que existeix; continuem.

RAG en converses: integració amb Conversa

DocuBot no rep preguntes soltes: manté converses (classe Conversa i GestorSessions, 03-03). Com conviuen RAG i historial? La resposta curta és el lema que vam encunyar a 03-03: "historial per a allò dit, RAG per a allò sabut". A la pràctica:

  • S'embedeix la pregunta del torn, no l'historial. Embeddir la conversa sencera contaminaria la cerca amb els temes dels torns anteriors.
  • Però la pregunta crua de vegades no basta. Després de "com convido un membre?", l'usuari pregunta "i com l'elimino?" — un embedding de "i com l'elimino?" no trobarà la doc de gestió de membres. La solució es diu query rewriting (reescriptura de la consulta): una crida prèvia i barata al LLM que reformula la pregunta amb el context de l'historial ("com elimino un membre d'un espai de treball?") abans d'embeddir-la. Ho mencionem perquè tingui nom; una versió senzilla basta:
# rag.py (part 4) — RAG per torn dins d'una conversa
def respondre_en_conversa(conv, pregunta: str, k: int = K_DEFECTE) -> dict:
    """Un torn de conversa amb RAG: recuperar per torn, historial a part."""
    pregunta_cerca = reformular_si_cal(conv, pregunta)  # query rewriting
    chunks = recuperar(pregunta_cerca, k=k)

    if not chunks or chunks[0]["similitud"] < LLINDAR_RELLEVANCIA:
        resposta = {"categoria": "altres", "resposta": FRASE_SENSE_INFO,
                    "fonts": [], "confianca": "baixa"}
    else:
        documentacio = construir_documentacio(chunks)
        # La documentació va al torn ACTUAL; l'historial l'aporta conv
        resposta = respondre(pregunta, documentacio, historial=conv.missatges())
        if resposta != RESPOSTA_FALLBACK:
            resposta["fonts"] = sorted({c["url"] for c in chunks})

    conv.afegir_torn(pregunta, resposta)   # el truncament a 10 torns (03-03) segueix vigent
    return resposta

El punt arquitectònic important: la documentació recuperada pertany al torn, no a l'historial. Cada torn recupera el que és seu; els chunks del torn anterior no s'arrosseguen (inflarien el context i el cost, i el truncament de 03-03 els netejaria igualment). L'historial conserva allò dit — preguntes i respostes — i RAG aporta a cada torn allò sabut que aquell torn necessita.

Abans i després: la pregunta del PDF de 01-04

Tanquem el compte pendent. A 01-04, el "DocuBot ingenu" —una crida pelada al model, sense system prompt ni documentació— responia això davant d'una pregunta sobre informes:

Pregunta: "Com exporto un informe de projecte a PDF?" DocuBot ingenu (01-04): "Per exportar a PDF, ves a Informes > Exportar > PDF i selecciona la plantilla desitjada. També pots fer servir l'endpoint /clone de l'API per duplicar l'informe abans d'exportar-lo."

Dues invencions en tres línies: l'exportació a PDF no existeix i l'endpoint /clone tampoc. Plausible, fluid, fals. Ara, el mateix cas amb el pipeline complet:

from rag import respondre_amb_rag
import json

print(json.dumps(respondre_amb_rag("Com exporto un informe de projecte a PDF?"),
                 ensure_ascii=False, indent=2))
{
  "categoria": "altres",
  "resposta": "L'exportació a PDF no està suportada a Nubelia. Els informes de projecte es poden exportar a CSV i Excel des del menú Informes > Exportar (Fragment 1). Si necessites un PDF, una opció és exportar a Excel i imprimir a PDF des del teu full de càlcul.",
  "fonts": ["wiki/informes/exportacio"],
  "confianca": "alta"
}

Val la pena disseccionar per què això funciona, peça a peça i lliçó a lliçó:

  1. La recuperació (04-01/04-02) va trobar el chunk d'"Informes i exportació" — que diu explícitament que PDF no està suportat, gràcies al fet que la ingesta (04-03) va conservar la secció sencera.
  2. La regla 2 de SYSTEM_DOCUBOT (02-01) va obligar el model a cenyir-se a aquell chunk: no hi ha d'on treure un menú "Exportar > PDF" inexistent.
  3. El contracte JSON (02-03) i la seva validació defensiva van retornar una estructura processable, i fonts conté la URL real del chunk — posada pel sistema, no pel model.
  4. I si la documentació no hagués dit res sobre exportació, el llindar hauria activat la frase exacta de la regla 3: "No trobo aquesta informació a la documentació disponible."

Aquest abans/després és l'argument del curs en miniatura: la diferència entre un LLM que sona a expert en Nubelia i un sistema que ho és no és al model — és el mateix — sinó en tot el que hem construït al voltant.

Errors Comuns i Consells

  • Generar sempre, hi hagi o no hi hagi recuperació decent. És l'error més car en qualitat: el LLM convertirà soroll en prosa convincent. El llindar sobre la similitud del millor chunk és la teva xarxa; calibra'l, però no el treguis mai.
  • Deixar que el model inventi el camp fonts. Si fonts surt de la generació, tindràs cites boniques i ocasionalment falses. Omple'l des de les metadades dels chunks recuperats: és informació que el sistema posseeix amb certesa.
  • Embeddir l'historial complet per recuperar en converses. La cerca es contamina amb els temes de tots els torns. Embedeix la pregunta del torn (reformulada si és el·líptica).
  • Arrossegar els chunks de torns anteriors al context. Cost creixent i context inflat per documentació que ja no ve al cas. Documentació per torn; historial per a allò dit.
  • Tractar k i el llindar com a constants universals copiades d'un tutorial. Són funció del teu corpus, el teu chunking i el teu model d'embeddings. La lliçó 04-05 existeix per donar-los valors amb fonament.
  • Consell: registra a cada crida (com el CSV de 02-04) la pregunta, els ids i similituds dels chunks recuperats i la resposta. Quan arribi una resposta dolenta —arribarà— podràs respondre la pregunta clau del diagnòstic: va fallar la recuperació o va fallar la generació? És exactament la distinció sobre la qual es construeix la propera lliçó.

Exercicis

  1. Endollar el cache semàntic. Modifica respondre_amb_rag() per consultar una instància de CacheSemantic (04-01) abans de recuperar, i desar la resposta després de generar (només si no és RESPOSTA_FALLBACK i la seva confiança no és "baixa" — raona per què aquestes dues condicions).
  2. El llindar en acció. Amb el corpus de demo carregat, executa respondre_amb_rag() amb aquestes tres preguntes i anota similitud del millor chunk i comportament: (a) "com restableixo la contrasenya?", (b) "puc pagar Nubelia amb criptomonedes?", (c) "què en penseu de l'últim fitxatge del Reial Madrid?". Distingeix el llindar els tres casos? Quin cas de 02-04 representa cadascuna?
  3. Query rewriting mínim. Implementa reformular_si_cal(conv, pregunta): si la conversa és buida, retorna la pregunta tal qual; si no, fes servir preguntar_docubot() (03-01) amb un prompt breu que demani reescriure la pregunta com a autocontinguda fent servir l'historial. Prova-la amb el diàleg "com convido un membre?" → "i com l'elimino?".

Solucions

  1. Esquelet de la integració:
from cache_semantic import CacheSemantic
_cache = CacheSemantic(llindar=0.92, ttl_segons=3600)

def respondre_amb_rag(pregunta, k=K_DEFECTE, filtres=None):
    cachejada = _cache.obtenir(pregunta)
    if cachejada is not None:
        return cachejada
    # ... pipeline igual que abans ...
    if resposta != RESPOSTA_FALLBACK and resposta.get("confianca") != "baixa":
        _cache.desar(pregunta, resposta)
    return resposta

Per què les dues condicions: cachejar el fallback congelaria un error transitori (un pic d'errors d'API de 03-02) convertint-lo en la resposta oficial durant tot el TTL; i cachejar respostes de confiança baixa multiplica l'abast de les respostes més dubtoses del sistema — un cache ha d'amplificar els encerts, no els dubtes. Nota l'ordre: el cache es consulta abans de recuperar, així que un hit estalvia l'embedding de recuperació, la consulta a Chroma i la crida al LLM.

  1. Resultats típics: (a) similitud ~0.75-0.85 → molt per sobre del llindar, resposta normal amb font wiki/administracio/seguretat-i-acces — un cas fàcil de 02-04. (b) similitud ~0.35-0.45 → zona fronterera: hi ha chunks de facturació/plans que s'assemblen una mica; segons on caigui respecte del llindar, DocuBot respon la frase anti-al·lucinació o intenta respondre amb la doc de plans (i si el prompt funciona, dirà que no hi ha informació sobre criptomonedes) — un cas sense resposta, i la millor demostració de per què el llindar es calibra amb dades i no a ull. (c) similitud ~0.10-0.20 → clarament sota el llindar, frase anti-al·lucinació sense gastar crida — un cas fora de domini (parent dels adversarials suaus). Si el teu llindar no separa (a) de (c) amb marge, revisa abans el chunking que el llindar.

  2. Una versió mínima i suficient:

def reformular_si_cal(conv, pregunta: str) -> str:
    if not conv.missatges():
        return pregunta
    instruccio = (
        "Reescriu l'última pregunta de l'usuari com una pregunta autocontinguda, "
        "resolent pronoms i referències amb l'ajuda de l'historial. "
        "Retorna NOMÉS la pregunta reescrita.\n\n"
        f"Historial:\n{conv.resum_text()}\n\nÚltima pregunta: {pregunta}"
    )
    return preguntar_docubot(instruccio).strip()

Amb el diàleg de l'enunciat, la reformulació esperada és "Com elimino un membre d'un espai de treball a Nubelia?" — i aquesta sí que recupera la doc correcta. Cost: una crida curta extra per torn conversacional; activa-la només quan hi ha historial (la condició de l'if) per no pagar-la en preguntes soltes. El query rewriting dona per a molt més (expansió de consultes, multi-pregunta), però aquesta versió cobreix el cas que de debò trenca el RAG conversacional: les preguntes el·líptiques.

Conclusió

DocuBot ja és el que vam prometre a 01-01: un assistent que de debò coneix Nubelia. rag.py tanca el cercle amb respondre_amb_rag() — recuperar (embedding de la pregunta + top-k sobre docs_nubelia), augmentar (bloc <documentacio> numerat i amb fonts) i generar (el respondre() robust de 03-02, intacte) — retornant el contracte JSON de sempre però amb una diferència de categoria: les fonts són ara fets del sistema, no declaracions del model. Pel camí hem pres les decisions que defineixen un RAG seriós (k=4 raonat, ordre per rellevància, i el llindar que prefereix un "No trobo aquesta informació a la documentació disponible." honest abans que generar sobre soroll), hem integrat RAG amb les converses de 03-03 sota el lema "historial per a allò dit, RAG per a allò sabut" (amb query rewriting per a les preguntes el·líptiques), i hem saldat el deute de 01-04: la pregunta del PDF que abans produïa menús i endpoints inventats ara produeix una resposta correcta, amb font verificable. Però siguem honestos com ho vam ser amb el DocuBot ingenu: que el pipeline funcioni a les nostres demos no vol dir que funcioni. Recupera els chunks correctes el 95% de les vegades o el 70%? El llindar de 0.45 és savi o casualitat? Les respostes són fidels als chunks o el model continua adornant? Un sistema RAG té dos motors que poden fallar per separat — la recuperació i la generació — i avaluar-los requereix separar-los. Aquesta és exactament la feina de l'última lliçó del mòdul: avaluació de sistemes RAG (04-05).

© Copyright 2026. Tots els drets reservats