Adattare un modello di embedding al proprio dominio

Il RAG non recupera i documenti giusti e la colpa quasi mai è del modello linguistico: è dell'embedding, addestrato su testo generico e messo a lavorare sul tuo gergo. Tre strade per adattarlo — con coppie etichettate, con soli testi, o generandosi le coppie da solo — e come misurare se è servito.

Un interprete bravissimo, appena assunto in un cantiere, traduce «tirante» con la parola sbagliata per tre giorni. Non è incompetente: è che il gergo non lo conosce ancora. Ai modelli di embedding capita la stessa cosa, ed è la causa più frequente — e la meno sospettata — di un sistema di ricerca semantica che restituisce documenti quasi giusti.

Quando un RAG risponde male, il riflesso è cambiare modello linguistico o riscrivere il prompt. Ma se il pezzo di documento che conteneva la risposta non è mai arrivato nel contesto, nessun modello e nessun prompt possono salvarti: il guasto è a monte, nel modello che trasforma testo in vettori. Questo tutorial mostra come adattarlo, in tre situazioni diverse a seconda dei dati che hai davvero.

La strada la scelgono i dati che hai, non il metodo che preferisci. Che dati hai? del tuo dominio Coppie vere domanda + passaggio che la contiene → negativi nel batch Solo testi nessuna domanda, nessuna etichetta → TSDAE Solo testi, ma puoi far generare le domande → GPL Prima di tutte e tre: un insieme di valutazione, altrimenti non saprai se è servito

Le tre strade si distinguono per un solo criterio: quanto materiale etichettato hai. La casella tratteggiata in fondo non è opzionale — è il passo che rende gli altri misurabili.

Prima di toccare qualcosa: misurare

Un adattamento senza metrica è una scommessa. Servono cinquanta o cento domande reali — prese dai ticket, dalle ricerche degli utenti, dalle email — con l’indicazione di quale documento contiene la risposta. Costa un pomeriggio ed è l’investimento con il rendimento più alto di tutto il progetto: senza, l’unica valutazione disponibile è «mi sembra migliore», che è notoriamente influenzata dal tempo che ci hai messo.

pip install sentence-transformers datasets
from sentence_transformers import SentenceTransformer
from sentence_transformers.evaluation import InformationRetrievalEvaluator

modello = SentenceTransformer("intfloat/multilingual-e5-base")

# corpus: {id_documento: testo}; query: {id_query: domanda}
# rilevanti: {id_query: {id_documento, ...}}
valutatore = InformationRetrievalEvaluator(
    queries=query, corpus=corpus, relevant_docs=rilevanti, name="dominio",
)
print(valutatore(modello))   # nDCG@10, recall@k, MRR: il punto di partenza

Il numero che esce da qui è la baseline. Qualunque cosa venga dopo si giudica contro quella, e capita più spesso di quanto si creda che un adattamento fatto male la peggiori.

Qui diamo per noto che cos’è un embedding e perché due testi vicini di significato finiscono vicini nello spazio: se serve il fondamento, il libro lo costruisce da capo, dalla bag of words fino ai vettori contestuali.

Leggi «Rappresentare il testo» nel libro →

La strada corta: hai delle coppie

Se puoi mettere insieme qualche migliaio di coppie (domanda, passaggio che risponde) — e spesso si possono ricavare da FAQ, ticket risolti, log di ricerca con i click — la strada è breve e funziona bene. La funzione di perdita da usare è MultipleNegativesRankingLoss, che la documentazione di sentence-transformers indica come scelta consigliata per dati di questa forma e descrive come «comunemente usata per addestrare i modelli di embedding più performanti».

from datasets import Dataset
from sentence_transformers import (
    SentenceTransformerTrainer, SentenceTransformerTrainingArguments,
)
from sentence_transformers.losses import MultipleNegativesRankingLoss

dati = Dataset.from_dict({
    "anchor":   ["come si annulla una polizza?", "chi firma il verbale?"],
    "positive": ["Per il recesso il contraente invia…", "Il verbale è sottoscritto dal…"],
})

perdita = MultipleNegativesRankingLoss(modello)
args = SentenceTransformerTrainingArguments(
    output_dir="modello-dominio",
    num_train_epochs=1,
    per_device_train_batch_size=32,   # il batch è un iperparametro vero, qui
    warmup_ratio=0.1,
)
SentenceTransformerTrainer(
    model=modello, args=args, train_dataset=dati,
    loss=perdita, evaluator=valutatore,
).train()

Il commento sul batch non è cosmetico. Questa perdita costruisce gli esempi negativi dentro il batch: per ogni domanda, i passaggi corretti di tutte le altre domande del batch fanno da risposte sbagliate. Un batch di 8 chiede al modello di distinguere fra otto candidati, uno di 128 fra centoventotto: il compito è più difficile e il modello che ne esce è migliore. È per questo che la documentazione segnala CachedMultipleNegativesRankingLoss, la variante che permette di alzare il batch oltre quello che la memoria consentirebbe.

