Salta al contenuto principale

97 - RAG da zero: recuperare il contesto giusto, e le risposte con una citazione verificabile

Il generatore non ripara un recupero sbagliato: tetto, segmentazione e filtri misurati, e un verificatore che rifiuta le risposte non ancorate.

Cosa ne pensi di questo articolo?

97 - RAG da zero: recuperare il contesto giusto, e le risposte con una citazione verificabile
Machine Learning Engineerfedericocalo.dev

In Breve

Il generatore non ripara un recupero sbagliato: tetto, segmentazione e filtri misurati, e un verificatore che rifiuta le risposte non ancorate.

Un modello linguistico sa quello che ha letto durante l'addestramento, e non sa niente dei tuoi documenti. La generazione aumentata dal recupero (RAG, da retrieval-augmented generation) risolve il problema nel modo più diretto: prima di chiedere al modello di rispondere, si cercano i pezzi di documento che contengono la risposta e si incollano nel prompt. L'idea ha un nome e una data - Lewis e colleghi, Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks, arXiv:2005.11401 - ed è diventata l'architettura più usata per far parlare un modello di cose che non ha mai visto.

È anche l'architettura in cui si sbaglia più spesso la diagnosi: quando un sistema RAG risponde male, l'istinto manda a riscrivere il prompt, e spesso il prompt non è il problema. Qui costruiamo un RAG pezzo per pezzo su un corpus vero, misuriamo ogni decisione - come tagliare i documenti, quanti pezzi passare, se riordinarli, se filtrarli per metadati - e aggiungiamo la cosa che manca quasi sempre: un controllo che per ogni affermazione risale allo span citato e rifiuta la risposta se numeri e nomi non compaiono letteralmente lì dentro.

Come sono scritti gli esempi, e che cosa possono dimostrare. In questa lezione non gira nessun modello. Il recupero usa un encoder finto: un hashing di parole pesate con l'IDF, deterministico, che non ha imparato niente e quindi non capisce i sinonimi. La generazione usa un generatore a copione: una funzione che prende la frase degli span con più parole in comune con la domanda e la cita. Nessuno dei due è un modello, e lo dichiaro perché le cifre che escono misurano il metodo (dove si guasta un RAG, come si vede, come si ripara), non la qualità di un modello vero: con un encoder addestrato i numeri cambierebbero, la forma dei problemi no. Il vantaggio è che tutto è rieseguibile e identico a ogni esecuzione. Tutto gira su Python 3.14.4 e NumPy 2.3.5; il corpus esce dai docstring della libreria standard di quella versione, quindi con un'altra versione i numeri saranno vicini ma non uguali.

L'analogia: l'esame a libro aperto

Immagina uno studente preparato a un esame a libro aperto. Ha capito la materia, ragiona bene, sa scrivere. Il libro è sul banco. Lo studente lo apre a tre pagine, le legge, e risponde usando solo quelle.

Se le tre pagine sono quelle giuste la risposta è ottima: non deve ricordare nulla, deve leggere e sintetizzare. Se sono quelle sbagliate - argomento vicino, capitolo adiacente, ma non la pagina con la definizione richiesta - accade una di due cose, entrambe guasti: o ammette che quelle pagine non rispondono, o costruisce una risposta plausibile con quello che ha davanti. Nessun miglioramento del suo ragionamento lo salva, perché il limite è nella scelta delle pagine.

In un RAG il modello è lo studente e il recupero è la mano che apre il libro. Da qui la tesi di questa lezione, che vale la pena scrivere in una riga: il generatore non può riparare un recupero sbagliato. Non è un'opinione sull'architettura, è un limite misurabile, e il primo mestiere da imparare è misurarlo in un minuto invece di passare un pomeriggio sul prompt.

Le tre fasi, e il numero da guardare prima di ogni altro

Un RAG ha tre fasi. Indicizzazione, una volta sola: i documenti si tagliano in pezzi (chunk), ogni pezzo diventa un vettore, i vettori vanno in un indice. Recupero, a ogni domanda: la domanda diventa un vettore e l'indice restituisce i k pezzi più vicini. Generazione: i k pezzi si impaginano in un prompt e il modello risponde.

Dove si misura il tetto in una pipeline RAG Tre blocchi in sequenza con la misura del tetto collocata fra il secondo e il terzo. 1. INDICIZZAZIONE una volta sola documenti → chunk → vettori + metadati nell'indice 2. RECUPERO a ogni domanda filtra, cerca k=50, riordina tiene i k=3 migliori 3. GENERAZIONE a ogni domanda impagina, vincola, cita poi si verifica, o si rifiuta TETTO si misura qui Il tetto e' la percentuale di domande per cui la risposta e' DENTRO i k chunk consegnati al modello. Nessun prompt, nessuna temperatura e nessun modello piu' grande portano l'accuratezza sopra quel valore.
Il tetto si misura fra la seconda e la terza fase: è il numero che dice se il problema è nel recupero o nella generazione.

Il guasto tipico sta nella seconda fase e si manifesta nella terza: è per questo che viene diagnosticato male. La misura che le separa si chiama tetto: la percentuale di domande per cui la risposta è dentro i k chunk consegnati al modello. Se il tetto è 0.50, metà delle domande arrivano al generatore senza la risposta nel contesto, e su quella metà il modello può solo astenersi o inventare. È un limite superiore duro.

Il corpus, il golden set e il tetto in dieci righe

Per misurare il tetto servono documenti e domande con la risposta nota. Il corpus si costruisce offline: ogni documento è un modulo della libreria standard, cioè il suo docstring più quelli pubblici delle funzioni e classi che definisce, concatenati. Sono documenti lunghi come quelli veri, non frammenti da cinquanta parole, ed è la loro lunghezza che rende la segmentazione una decisione.

import collections, hashlib, importlib, inspect, math, re, statistics, time
import numpy as np

MODULI = ["statistics", "bisect", "heapq", "itertools", "functools", "difflib",
          "textwrap", "tempfile", "uuid", "base64", "secrets", "gzip", "math",
          "glob", "zlib", "operator", "fractions", "fnmatch"]

def normalizza(s):
    return " ".join(s.split())

def costruisci_corpus():
    documenti, visti = [], set()
    for nome in MODULI:
        m = importlib.import_module(nome)
        parti = [f"MODULE {nome}"]
        if inspect.getdoc(m):
            parti.append(inspect.getdoc(m).strip())
        for attr, obj in sorted(vars(m).items()):
            if attr.startswith("_"):
                continue
            if not (inspect.isfunction(obj) or inspect.isclass(obj) or inspect.isbuiltin(obj)):
                continue
            doc = inspect.getdoc(obj)
            if not doc or len(doc.split()) < 20:
                continue
            casa = obj.__module__.lstrip("_")               # il modulo che la definisce
            if casa != nome and casa in MODULI:             # ri-esportata da un modulo che
                continue                                    # sta nel corpus: la tiene il suo
            impronta = (obj.__module__, obj.__qualname__)   # proprietario; poi una volta sola
            if impronta in visti:
                continue
            visti.add(impronta)
            parti.append(f"## {nome}.{attr}\n\n{doc.strip()}")
        documenti.append({"id": nome, "testo": "\n\n".join(parti), "stato": "corrente"})
    return documenti

Due dettagli del codice evitano errori silenziosi. Il controllo su __module__ esclude le funzioni che un modulo importa da un altro: statistics importa bisect_left e Fraction, e senza il controllo il primo modulo della lista se le terrebbe, lasciando bisect e fractions senza le proprie funzioni (è un difetto che ho trovato proprio così, con un'ancora che non si trovava). E il filtro sulle venti parole scarta i docstring di una riga, che non contengono una risposta ma solo un'etichetta.

Poi il golden set. Ogni domanda porta con sé un'ancora: la stringa letterale, presa dal docstring, che contiene la risposta. Un chunk conta come corretto solo se la contiene tutta. È severo e per questo utile: distingue «il chunk viene dal documento giusto» da «il chunk contiene la frase che risponde», due cose che si confondono in continuazione. Alle diciotto domande ho aggiunto dieci note scritte a mano che affermano il contrario della libreria: bozze e versioni superate, marcate nel campo stato. Un corpus senza contraddizioni è un corpus finto: nei repository veri convivono la documentazione corrente, quella di tre versioni fa e gli appunti di qualcuno.

# (domanda, modulo, ancora): l'ancora e' la stringa letterale che contiene la risposta
GOLDEN = [
    ("What is the default cutoff of get_close_matches?", "difflib",
     "Optional arg cutoff (default 0.6)"),
    ("Which position does bisect_left return when x is already in the list?", "bisect",
     "insert just before the leftmost x already there"),
    ("Where does insort_right put an item that is already present?", "bisect",
     "insert it to the right of the rightmost x"),
    ("How does heappushpop compare with heappush followed by heappop?", "heapq",
     "runs more efficiently than heappush() followed by a separate call to heappop()"),
    ("What does fmean do when the dataset is empty?", "statistics",
     "If the input dataset is empty, it raises a StatisticsError."),
    ("What is the average speed from harmonic_mean for 40 km/hr and 60 km/hr over equal distances?", "statistics",
     "harmonic_mean([40, 60]) 48.0"),
    ("What does median_low return with an even number of data points?", "statistics",
     "the smaller of the two middle values is returned"),
    ("How does textwrap shorten treat whitespace in the text?", "textwrap",
     "The text first has its whitespace collapsed."),
    ("Who can read the directory created by mkdtemp?", "tempfile",
     "readable, writable, and searchable only by the creating user"),
    ("Is a NamedTemporaryFile deleted automatically by default?", "tempfile",
     "whether the file is automatically deleted (default True)"),
    ("Why use compare_digest instead of comparing secrets with ==?", "secrets",
     "designed to prevent timing analysis"),
    ("What happens to the last batch of itertools batched when the input runs out?", "itertools",
     "The last batch may be shorter than n."),
    ("What does lru_cache do when maxsize is None?", "functools",
     "the LRU features are disabled and the cache can grow without bound"),
    ("Which characters replace plus and slash in urlsafe_b64encode?", "base64",
     "uses '-' instead of '+' and '_' instead of '/'"),
    ("Does the star wildcard of glob match filenames that start with a dot?", "glob",
     "filenames starting with a dot are special cases that are not matched by '*' and '?'"),
    ("Does gzip compress store the modification time of the data?", "gzip",
     "The modification time is set to 0 by default, for reproducibility."),
    ("What does math comb return when k is greater than n?", "math",
     "evaluates to zero when k > n"),
    ("What rational number does Fraction(8, 6) produce?", "fractions",
     "Fraction(8, 6) will produce a rational number equivalent to 4/3"),
]
DOMANDE = [g[0] for g in GOLDEN]
ANCORE = [g[2] for g in GOLDEN]

# dieci note scritte a mano che affermano il contrario: bozze o versioni superate
NOTE = [
    ("nota-difflib", "bozza", "NOTE (draft): the default cutoff of get_close_matches is 0.9, so only near identical words are returned."),
    ("nota-bisect-1", "obsoleta", "NOTE (old version): bisect_left returns the position after the rightmost x already in the list when x is already in the list."),
    ("nota-bisect-2", "bozza", "NOTE (draft): insort_right puts an item that is already present before the leftmost equal item."),
    ("nota-heapq", "obsoleta", "NOTE (old version): heappushpop is slower than heappush followed by heappop, and the two differ in cost."),
    ("nota-statistics-1", "bozza", "NOTE (draft): fmean returns 0.0 when the dataset is empty and raises nothing."),
    ("nota-statistics-2", "bozza", "NOTE (draft): median_low with an even number of data points returns the larger of the two middle values."),
    ("nota-tempfile-1", "obsoleta", "NOTE (old version): the directory created by mkdtemp is readable by every user of the machine."),
    ("nota-tempfile-2", "bozza", "NOTE (draft): a NamedTemporaryFile is never deleted automatically by default, call os.remove yourself."),
    ("nota-functools", "obsoleta", "NOTE (old version): lru_cache with maxsize None keeps the LRU features enabled and evicts the oldest entry."),
    ("nota-gzip", "bozza", "NOTE (draft): gzip compress stores the current time as the modification time of the data."),
]

Il resto della costruzione costruisce il corpus, controlla ogni ancora e aggiunge le note; il blocco stampa le dimensioni che contano per leggere le misure successive (un corpus con documenti da 230 parole si comporta in modo diverso da uno con documenti da 5000).

documenti = costruisci_corpus()
by_id = {d["id"]: d for d in documenti}
for domanda, modulo, ancora in GOLDEN:
    assert normalizza(ancora) in normalizza(by_id[modulo]["testo"]), (modulo, ancora)
documenti += [{"id": i, "testo": t, "stato": s} for i, s, t in NOTE]

parole_doc = [len(d["testo"].split()) for d in documenti]
print(f"documenti: {len(documenti) - len(NOTE)} moduli + {len(NOTE)} note = {len(documenti)}")
print(f"parole totali {sum(parole_doc)} | mediana per documento {int(statistics.median(parole_doc))} | max {max(parole_doc)}")
print(f"domande del golden set: {len(GOLDEN)}")
documenti: 18 moduli + 10 note = 28
parole totali 12822 | mediana per documento 230 | max 2989
domande del golden set: 18

Il golden set ha 18 domande, e sono poche: lo dichiaro invece di nasconderlo. Il passo minimo è 1/18 ≈ 0,056, quindi un punto percentuale non esiste, e una differenza di una domanda su diciotto non separa due configurazioni. L'assert in fondo alla costruzione è la parte che non va saltata: se un'ancora non compare nel documento, la domanda è invalida, e il tetto verrebbe sottostimato per un errore tuo e non del recupero. Il dimensionamento serio di un golden set è l'argomento della lezione sulla valutazione dei sistemi generativi; qui serve a mostrare che si misura, e come.

L'encoder finto, l'indice e il tetto

L'encoder finto fa tre cose: spezza il testo in parole (separando anche il camelCase), toglie le parole vuote, e proietta ogni parola in una delle 1024 posizioni del vettore con un segno scelto dall'hash. Il peso è (1 + log c) · idf, dove l'IDF è calcolato sui chunk dell'indice: è la ragione per cui adatta va chiamato con i chunk di quella configurazione. Non è un'approssimazione di un modello vero: è un recupero lessicale travestito da vettore, e va letto così. Un encoder addestrato troverebbe anche «star» per «asterisco»; questo no.

STOP = set("a an and are as at be by for from if in into is it its of on or that the this to when with "
           "what which who how does do not can than then there their was were will".split())

def parole(testo):
    testo = re.sub(r"([a-z])([A-Z])", r"\1 \2", testo)           # camelCase -> camel Case
    return [w for w in re.findall(r"[a-z0-9]+", testo.lower()) if w not in STOP]

def _h(s):
    return int.from_bytes(hashlib.blake2b(s.encode(), digest_size=8).digest(), "big")

class EncoderFinto:
    """Encoder FINTO: hashing con segno delle parole, pesate con l'IDF dei chunk. Nessun modello."""
    def __init__(self, dim=1024):
        self.dim, self.idf, self.idf_ignoto = dim, {}, 1.0

    def adatta(self, testi):
        df = collections.Counter()
        for t in testi:
            df.update(set(parole(t)))
        n = len(testi)
        self.idf = {w: math.log((1 + n) / (1 + c)) + 1 for w, c in df.items()}
        self.idf_ignoto = math.log(1 + n) + 1       # parola mai vista nei chunk: la piu' rara
        return self

    def codifica(self, testi):
        E = np.zeros((len(testi), self.dim), dtype=np.float32)
        for r, t in enumerate(testi):
            for w, c in collections.Counter(parole(t)).items():
                h = _h(w)
                E[r, h % self.dim] += (1 if (h >> 40) & 1 else -1) * (1 + math.log(c)) * self.idf.get(w, self.idf_ignoto)
        n = np.linalg.norm(E, axis=1, keepdims=True)
        return E / np.where(n == 0, 1, n)

Prima della ricerca servono i chunker, cioè le tre strategie con cui si taglia un documento. Poi servono due funzioni: l'indice e la ricerca. Il punto delicato è il filtro: cerca lo applica prima di ordinare, mettendo a meno infinito il punteggio degli esclusi, così che non competano per le k posizioni. Filtrare dopo - recuperare i primi k e poi scartare - lascerebbe meno di k risultati utili, o nessuno.

def finestra_fissa(testo, dim, sovrapposizione):
    """Finestre di `dim` parole che scorrono di `dim - sovrapposizione`."""
    parole_ = testo.split()
    passo = max(1, dim - sovrapposizione)         # max(1, ...): con sovrapposizione >= dim il
    return [" ".join(parole_[i:i + dim])          # passo sarebbe zero e range() si fermerebbe
            for i in range(0, len(parole_), passo)  # con un errore invece di avanzare
            if parole_[i:i + dim]] or [testo]

def per_struttura(testo, dim_max):
    """Taglia sui confini di paragrafo; accorpa i paragrafi corti fino a dim_max parole."""
    paragrafi = [p.strip() for p in re.split(r"\n\s*\n", testo) if p.strip()]
    pezzi, corrente = [], []
    for p in paragrafi:
        if corrente and sum(len(x.split()) for x in corrente) + len(p.split()) > dim_max:
            pezzi.append("\n\n".join(corrente))
            corrente = []
        corrente.append(p)
    return pezzi + (["\n\n".join(corrente)] if corrente else [])

def per_frase(testo, frasi_per_pezzo, frasi_sovrapposte):
    """Finestra di N frasi che scorre di N meno le frasi sovrapposte."""
    frasi = [f.strip() for f in re.split(r"(?<=[.!?:])\s+", testo.replace("\n", " ")) if f.strip()]
    passo = max(1, frasi_per_pezzo - frasi_sovrapposte)
    return [" ".join(frasi[i:i + frasi_per_pezzo])
            for i in range(0, len(frasi), passo) if frasi[i:i + frasi_per_pezzo]] or [testo]

La finestra fissa è la più semplice e la più cieca: taglia dove cade il conteggio. La segmentazione per struttura rispetta i paragrafi e accorpa quelli corti fino al tetto di parole; è la più sensata se il testo ha paragrafi, e infatti i docstring li hanno. Quella per frase sposta la finestra di frasi anziché di parole, e produce pezzi piccoli e numerosi. Il max(1, ...) nei passi non è pedanteria: con una sovrapposizione pari alla finestra il passo sarebbe zero e range si fermerebbe con un errore, nel mezzo di un'indicizzazione.

def indicizza(documenti, tagliatore):
    chunk = [{"testo": t, "doc": d["id"], "stato": d["stato"]}
             for d in documenti for t in tagliatore(d["testo"])]
    enc = EncoderFinto().adatta([c["testo"] for c in chunk])
    return {"chunk": chunk, "enc": enc, "M": enc.codifica([c["testo"] for c in chunk])}

def cerca(indice, domanda, k, solo_stato=None):
    """I k chunk piu' vicini. Il filtro si applica PRIMA di ordinare: gli esclusi non competono."""
    punteggi = indice["M"] @ indice["enc"].codifica([domanda])[0]
    if solo_stato is not None:
        ammesso = np.array([c["stato"] == solo_stato for c in indice["chunk"]])
        punteggi = np.where(ammesso, punteggi, -np.inf)
    ordine = np.argsort(-punteggi, kind="stable")[:k]
    return [(indice["chunk"][i], float(punteggi[i])) for i in ordine if np.isfinite(punteggi[i])]

Ora la misura che dà il nome alla lezione. La funzione conta quante domande hanno l'ancora dentro uno dei primi k chunk recuperati.

def tetto(recuperati, ancore, k):
    """Frazione di domande per cui l'ancora e' dentro uno dei primi k chunk."""
    colpi = 0
    for chunk_query, ancora in zip(recuperati, ancore):
        bersaglio = normalizza(ancora)                  # spazi collassati su entrambi i lati:
        if any(bersaglio in normalizza(c["testo"])      # l'ancora ha gli a capo del docstring,
               for c, _ in chunk_query[:k]):            # il chunk no, e sono lo stesso testo
            colpi += 1
    return colpi / len(ancore)

def recall(indice, k, solo_stato=None):
    return tetto([cerca(indice, q, k, solo_stato) for q in DOMANDE], ANCORE, k)

Dieci righe: tutto il codice che serve per sapere, prima di scrivere un prompt, se il problema è nel recupero. La normalizzazione degli spazi su entrambi i lati non è pedanteria: l'ancora ha gli a capo del docstring, il chunk no, e senza normalizzare il tetto risulta vicino a zero e si cerca per un pomeriggio un guasto nel recupero che sta nel confronto fra stringhe. Il primo risultato, con finestre da 160 parole sovrapposte di 32 e nessun filtro:

ix = indicizza(documenti, lambda t: finestra_fissa(t, 160, 32))
recuperati = [cerca(ix, q, 50) for q in DOMANDE]
for k in (1, 3, 5, 10, 50):
    print(f"k={k:>2}  tetto = {tetto(recuperati, ANCORE, k):.3f}")
k= 1  tetto = 0.389
k= 3  tetto = 1.000
k= 5  tetto = 1.000
k=10  tetto = 1.000
k=50  tetto = 1.000

Il tetto a k = 3 è 1,000: la risposta è sempre fra i primi tre chunk. A k = 1 è 0,389: il chunk giusto è primo meno di quattro volte su dieci. Dalla distanza fra i due numeri si legge una diagnosi precisa, che nessun prompt cambia: il recuperatore trova la risposta, la mette solo nel posto sbagliato. Perché, lo vediamo fra poco: nei primi posti stanno anche le note contraddittorie, che parlano delle stesse parole. Un generatore che leggesse solo il primo chunk risponderebbe bene meno di quattro volte su dieci, qualunque fosse il suo prompt.

Esercizio 1

Il tetto che non si può superare

Un sistema RAG risponde correttamente al 42% di un golden set da 200 query. Passa k = 5 chunk al modello, e il tetto misurato a k = 5 è 0,55.

  1. Qual è il massimo guadagno che puoi ottenere lavorando solo sul prompt?
  2. Un collega propone di passare a un modello tre volte più grande. Quanto può valere, al massimo?
  3. Misuri il tetto a k = 50 e viene 0,91. Che cosa ti dice questo numero, e su quale componente ti manda a lavorare?

Prima di aprire la soluzione scrivi i tre numeri: il tetto è un tetto sulla percentuale di risposte corrette, e la sottrazione fra tetto e punteggio attuale è già la risposta alla prima domanda.

Mostra la soluzione

1. Al massimo 0,55 − 0,42 = 0,13, tredici punti, e solo nel caso limite in cui il prompt perfetto estragga la risposta da tutte le query che ce l'hanno nel contesto. Sul 45% restante una risposta corretta sarebbe un'invenzione fortunata.

2. Lo stesso tetto, quindi gli stessi tredici punti: il modello più grande legge meglio quello che gli dai, non indovina quello che non gli hai dato. È la ragione per cui il tetto si misura prima di decidere un aggiornamento di modello.

3. Che il recuperatore trova la risposta nel 91% dei casi ma la colloca oltre la quinta posizione: non è un problema di indicizzazione né di segmentazione, è un problema di ordinamento. È il caso in cui un riordino su 50 candidati recupera gran parte dei 36 punti fra 0,55 e 0,91, e in cui lavorare sul prompt sarebbe tempo buttato.

Segmentazione: tre strategie, scelte con un'ablazione

Tagliare un documento in chunk è la decisione più sottovalutata della pipeline, perché avviene una volta sola e non produce errori. Tre strategie coprono quasi tutti i casi, e il loro codice è nella sezione precedente. Qui l'unità è la parola, non il token: un tokenizzatore vero non c'è, e la proporzione fra i due dipende dal modello (la lezione sulla tokenizzazione misura quanto).

Non si scelgono per intuizione: si misurano. Ablazione sul corpus da 28 documenti, con l'encoder finto e nessun filtro. Il codice fa girare quattro configurazioni; le colonne sono quanti chunk ne escono, quante parole in media, e quanto vale il richiamo (recall@k: la frazione di domande con l'ancora nei primi k).