Una conseguenza da conoscere: se due domande del batch hanno per caso la stessa risposta, il modello viene penalizzato per averlo capito. Con corpora piccoli e ripetitivi vale la pena deduplicare prima.

Non hai coppie: TSDAE

Il caso più comune nelle aziende è avere molti documenti e zero domande. TSDAE — Transformer-based Sequential Denoising Auto-Encoder, di Wang, Reimers e Gurevych (2021) — impara solo dai testi: rovina una frase cancellandone parole a caso, chiede al modello di comprimerla in un vettore, e a un decoder di ricostruirla intera partendo da quel vettore. Per riuscirci, il vettore deve conservare il senso della frase, gergo compreso.

from datasets import Dataset
from sentence_transformers.losses import DenoisingAutoEncoderLoss
from sentence_transformers.datasets import DenoisingAutoEncoderDataset

frasi = [r.strip() for r in open("documenti.txt", encoding="utf-8") if r.strip()]

# la colonna "damaged" è l'ingresso, "text" il bersaglio da ricostruire
dati_tsdae = Dataset.from_dict({"text": frasi})
dati_tsdae.set_transform(
    lambda lotto: {"damaged": [DenoisingAutoEncoderDataset.delete(t) for t in lotto["text"]]},
    columns=["text"], output_all_columns=True,
)

perdita = DenoisingAutoEncoderLoss(
    modello,
    decoder_name_or_path="intfloat/multilingual-e5-base",
    tie_encoder_decoder=True,
)

I numeri del paper, riportati come li scrivono gli autori: TSDAE supera gli approcci precedenti «fino a 6,4 punti» e raggiunge «fino al 93,1% delle prestazioni degli approcci supervisionati nel dominio», misurato su quattro insiemi di dati di domini eterogenei. La documentazione di sentence-transformers riporta su compiti semantici una media che passa da 52,3 a 56,5.

Il difetto è dichiarato nella documentazione stessa: il costo. Va eseguito un pre-addestramento sul proprio corpus prima dell’addestramento supervisionato, e sono due giri invece di uno.

Farsi le coppie da soli: GPL

La terza strada rovescia il problema: se mancano le domande, le si genera. GPL — Generative Pseudo Labeling, degli stessi autori più Nandan Thakur (2021) — prende ogni passaggio del corpus, fa scrivere a un modello generativo la domanda a cui quel passaggio risponderebbe, poi recupera passaggi concorrenti e fa pesare ogni coppia da un cross-encoder, che assegna un punteggio di pertinenza invece di un secco sì o no.

Quel punteggio morbido è il punto dell’intero metodo. Le domande generate automaticamente sono spesso mediocri, e un passaggio pescato come «negativo» a volte risponde alla domanda meglio di quello ufficiale: trattarli come vero e falso insegnerebbe al modello degli errori. Il cross-encoder li ordina invece di etichettarli, e l’errore si diluisce.

Gli autori riportano fino a 9,3 punti di nDCG@10 sopra il modello di partenza, su sei insiemi di dati di domini specialistici, e un ulteriore guadagno medio di 1,4 punti se al metodo si antepone TSDAE. La documentazione, sul proprio confronto di ricerca semantica, riporta una media che va da 45,2 senza adattamento a 51,4 con GPL e 52,4 combinando i due. L’implementazione di riferimento è quella del gruppo UKP, e vuole in ingresso il solo corpus.

Cosa portarti a casa

Tre avvertenze pratiche, tutte imparate nel modo scomodo.

Un modello adattato obbliga a reindicizzare. I vettori vecchi e i nuovi non vivono nello stesso spazio: appena cambi il modello, l’intero indice va ricalcolato. Su qualche migliaio di documenti è questione di minuti, su qualche milione è un’operazione da pianificare — e va pianificata anche la convivenza delle due versioni durante il passaggio.

L’adattamento è specializzazione, non miglioramento. Un modello portato sul gergo assicurativo diventa in genere peggiore sul resto. Se la tua ricerca copre più domini distanti, tenere modelli separati batte quasi sempre il tentativo di farne uno buono per tutto.

Prima di adattare, controlla il chunking. Capita spesso che il recupero fallisca non per colpa dei vettori ma perché i documenti sono spezzati male, e la risposta cade a cavallo fra due pezzi. È un pomeriggio di verifica e costa meno di un addestramento.

Se il recupero non porta il documento giusto, il modello linguistico più intelligente del mondo può solo inventare con maggiore eleganza.

La ragione per cui vale la pena occuparsene è di rapporto fra costo ed effetto. Cambiare il modello generativo di un RAG costa a ogni singola chiamata, per sempre; adattare l’embedding costa qualche ora di GPU una volta sola, e agisce sul pezzo della catena che decide che cosa il modello avrà davanti quando risponde.

I commenti sono riservati agli iscritti.

Accedi per commentare