print(f"{'strategia':<34}{'chunk':>6}{'parole/chunk':>13}{'recall@1':>9}{'recall@3':>9}")
for nome, taglia in [("finestra fissa 160, sovr. 0", lambda t: finestra_fissa(t, 160, 0)),
                     ("finestra fissa 160, sovr. 32", lambda t: finestra_fissa(t, 160, 32)),
                     ("per struttura (paragrafi, max 160)", lambda t: per_struttura(t, 160)),
                     ("per frase (4 frasi, 1 sovrapposta)", lambda t: per_frase(t, 4, 1))]:
    i2 = indicizza(documenti, taglia)
    lung = [len(c["testo"].split()) for c in i2["chunk"]]
    print(f"{nome:<34}{len(lung):>6}{sum(lung) / len(lung):>13.1f}{recall(i2, 1):>9.3f}{recall(i2, 3):>9.3f}")
strategia                          chunk parole/chunk recall@1 recall@3
finestra fissa 160, sovr. 0           96        133.6    0.333    0.944
finestra fissa 160, sovr. 32         116        134.7    0.389    1.000
per struttura (paragrafi, max 160)   104        123.3    0.444    1.000
per frase (4 frasi, 1 sovrapposta)   343         49.9    0.389    0.889

La lettura onesta è che a k = 3 le prime tre strategie sono quasi alla pari (0,944, 1,000, 1,000) e che la differenza fra 0,944 e 1,000 è una domanda su diciotto. Quella per frase, con 343 chunk contro 96-116, è l'unica che perde davvero (0,889) e costa tre volte l'indice: pezzi da cinquanta parole non portano il contorno che serve a un termine per distinguere una funzione dall'altra. A k = 1 i valori si muovono fra 0,333 e 0,444 ma è un segnale più debole ancora, per lo stesso motivo. La regola da portare via: se i documenti hanno una struttura, usala, perché a parità di richiamo non costa niente; ma non credere a una classifica fra due configurazioni che differiscono di una domanda.

La seconda ablazione separa la dimensione del chunk dalla sovrapposizione, a parità di strategia (finestra fissa, sovrapposizione del 20%).

print(f"{'dim':>5}{'sovr.':>6}{'chunk':>6}{'rec@1':>7}{'rec@3':>7}{'rec@5':>7}{'rec@50':>8}")
for dim, sov in [(40, 0), (40, 8), (80, 0), (80, 16), (160, 0), (160, 32), (320, 0), (320, 64)]:
    i2 = indicizza(documenti, lambda t: finestra_fissa(t, dim, sov))
    print(f"{dim:>5}{sov:>6}{len(i2['chunk']):>6}{recall(i2, 1):>7.3f}{recall(i2, 3):>7.3f}"
          f"{recall(i2, 5):>7.3f}{recall(i2, 50):>8.3f}")
  dim sovr. chunk  rec@1  rec@3  rec@5  rec@50
   40     0   334  0.222  0.611  0.722   0.833
   40     8   413  0.222  0.611  0.778   0.889
   80     0   175  0.389  0.889  0.944   1.000
   80    16   214  0.333  0.944  1.000   1.000
  160     0    96  0.333  0.944  1.000   1.000
  160    32   116  0.389  1.000  1.000   1.000
  320     0    58  0.444  0.944  1.000   1.000
  320    64    67  0.389  1.000  1.000   1.000

Due letture. La prima è che in questo corpus i chunk piccoli perdono: a 40 parole il recall@5 è 0,722-0,778 e il recall@50 si ferma a 0,833-0,889, cioè in due o tre domande su diciotto la risposta non esiste in nessun chunk recuperabile, perché l'ancora è stata spezzata. Il motivo è la lunghezza dei documenti: la mediana è 230 parole, e un chunk da 40 non contiene né il nome della funzione né il suo valore. Il risultato si capovolge rispetto a quello che si legge spesso sui corpora di pagine web, dove i chunk grandi diluiscono il vettore, e ricorda che la dimensione giusta dipende dalla lunghezza e dalla densità dei tuoi documenti: va misurata, non copiata. La seconda lettura è la sovrapposizione: a 80 e 160 parole alza il recall@3 (0,889 a 0,944, 0,944 a 1,000), e a 40 non basta. Il perché è nella prossima sezione.

Il chunk che taglia una definizione a metà

C'è un guasto della finestra fissa che non si vede in nessuna media, perché colpisce poche domande e le colpisce in modo totale. Se il confine fra due chunk cade dentro la frase che contiene la risposta, quella frase non esiste più intera da nessuna parte: metà nel chunk i, metà nel chunk i+1. E l'indice si costruisce senza un errore e senza un avviso.

Si misura spazzando le dimensioni di finestra da 20 a 400 parole (venti valori) e contando le ancore che non stanno intere in nessun chunk del loro documento: 18 ancore per 20 dimensioni, 360 prove, senza sovrapposizione e con una sovrapposizione del 20% della finestra. Il codice stampa anche il primo taglio concreto che trova da 60 parole in su.

def tagliate(dim, sov):
    n = 0
    for _, modulo, ancora in GOLDEN:
        pezzi = finestra_fissa(by_id[modulo]["testo"], dim, sov)
        n += not any(normalizza(ancora) in normalizza(p) for p in pezzi)
    return n
def n_chunk(dim, sov):
    return sum(len(finestra_fissa(d["testo"], dim, sov)) for d in documenti)
dims = range(20, 401, 20)
senza = [tagliate(d, 0) for d in dims]
con = [tagliate(d, d // 5) for d in dims]
print(f"{'dim':>5}{'tagliate senza sovr.':>22}{'tagliate con 20%':>18}")
for d, a, b in zip(dims, senza, con):
    if a or b:
        print(f"{d:>5}{a:>22}{b:>18}")
print(f"totale prove: {len(dims)} dimensioni x {len(GOLDEN)} ancore = {len(dims) * len(GOLDEN)}")
print(f"ancore tagliate SENZA sovrapposizione: {sum(senza)}/{len(dims) * len(GOLDEN)}")
print(f"ancore tagliate CON sovrapposizione del 20%: {sum(con)}/{len(dims) * len(GOLDEN)}")
c0 = sum(n_chunk(d, 0) for d in dims); c1 = sum(n_chunk(d, d // 5) for d in dims)
print(f"costo della sovrapposizione: {c1} chunk contro {c0} = +{100 * (c1 - c0) / c0:.1f}% di indice")
def primo_taglio(dim_minima):
    for d in dims:
        if d < dim_minima:
            continue
        for _, modulo, ancora in GOLDEN:
            parole_doc = by_id[modulo]["testo"].split()
            a = normalizza(ancora).split()
            for i in range(len(parole_doc) - len(a) + 1):
                if parole_doc[i:i + len(a)] == a:
                    b = (i // d + 1) * d                    # primo confine dopo l'inizio
                    if i < b < i + len(a):
                        return d, modulo, ancora, parole_doc[b - 6:b], parole_doc[b:b + 6]
d, modulo, ancora, coda, testa = primo_taglio(60)
print(f"esempio a dim {d}, modulo {modulo}, ancora: {ancora!r}")
print("  coda del chunk i   :", "..." + " ".join(coda))
print("  testa del chunk i+1:", " ".join(testa) + " ...")
  dim  tagliate senza sovr.  tagliate con 20%
   20                     3                 4
   40                     3                 2
   60                     1                 0
  100                     1                 0
  120                     1                 0
  200                     1                 0
  260                     1                 0
totale prove: 20 dimensioni x 18 ancore = 360
ancore tagliate SENZA sovrapposizione: 11/360
ancore tagliate CON sovrapposizione del 20%: 6/360
costo della sovrapposizione: 3215 chunk contro 2646 = +21.5% di indice
esempio a dim 60, modulo difflib, ancora: 'Optional arg cutoff (default 0.6)'
  coda del chunk i   : ...> 0. Optional arg cutoff (default
  testa del chunk i+1: 0.6) is a float in [0, ...

Senza sovrapposizione si tagliano 11 ancore su 360 (il 3,1%); con il 20% se ne tagliano 6 (l'1,7%), e tutte le sei stanno a 20 e 40 parole. Sopra le 60 parole la sovrapposizione azzera i tagli, ma sotto no, e il motivo è un criterio che si può calcolare: la sovrapposizione deve essere almeno lunga come l'ancora più lunga che vuoi proteggere. A 20 parole la sovrapposizione è di 4, a 40 di 8, e un'ancora come «Optional arg cutoff (default 0.6)» ne ha sei: la sua frase può ancora cadere a cavallo. Il costo è un indice più grande del 21,5% (3215 chunk contro 2646, sommando le venti dimensioni), e la stessa frase ora presente in due chunk può occupare due delle k posizioni, un effetto collaterale di cui va tenuto conto quando si sceglie k.

L'esempio stampato è concreto: a 60 parole il nome del parametro, cutoff, sta in fondo a un chunk e il suo valore, 0.6, in testa al successivo. Nessuno dei due risponde, e il secondo non contiene la parola cutoff, quindi non viene nemmeno recuperato per quella domanda. Una manciata di domande che non funzioneranno mai, con qualunque prompt, mentre la media sta bene: è questo il tipo di guasto che solo un golden set con ancore letterali rende visibile.

Metadati e filtri: gli errori che si evitano senza cercarli

Una parte degli errori di un RAG non ha niente a che vedere con la somiglianza semantica. Un documento non visibile a quell'utente, una versione superata, una bozza, un documento scaduto: sono tutti pertinenti alla domanda, quindi un recupero per somiglianza li classifica in alto - ed è il comportamento corretto di un sistema che misura la somiglianza. Il modo di escluderli non è insegnare al vettore a distinguerli: è non metterli fra i candidati.

Per questo i metadati vanno nell'indice, accanto al vettore, e il filtro si applica prima della ricerca. Gli indici vettoriali veri lo prevedono: la documentazione di Redis descrive le ricerche vettoriali con filtri «per restringere lo spazio di ricerca» su campi di testo, numerici, geografici e TAG. Lo schema della richiesta è questo (schema da adattare, non eseguito: qui non c'è nessun Redis in ascolto):

# schema da adattare, non eseguito (sintassi dalla documentazione Redis)
FT.CREATE idx:documenti ON HASH PREFIX 1 doc: SCHEMA
    stato TAG
    testo TEXT
    vettore VECTOR HNSW 6 TYPE FLOAT32 DIM 1024 DISTANCE_METRIC COSINE

FT.SEARCH idx:documenti "(@stato:{corrente})=>[KNN 3 @vettore $q AS distanza]"
    PARAMS 2 q <blob-del-vettore> DIALECT 2

Il campo è un TAG e non un TEXT perché è un valore esatto («corrente» o «bozza»), non un testo da tokenizzare. Nel nostro codice lo stesso filtro è il parametro solo_stato di cerca. Il blocco che segue misura quanto vale, con le note contraddittorie nel corpus:

ix = indicizza(documenti, lambda t: finestra_fissa(t, 160, 32))
esclusi = sum(c["stato"] != "corrente" for c in ix["chunk"])
print(f"chunk esclusi dal filtro: {esclusi}/{len(ix['chunk'])} ({100 * esclusi / len(ix['chunk']):.2f}%)")
for etichetta, f in (("nessun filtro", None), ("stato = corrente", "corrente")):
    top3 = [cerca(ix, q, 3, f) for q in DOMANDE]
    contraddette = sum(any(c["stato"] != "corrente" for c, _ in t) for t in top3)
    print(f"{etichetta:<18} recall@1 {recall(ix, 1, f):.3f} | recall@3 {recall(ix, 3, f):.3f} | "
          f"domande con un chunk contraddittorio nei primi 3: {contraddette}/{len(DOMANDE)}")
chunk esclusi dal filtro: 10/116 (8.62%)
nessun filtro      recall@1 0.389 | recall@3 1.000 | domande con un chunk contraddittorio nei primi 3: 10/18
stato = corrente   recall@1 0.889 | recall@3 1.000 | domande con un chunk contraddittorio nei primi 3: 0/18

Dieci chunk su 116 (l'8,62% dell'indice) sono note superate. Senza filtro, in 10 domande su 18 almeno uno dei tre chunk consegnati al modello afferma il contrario della libreria; con il filtro, zero. Il recall@3 non cambia (resta 1,000) ma il recall@1 sale da 0,389 a 0,889: i chunk contraddittori condividono le parole della domanda, quindi stanno spesso in cima, e il filtro è la mossa che sistema l'ordinamento che prima sembrava un problema del recuperatore. La regola da portare via: il filtro sui metadati elimina interi insiemi di errori a costo quasi nullo. Farlo a valle non funziona: hai già consumato le k posizioni con i documenti che volevi escludere. Il rovescio è che un chunk filtrato non esiste più per nessuna domanda: se il campo è sbagliato, o se la bozza era l'unico documento che rispondeva, il richiamo scende, e il sintomo è «quella risposta non la trova mai».

Riordino: due passate indipendenti contro una passata sulla coppia

Un indice vettoriale usa un bi-encoder: il documento diventa vettore quando lo indicizzi, la domanda quando arriva, e le due trasformazioni sono indipendenti. È ciò che rende l'indice possibile, e insieme il suo limite: quando il documento diventa un vettore la domanda non esiste ancora, quindi la compressione non può sapere quale parte del documento sarà quella rilevante.

Un cross-encoder fa il contrario: prende la coppia (domanda, chunk) e la elabora in una passata sola, con i termini della domanda e quelli del documento che si guardano a vicenda. È più accurato e non è indicizzabile, perché va eseguito su ogni coppia al momento della domanda (Nogueira e Cho, Passage Re-ranking with BERT, arXiv:1901.04085). Da qui lo schema a due stadi: il bi-encoder recupera largo, il cross-encoder riordina stretto.

Un cross-encoder neurale qui non c'è, e lo sostituisco con un surrogato lessicale, dichiarato come tale: un punteggio congiunto che guarda copertura dei termini della domanda, bigrammi condivisi e prossimità dei termini dentro il chunk. Non è un modello addestrato, ma è un calcolo sulla coppia, che è la proprietà da capire. I pesi 0,55 / 0,30 / 0,15 sono una scelta mia, non ottimizzata.

def riordina_lessicale(query, candidati):
    """Surrogato di cross-encoder: UNA passata sulla COPPIA (query, chunk). Non e' un modello."""
    qt = parole(query)                            # termini della domanda, senza stopword
    qbi = set(zip(qt, qt[1:]))                    # bigrammi della domanda
    fuori = []
    for c, sim in candidati:
        ct = parole(c["testo"])
        pos = {}
        for i, w in enumerate(ct):
            pos.setdefault(w, []).append(i)
        trovati = [w for w in qt if w in pos]
        copertura = len(set(trovati)) / len(set(qt))
        bigrammi = len(qbi & set(zip(ct, ct[1:]))) / max(1, len(qbi))
        if len(trovati) >= 2:                     # i termini della domanda stanno vicini
            indici = sorted(pos[w][0] for w in set(trovati))   # nel chunk, o sparsi?
            prossimita = len(indici) / (indici[-1] - indici[0] + 1)
        else:
            prossimita = 0.0
        fuori.append((c, 0.55 * copertura + 0.30 * bigrammi + 0.15 * prossimita, sim))
    fuori.sort(key=lambda x: (-x[1], -x[2]))      # a pari punteggio decide il bi-encoder
    return [(c, s) for c, s, _ in fuori]

def recupera(indice, domanda, k_candidati=50, k_finale=5, riordina=True, solo_stato=None):
    candidati = cerca(indice, domanda, k_candidati, solo_stato)
    if riordina:
        candidati = riordina_lessicale(domanda, candidati)
    return candidati[:k_finale]

Il recupero diventa: cerca k = 50 candidati col bi-encoder, riordina, tieni i primi pochi. Il blocco confronta tre dimensioni di chunk, sempre con il filtro attivo, e poi cronometra le due vie: per ogni configurazione calcola il richiamo del solo bi-encoder e quello dopo il riordino, a k = 1 e k = 3, più il recall@50 che fa da tetto dei candidati. Il cronometro ripete ogni domanda venti volte e riporta il minimo e il massimo di tre misure.

print(f"{'chunk':>10}{'rec@50':>8}{'rec@1 bi':>10}{'rec@1 riord.':>14}{'rec@3 bi':>10}{'rec@3 riord.':>14}")
for dim, sov in [(40, 8), (80, 16), (160, 32)]:
    i2 = indicizza(documenti, lambda t: finestra_fissa(t, dim, sov))
    bi = lambda k: tetto([cerca(i2, q, k, "corrente") for q in DOMANDE], ANCORE, k)
    rr = lambda k: tetto([recupera(i2, q, 50, k, True, "corrente") for q in DOMANDE], ANCORE, k)
    print(f"{dim:>6}/{sov:<3}{bi(50):>8.3f}{bi(1):>10.3f}{rr(1):>14.3f}{bi(3):>10.3f}{rr(3):>14.3f}")
def ms_per_domanda(f, ripetizioni=20):
    t0 = time.perf_counter()
    for _ in range(ripetizioni):
        for q in DOMANDE:
            f(q)
    return 1000 * (time.perf_counter() - t0) / (ripetizioni * len(DOMANDE))
solo_bi = [ms_per_domanda(lambda q: cerca(ix, q, 50, "corrente")) for _ in range(3)]
con_rr = [ms_per_domanda(lambda q: recupera(ix, q, 50, 3, True, "corrente")) for _ in range(3)]
print(f"latenza per domanda, 3 esecuzioni: solo bi-encoder {min(solo_bi):.2f}-{max(solo_bi):.2f} ms | "
      f"con riordino {min(con_rr):.2f}-{max(con_rr):.2f} ms")
     chunk  rec@50  rec@1 bi  rec@1 riord.  rec@3 bi  rec@3 riord.
    40/8     0.889     0.278         0.500     0.667         0.667
    80/16    1.000     0.722         0.889     0.944         0.944
   160/32    1.000     0.889         0.944     1.000         1.000
latenza per domanda, 3 esecuzioni: solo bi-encoder 0.30-0.50 ms | con riordino 7.42-8.70 ms

Il riordino aiuta dove il bi-encoder è debole e non dove è già forte. Con chunk da 40 parole il recall@1 passa da 0,278 a 0,500, a 80 da 0,722 a 0,889, a 160 da 0,889 a 0,944: più il recupero è in difficoltà, più il riordino guadagna. Ma il recall@3 non si muove mai (0,667, 0,944, 1,000 prima e dopo), e la prima colonna spiega perché: a 40 parole il recall@50 è 0,889, quindi in due domande la risposta non è fra i candidati e nessun riordino può inventarla. È la regola generale: il riordino sposta in alto ciò che i candidati già contengono. Se il recall@50 è basso, il guasto sta a monte, nella segmentazione.

Il costo si misura in millisecondi. Su quattro esecuzioni del programma, su una macchina condivisa, la ricerca del bi-encoder è costata 0,24-0,75 ms per domanda e la ricerca più il riordino 6-17 ms (la riga stampata sopra è una sola delle quattro). Sono dieci millisecondi o meno, niente rispetto ai secondi di una generazione vera, e per questo il riordino conviene quasi sempre quando c'è il guadagno. C'è una condizione importante: il nostro surrogato guarda le stesse parole dell'encoder, quindi non porta informazione nuova e guadagna poco. Un cross-encoder vero porta un segnale diverso, e un riordino che ripete il segnale del recupero costa tempo senza dare niente.

Il prompt di ancoraggio: tre requisiti scritti come requisiti

Il prompt ha un compito stretto e tre requisiti, da scrivere come requisiti e non come buoni propositi. Primo: rispondere solo dal contesto, esplicitando il caso difficile - non usare nulla che il modello sappia da altre fonti, nemmeno se è certo che sia vero. Secondo: dichiarare di non sapere con una frase esatta, perché «di' che non lo sai» è un invito e una stringa fissa è un contratto verificabile a valle. Terzo: citare per frase l'identificativo dello span usato.

ISTRUZIONI = """Sei un assistente che risponde SOLO con quello che c'e' scritto nel contesto.

Regole, da rispettare tutte e tre:
1. Usa esclusivamente le informazioni presenti negli span qui sotto. Non usare nulla
   che tu sappia da altre fonti, nemmeno se sei certo che sia vero.
2. Se gli span non contengono la risposta, scrivi esattamente questa riga e nient'altro:
   NON NEL CONTESTO
3. Ogni frase della risposta deve terminare con l'identificativo dello span da cui
   l'hai presa, fra parentesi quadre, cosi': [S3]. Una frase senza identificativo e'
   una violazione. Non citare span che non hai usato.

Rispondi in inglese, in al massimo due frasi."""

def costruisci_prompt(domanda, span_testi):
    contesto = "\n\n".join("[S%d] %s" % (i + 1, t) for i, t in enumerate(span_testi))
    return ISTRUZIONI + "\n\nSPAN:\n\n" + contesto + "\n\nDOMANDA: " + domanda + "\n\nRISPOSTA:"

La funzione numera gli span da [S1] in poi, perché l'identificativo è ciò che permetterà al verificatore di risalire al testo. Quanto sia lungo il prompt costruito lo misuro più sotto, con i tre span della prima domanda. La chiamata a un modello locale è un POST e niente più; la riporto come schema perché qui non c'è nessun modello in ascolto e non l'ho eseguita.

# schema da adattare, non eseguito: serve un runtime locale in ascolto e un modello scaricato
import json, urllib.request

def chiama(prompt, modello, num_predict=200, seed=7):
    req = urllib.request.Request(
        "http://localhost:11434/api/generate",
        data=json.dumps({"model": modello, "prompt": prompt, "stream": False,
                         "options": {"temperature": 0, "seed": seed,
                                     "num_predict": num_predict,
                                     "num_ctx": 16384}}).encode(),   # dichiaralo sempre
        headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=1800) as r:
        return json.load(r)["response"].strip()

Le opzioni non sono dettagli. temperature: 0 con seed fissato rende l'esperimento ripetibile; num_predict limita la lunghezza della risposta; num_ctx è, secondo la documentazione dell'API di Ollama, la «lunghezza del contesto in token». Dichiararlo esplicitamente è prudenza: un prompt più lungo della finestra del runtime non può entrarci per intero, e ho scelto di non lasciare il valore implicito perché non ho verificato quale sia il comportamento esatto del tuo runtime in quel caso. Il formato dell'uscita è minimale - prosa con marcatori [Sn] - perché l'obiettivo è la tracciabilità. Quando la risposta deve entrare in un programma serve uno schema validato: è l'argomento della lezione Output strutturato: far restituire all'agente dati che puoi validare. I due meccanismi si compongono: schema per la forma, citazione per la provenienza.

La verifica che non si fida

Chiedere la citazione non basta. Un modello può citare uno span che non dice quella cosa, o produrre un numero plausibile e attaccarci un [S2] altrettanto plausibile. La citazione, da sola, è un'affermazione del modello sul proprio comportamento: l'unica categoria di affermazione che non si può accettare da un sistema probabilistico.

Serve un controllo programmatico: per ogni frase risalire allo span citato ed esigere che ogni numero e ogni nome tecnico compaiano letteralmente lì dentro. La regola è grezza e per questo robusta: non interpreta il significato, confronta stringhe.

CITAZIONE = re.compile(r"\[S(\d+)\]")
NUMERO = re.compile(r"-?\d+(?:\.\d+)?")
# Un nome tecnico ha un punto, un trattino basso o una maiuscola interna.
# Le parole inglesi comuni non ne hanno: e' cosi' che "According" a inizio
# frase non fa rifiutare una risposta corretta.
NOME = re.compile(r"\b(?:[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*"
                  r"|[A-Za-z]*[a-z][A-Z][A-Za-z]*"
                  r"|[a-z]+_[a-z_]+)\b")

def verifica_ancoraggio(risposta, span):
    if risposta.strip().startswith("NON NEL CONTESTO"):
        return "astenuta"
    frasi = [f.strip() for f in re.split(r"(?<=[.!?])\s+", risposta) if f.strip()]
    if not frasi:
        return "rifiutata"
    for frase in frasi:
        citati = CITAZIONE.findall(frase)
        if not citati:
            return "rifiutata"                     # frase senza provenienza
        testi = []
        for c in citati:
            n = int(c)
            if not (1 <= n <= len(span)):
                return "rifiutata"                 # ha citato uno span che non esiste
            testi.append(span[n - 1])
        unione = " ".join(testi).lower()
        nudo = CITAZIONE.sub("", frase)            # obbligatorio: senza questo, il "3"
        for numero in NUMERO.findall(nudo):        # di "[S3]" verrebbe cercato nello span
            if numero.lower() not in unione:
                return "rifiutata"
        for nome in NOME.findall(nudo):
            if nome.lower() not in unione:
                return "rifiutata"
    return "ancorata"

Tre scelte del codice meritano una riga. Il numero dell'identificativo viene tolto dalla frase prima di cercare i numeri (altrimenti il 3 di [S3] verrebbe cercato nello span). La regola dei nomi tecnici - un punto, un trattino basso o una maiuscola interna - serve a non far rifiutare una risposta corretta perché inizia con «According». E un identificativo fuori dall'intervallo ([S4] con tre span) è una violazione, non un errore da ignorare. Le sei risposte che seguono sono scritte da me, non prodotte da un modello, e coprono i casi:

contesto = ["Optional arg cutoff (default 0.6) is a float in [0, 1].",
            "NOTE (draft): the default cutoff of get_close_matches is 0.9."]
casi = ["The default cutoff is 0.6.",
        "The default cutoff is 0.9 [S1].",
        "The default cutoff is 0.9 [S2].",
        "The default cutoff is 0.6 [S1].",
        "The default cutoff is 0.6 [S4].",
        "NON NEL CONTESTO"]
for r in casi:
    print(f"{verifica_ancoraggio(r, contesto):<10} <- {r}")
rifiutata  <- The default cutoff is 0.6.
rifiutata  <- The default cutoff is 0.9 [S1].
ancorata   <- The default cutoff is 0.9 [S2].
ancorata   <- The default cutoff is 0.6 [S1].
rifiutata  <- The default cutoff is 0.6 [S4].
astenuta   <- NON NEL CONTESTO

I casi leggono come una tabella di verità. Senza citazione, rifiutata: anche se il contenuto è giusto, perché il verificatore valuta la tracciabilità e non la verità. Con 0.9 citato su [S1], rifiutata: è il caso per cui il verificatore esiste, numero inventato con citazione plausibile. Con 0.6 citato su [S1], ancorata. Con [S4], rifiutata. E con 0.9 su [S2], ancorata - ed è falsa, perché lo span [S2] è la nota in bozza. Le risposte che non passano non si mostrano con un avviso: si rifiutano, e la percentuale di rifiuti diventa una metrica di esercizio. Un sistema con zero rifiuti non è perfetto, è senza controllo.

Il controllo garantisce che la risposta sia ancorata, non che sia vera. Se nel contesto è finito un documento sbagliato, una risposta fedelmente sbagliata passa a pieni voti. Contro quello serve il filtro sui metadati, non il verificatore: due difese distinte per due guasti distinti. Per vederlo su tutte e diciotto le domande ho messo insieme l'intera pipeline, con il generatore a copione al posto del modello:

def modello_finto(domanda, span):
    """Generatore a copione: NON e' un modello. Prende la frase degli span che condivide
    piu' termini con la domanda e la cita; se ne condivide meno di due, si astiene."""
    qt = set(parole(domanda))
    punti, n_span, scelta = 0, 0, ""
    for n, testo in enumerate(span, 1):
        for frase in re.split(r"(?<=[.!?])\s+", normalizza(testo)):
            p = len(qt & set(parole(frase)))
            if p > punti:
                punti, n_span, scelta = p, n, frase
    if punti < 2:
        return "NON NEL CONTESTO"
    return f"{scelta.rstrip('.')} [S{n_span}]."

def esegui_pipeline(indice, solo_stato=None, k_finale=3):
    esiti = collections.Counter()
    for (domanda, modulo, ancora) in GOLDEN:
        top = recupera(indice, domanda, 20, k_finale, True, solo_stato)
        span = [c["testo"] for c, _ in top]
        risposta = modello_finto(domanda, span)
        esito = verifica_ancoraggio(risposta, span)
        if esito == "ancorata":
            n = int(CITAZIONE.findall(risposta)[0])
            esito += " / fonte giusta" if top[n - 1][0]["doc"] == modulo else " / fonte SBAGLIATA"
        esiti[esito] += 1
    return esiti

Prima di mettere tutto insieme, il prompt reale. Il blocco costruisce la richiesta per la prima domanda del golden set, con i primi tre span riordinati e filtrati, e mostra quanto è lungo e che cosa risponderebbe il generatore a copione:

q = GOLDEN[0][0]
top = recupera(ix, q, 20, 3, True, "corrente")
span = [c["testo"] for c, _ in top]
prompt = costruisci_prompt(q, span)
print(f"prompt: {len(prompt.split())} parole, {len(prompt)} caratteri; span consegnati: {len(span)}")
print("risposta del generatore a copione:", modello_finto(q, span))
prompt: 597 parole, 3886 caratteri; span consegnati: 3
risposta del generatore a copione: Function get_close_matches(word, possibilities, n=3, cutoff=0.6): Use SequenceMatcher to return list of the best "good enough" matches [S3].

597 parole per tre span: la finestra del modello va dimensionata su questo numero moltiplicato per il fattore parole-token del tuo modello, non su quello che speri sia. La risposta del generatore a copione è la frase di intestazione della funzione, con la citazione [S3]: contiene il nome e la firma, con il cutoff=0.6 dentro, ma non la frase che spiega il parametro. È il limite di un generatore costruito per sovrapposizione di parole, e conviene ricordarlo leggendo la tabella che segue.

for etichetta, f in (("nessun filtro", None), ("stato = corrente", "corrente")):
    esiti = esegui_pipeline(ix, f)
    print(f"{etichetta:<18}", dict(sorted(esiti.items())))
nessun filtro      {'ancorata / fonte SBAGLIATA': 10, 'ancorata / fonte giusta': 8}
stato = corrente   {'ancorata / fonte giusta': 18}

Senza il filtro, il generatore a copione cita in 10 domande su 18 uno span che viene da una nota e non dal modulo giusto, e il verificatore le dà tutte e dieci per ancorate: citano, i numeri ci sono, nessuna parola inventata. Con il filtro, tutte e diciotto vengono dal modulo giusto. È la dimostrazione misurata del paragrafo precedente, e il «dieci» coincide con il numero di domande che avevano un chunk contraddittorio nei primi tre. Attenzione a cosa non dice: «fonte giusta» vuol dire che lo span citato viene dal modulo atteso, non che la frase scelta contenga la risposta; il generatore a copione sceglie per sovrapposizione di parole e, per la prima domanda, ha scelto la frase di intestazione della funzione (vedi sopra) e non quella del cutoff. Un generatore vero fa meglio, o peggio, in modi che qui non misuro.

Esercizio 2

Tre risposte al vaglio del verificatore

Il contesto consegnato al modello è di due span: il primo è la riga vera del docstring di difflib, il secondo è una delle note in bozza che ho aggiunto al corpus per contraddirla. Immagina che il recupero, senza filtro sullo stato, le abbia consegnate entrambe, in quest'ordine, e che il modello risponda in tre modi diversi. Gli span sono numerati da 1, come fa costruisci_prompt.

[S1] Optional arg cutoff (default 0.6) is a float in [0, 1].
[S2] NOTE (draft): the default cutoff of get_close_matches is 0.9.

Per ciascuna di queste tre risposte, di' che esito restituisce verifica_ancoraggio e se l'esito è quello che vorresti:

  1. The default cutoff is 0.6.
  2. The default cutoff is 0.9 [S1].
  3. The default cutoff is 0.9 [S2].

Ragiona frase per frase seguendo l'ordine del codice: prima la citazione, poi i numeri, poi i nomi. Puoi confrontare le tue risposte con il blocco di uscita della sezione precedente, che contiene proprio questi tre casi.

Mostra la soluzione

1. Rifiutata, per frase senza citazione: il contenuto è corretto e viene scartato comunque, perché il verificatore valuta la tracciabilità, non la verità. Una risposta giusta che il sistema non sa giustificare non è distinguibile da una risposta giusta per caso.

2. Rifiutata, perché 0.9 non compare in [S1]. È il caso che il verificatore esiste per intercettare: numero inventato, citazione plausibile.

3. Ancorata, e falsa. Lo span [S2] dice davvero 0.9: la risposta è fedele a una fonte che non doveva entrare nel contesto. L'esito è quello giusto, perché accorgersene non è compito del verificatore: il guasto si ripara a monte con il filtro sullo stato dei documenti.

Trade-off

  • Recupero contro generazione. Il generatore non ripara un recupero sbagliato, e non è un difetto di prompt: è un limite. Qui il tetto a k = 3 è 1,000 ma a k = 1 è 0,389: la distanza è tutta ordinamento. Mitigazione: misura il tetto a k largo e a k stretto prima di toccare il prompt; la distanza dice su quale componente stai per lavorare.
  • Chunk piccoli contro chunk grandi. Su questo corpus a 40 parole il recall@50 si ferma a 0,889 perché le ancore si spezzano, a 160 arriva a 1,000. Il prezzo del grande è un vettore che media più argomenti, che qui non emerge perché l'encoder è lessicale e i documenti sono corti. Mitigazione: misura la dimensione sui tuoi documenti, partendo da un valore vicino alla lunghezza del paragrafo tipico.
  • Segmentazione per struttura contro segmentazione per frase. Per struttura 104 chunk e recall@3 1,000, per frase 343 chunk e 0,889. Mitigazione: se i documenti hanno confini di paragrafo, usali; la finestra fissa serve quando il testo è un flusso indifferenziato.
  • Sovrapposizione contro dimensione dell'indice. Il 20% di sovrapposizione porta i tagli da 11 a 6 su 360 prove e costa il 21,5% di chunk in più; sotto le 60 parole la sovrapposizione è più corta dell'ancora e non protegge. Mitigazione: dimensiona la sovrapposizione sulla lunghezza della frase da proteggere, non su una percentuale.
  • Riordino contro latenza. Il surrogato porta il recall@1 da 0,278 a 0,500 con chunk da 40 parole, e il costo da meno di un millisecondo a 6-17 ms. Conviene dove il recupero è debole e i candidati contengono la risposta; sul recall@3 non cambia mai niente. Mitigazione: decidi guardando il recall@50 dei candidati, non la promessa del riordinatore.
  • Filtro sui metadati contro richiamo. Il filtro esclude 10 chunk su 116, porta le domande con un chunk contraddittorio da 10/18 a 0/18 e il recall@1 da 0,389 a 0,889. Il rovescio è che un chunk filtrato non esiste più per nessuna domanda. Mitigazione: misura il recall@k con e senza filtro.
  • Citazione obbligatoria contro tasso di risposta. Il verificatore rifiuta ogni frase senza provenienza e ogni numero che non compaia nello span: ci finisce dentro anche una risposta corretta che il modello non sa giustificare. Il guadagno è che niente arriva al lettore senza una fonte risalibile; il prezzo è un tasso di rifiuto maggiore di zero, che con un modello vero va misurato sul proprio golden set. Mitigazione: tieni il tasso di rifiuto come metrica di esercizio, accanto all'accuratezza.

Le sette voci hanno una struttura comune: ogni scelta compra qualcosa e lo paga altrove, e la mitigazione è quasi sempre una misura, non un parametro da ricordare. Tre di loro (tetto, filtro, sovrapposizione) vanno misurate prima di qualunque ottimizzazione, perché cambiano che cosa il resto della pipeline deve fare.

Le trappole in cui si cade davvero

  1. Riscrivere il prompt prima di misurare il tetto. Sintomo: un pomeriggio di istruzioni riformulate e l'accuratezza che si muove di un'inezia. Causa: la risposta non è nei chunk consegnati, o è in fondo alla lista. Rimedio: le dieci righe di tetto(...) prima di ogni altra cosa.
  2. Leggere il punteggio di somiglianza come una percentuale. Sintomo: «il primo risultato sta a 0,78, quindi è buono al 78%», e una soglia scritta a mano che taglia via le risposte giuste. Causa: lo spazio di un encoder non è calibrato, e il valore assoluto non significa niente fuori dalla sua distribuzione. Rimedio: solo metriche d'ordine, e la soglia ricavata dai percentili del tuo corpus, rifatta a ogni cambio di modello.
  3. Scrivere le domande di valutazione guardando i chunk. Sintomo: recall@k eccellente in valutazione e un sistema che in produzione non trova niente. Causa: le domande riusano le parole del chunk che devono recuperare, quindi si misura la memoria dell'indice invece del recupero. Qui è un rischio concreto: le mie domande contengono i nomi delle funzioni, e un encoder lessicale ci va a nozze. Rimedio: domanda formulata come la scriverebbe un utente, ancora presa dal documento, taglia dell'insieme dichiarata.
  4. Impaginare gli span senza decidere l'ordine. Sintomo: lo stesso insieme di chunk dà risposte diverse a seconda di come li metti in fila, e il caso peggiore è quando quello buono sta in mezzo. Causa: i modelli usano meglio l'inizio e la fine del contesto che il centro (Liu e colleghi, Lost in the Middle: How Language Models Use Long Contexts, arXiv:2307.03172). Rimedio: pochi span, i migliori ai due estremi, e l'ordine trattato come una variabile da misurare - qui non l'ho misurata, perché il generatore a copione non ha un centro del contesto in cui perdersi.
  5. Fidarsi della citazione perché c'è. Sintomo: risposte ben formate, un [Sn] dopo ogni frase, e un numero sbagliato dentro. Causa: la citazione, da sola, è un'affermazione del modello sul proprio comportamento. Rimedio: il confronto letterale fra i numeri della frase e il testo dello span.
  6. Lasciare che il confine di chunk tagli la frase che risponde. Sintomo: una manciata di domande che non funzionano mai, con qualunque prompt, mentre la media sta bene. Causa: la frase non esiste più intera da nessuna parte. Rimedio: una sovrapposizione più lunga dell'ancora, sapendo che la stessa frase, ora presente in due chunk, può occupare due delle k posizioni.

Sei sintomi diversi, una sola abitudine che li previene: misurare un pezzo alla volta, con il tetto come primo numero, e confrontare sempre prima e dopo. Le trappole 1, 3 e 5 sono quelle in cui un sistema sembra funzionare perché la domanda che vedi è quella che hai scritto tu; le altre tre si manifestano solo con documenti e utenti veri.

Rifare l'esperimento

Tutti i blocchi di questa lezione sono un solo programma: mettili in un file nell'ordine in cui compaiono (corpus, golden set, encoder, indice, tetto, chunker, riordino, prompt, verificatore, generatore a copione, poi i blocchi di misura) e lancialo con python3; serve NumPy 2.x e nessun altro pacchetto, nessuna rete e nessun modello. Io l'ho eseguito due volte e le uscite, tolta la riga dei tempi, sono identiche. Gli esercizi con le parti da completare sono in esercizi.py, scaricabile dal repository del corso: non l'ho eseguito per questa revisione, e il suo esercizio 3b usa un runtime locale che qui non c'è.

Cosa portarti via

Un RAG è un esame a libro aperto, e quasi tutto il lavoro sta nella mano che apre il libro: il recupero costruito nella lezione su embedding e ricerca semantica. Il numero da guardare prima di ogni altro è il tetto a k largo e a k stretto, non l'accuratezza finale: qui 1,000 a k = 3 contro 0,389 a k = 1 dice che il recuperatore trova sempre la risposta e la mette nel posto sbagliato, ed è la diagnosi che manda sul filtro e sul riordino invece che sul prompt. Il resto sono tre decisioni misurabili: tagliare con una sovrapposizione più lunga dell'ancora (da 11 a 6 tagli su 360, azzerati sopra le 60 parole), filtrare sui metadati prima di cercare (da 10/18 domande con un documento contraddittorio nel contesto a 0/18), e riordinare solo dove i candidati contengono già la risposta.

La seconda cosa da portarsi via è che la citazione non è una cortesia editoriale: è l'unico punto in cui un sistema probabilistico diventa controllabile da un programma. Il verificatore confronta stringhe e non significati, e funziona proprio per questo, e proprio per questo garantisce l'ancoraggio, non la verità: la risposta sbagliata ma citata correttamente passa a pieni voti, e si ripara a monte col filtro. Quello che resta aperto è la misura con un modello vero: qui encoder e generatore sono finti, 18 domande separano a malapena 0,389 da 0,889, e il tasso di rifiuto di un modello reale non l'ho misurato. Dimensionare un golden set, scegliere le metriche e usare un modello come giudice - con i pregiudizi che quel giudice si porta dietro - è il mestiere della lezione successiva, sulla valutazione dei sistemi generativi.

Nella stessa serie trovi anche Far girare un modello in locale: quantizzazione, memoria e latenza misurate.

Fonti

Le prime sei fonti sono primarie e le ho aperte il 2026-11-12 per verificare titolo e contenuto citato; l'ultima voce dichiara invece da dove vengono le cifre, perché una misura fatta nel repository si cita con il comando che la produce.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti