Lezione passo-passo: cercare fonti e costruire un RAG
Cosa saprai fare alla fine
Alla fine saprai:
- spiegare perché
Ctrl+Fnon basta su molti documenti; - distinguere documento, corpus, chunk, metadati, indice e query;
- costruire una ricerca lessicale simile a BM25;
- capire embedding e similarità senza formule misteriose;
- descrivere retrieval ibrido, RRF e reranker nell'ordine corretto;
- disegnare una pipeline RAG completa;
- calcolare Recall@k, Precision@k e MRR;
- separare domande con risposta e senza risposta;
- applicare filtri di tenant e permessi prima di mostrare contenuti;
- eseguire un piccolo laboratorio RAG interamente locale.
Cosa devi sapere prima
Completa la lezione su LLM e prompt. Devi conoscere input, output, token, contesto e allucinazione.
Devi inoltre saper eseguire un file Python e leggere liste, dizionari, cicli e funzioni. Se serve, ripassa Python passo-passo.
Il laboratorio usa solo la libreria standard di Python: non scarica modelli, non richiede chiavi e non usa internet.
1. Il problema parte da Ctrl+F
Hai tre documenti aziendali e vuoi trovare la procedura per reimpostare una password.
Apri un file e premi Ctrl+F. Cerchi password e trovi la sezione giusta.
Questa è già una forma semplice di retrieval, cioè recupero: data una domanda, cerchi il testo utile.
Ora immagina 50.000 documenti. Alcuni scrivono “credenziali”, altri “accesso”, altri
“codice segreto”. Ctrl+F:
- lavora di solito su un file alla volta;
- cerca la parola esatta;
- non ordina bene migliaia di risultati;
- non comprende sinonimi;
- non applica automaticamente tutti i permessi;
- non prepara una risposta con fonti.
Serve quindi un sistema che recuperi pochi passaggi rilevanti prima di chiedere a un modello linguistico di rispondere.
2. Le parole fondamentali
Un documento è un'unità originale: un file Markdown, una pagina, un contratto o
un record del database. Esempio: procedura-ferie.md.
Il corpus è l'intera collezione ricercabile. Analogia: non un libro, ma tutta la biblioteca.
Un chunk, cioè blocco, è una porzione di documento. Dividiamo documenti lunghi per recuperare la sezione utile senza passare cento pagine al modello.
I metadati sono dati che descrivono il contenuto: titolo, fonte, data, lingua, tenant e ruoli ammessi. Non sono il testo principale, ma servono a filtrare, aggiornare e citare.
Un indice è una struttura preparata per cercare velocemente. L'indice di un libro rimanda da una parola alle pagine; un indice software svolge un compito simile su molti contenuti.
La query è la richiesta di ricerca. Può essere composta da parole, come
reset password, o da una domanda, come Come recupero l'accesso?.
3. Preparare i documenti: ingestion e chunking
L'ingestion, cioè acquisizione, è il processo che legge le fonti e le rende ricercabili:
Il chunking è la divisione in blocchi.
- Chunk troppo piccolo: può separare una regola dalla sua eccezione.
- Chunk troppo grande: aggiunge rumore e consuma token.
- Divisione per sezioni e paragrafi: conserva meglio il significato.
Ogni chunk dovrebbe mantenere almeno identificatore, documento di origine, testo, sezione, versione e permessi.
Esempio:
chunk = {
"id": "hr-001-ferie",
"documento": "manuale-hr",
"sezione": "Ferie",
"testo": "Le ferie devono essere approvate dal responsabile.",
"tenant_id": "azienda-a",
"ruoli": ["dipendente", "manager"],
}
Senza documento e sezione non puoi costruire una citazione utile. Senza
tenant_id e ruoli rischi di recuperare dati per la persona sbagliata.
4. Ricerca lessicale e idea di BM25
La ricerca lessicale confronta le parole della query con quelle dei documenti.
È simile a Ctrl+F, ma può assegnare un punteggio e ordinare tutti i risultati.
BM25 è un algoritmo di ranking, cioè ordinamento per rilevanza. In linguaggio semplice:
- premia un documento che contiene le parole cercate;
- premia di più parole rare, perché distinguono meglio;
- evita che ripetere una parola cento volte dia cento volte il vantaggio;
- compensa la lunghezza, così un documento enorme non vince solo perché contiene quasi ogni parola.
Esempio: la query errore payroll PX-104 contiene un codice raro. BM25 tende a essere
molto efficace perché il codice esatto è più informativo della parola comune
errore.
BM25 non “capisce” automaticamente che cedolino e busta paga sono simili se le
parole non coincidono.
5. Embedding e similarità
Un embedding è una lista di numeri che rappresenta il significato appreso di un testo. Un modello di embedding trasforma query e chunk in vettori.
Analogia: immaginiamo una mappa. Testi sulle ferie finiscono vicini; testi sulle reti informatiche finiscono in un'altra zona. La posizione non è una coordinata geografica, ma un insieme di numeri appreso dal modello.
"non vedo il cedolino" -> [0,8, 0,1, 0,4]
"manca la busta paga" -> [0,75, 0,12, 0,42]
"configura il router" -> [0,05, 0,9, 0,02]
La similarità, cioè misura di somiglianza, confronta due vettori. Una misura comune è la similarità del coseno: guarda quanto i vettori puntano nella stessa direzione. Un valore maggiore indica in genere maggiore vicinanza nel modello, non una probabilità che la risposta sia corretta.
La ricerca tramite embedding è detta spesso semantica, perché può avvicinare sinonimi. Può però perdere codici esatti, nomi rari o dettagli numerici. Per questo non sostituisce sempre BM25.
6. Dalle basi al retrieval ibrido
Il retrieval ibrido unisce ricerca lessicale e ricerca tramite embedding.
Esempio:
- BM25 trova
PX-104perché coincide esattamente; - embedding trova
cedolinoper la querybusta paga; - la combinazione conserva entrambi i vantaggi.
I due sistemi producono punteggi su scale diverse. Sommarli direttamente può essere fuorviante. RRF, da Reciprocal Rank Fusion, cioè fusione dei ranghi reciproci, combina invece le posizioni nelle classifiche:
Un documento primo in entrambe le liste riceve più credito di uno presente in una sola lista e molto in basso. La costante riduce differenze troppo aggressive.
Dopo aver recuperato, per esempio, 30 candidati, possiamo usare un reranker, cioè un secondo ordinatore più accurato e costoso. Il reranker legge insieme query e testo, poi riordina pochi candidati. Non può recuperare un documento escluso dalla prima fase: prima serve un buon richiamo, poi si migliora l'ordine.
7. La pipeline RAG completa
RAG significa Retrieval-Augmented Generation, cioè generazione aumentata dal recupero. La pipeline completa è:
documenti
-> acquisizione
-> chunk e metadati
-> indice
utente autenticato + domanda
-> filtro tenant e permessi
-> retrieval
-> eventuale fusione e reranking
-> pochi chunk con identificatori
-> prompt con domanda e fonti
-> modello linguistico
-> risposta o astensione
-> verifica delle citazioni
Il retrieval porta informazioni nel contesto della singola richiesta. Non modifica permanentemente il modello.
Il modello deve poter dire: “Non trovo informazioni sufficienti nelle fonti autorizzate”. Questa astensione è migliore di una risposta inventata.
Le citazioni devono riferirsi solo agli identificatori effettivamente recuperati. L'applicazione deve verificarle prima di mostrarle.
8. Metriche con un esempio calcolato
Per valutare il retrieval prepariamo un insieme di risposte attese, chiamato golden set. Per una domanda, supponiamo che i documenti rilevanti siano:
Il sistema restituisce i primi cinque:
Recall@k, cioè richiamo ai primi k, risponde: “Quanti documenti rilevanti ho
trovato fra tutti quelli rilevanti?”.
Ai primi 3 risultati troviamo solo A:
Precision@k, cioè precisione ai primi k, risponde: “Quanti dei risultati mostrati
sono rilevanti?”.
Reciprocal Rank è l'inverso della posizione del primo risultato rilevante. Qui A
è secondo:
MRR, da Mean Reciprocal Rank, è la media dei reciprocal rank su più domande. Se tre domande hanno il primo rilevante alle posizioni 1, 2 e 4:
Domande senza risposta
Se nel corpus non esiste alcuna fonte rilevante, Recall produce una divisione 0/0.
Non inserire questi casi nella stessa media.
Valutali separatamente:
- percentuale di astensioni corrette;
- percentuale di false risposte;
- numero di risultati non autorizzati, che deve essere zero.
Questa separazione impedisce di premiare un sistema che recupera sempre qualcosa, anche quando non dovrebbe rispondere.
9. Sicurezza: tenant e ACL
Un tenant è un cliente o spazio organizzativo isolato dagli altri. Un servizio può
contenere documenti di azienda-a e azienda-b, ma un utente della prima non deve
vedere quelli della seconda.
Una ACL, da Access Control List, cioè lista di controllo degli accessi, descrive chi può leggere una risorsa.
Analogia: in una biblioteca aziendale, il badge decide quali stanze puoi aprire. Non portiamo prima tutti i fascicoli sul tavolo per poi nascondere quelli vietati. Il filtro deve avvenire prima o durante la ricerca.
Sequenza corretta:
Il modello non deve scegliere liberamente tenant_id. Il valore arriva
dall'autenticazione. Anche log, cache, reranker e prompt devono ricevere soltanto dati
autorizzati.
10. Programma locale completo
Questo laboratorio implementa un piccolo retrieval lessicale ispirato ai principi di
BM25. Per chiarezza non replica ogni dettaglio matematico della libreria
rank-bm25, ma include frequenza, rarità e normalizzazione della lunghezza.
Copia in rag_locale.py:
import math
import re
from collections import Counter
STOP_WORDS = {
"a", "al", "alla", "che", "come", "da", "di", "dove", "e", "è",
"gli", "i", "il", "in", "la", "le", "lo", "per", "qual", "un", "una",
}
DOCUMENTI = [
{
"id": "hr-ferie",
"tenant_id": "azienda-a",
"titolo": "Ferie",
"testo": "Le ferie devono essere richieste nel portale e approvate dal manager.",
},
{
"id": "hr-payroll",
"tenant_id": "azienda-a",
"titolo": "Cedolino",
"testo": "La busta paga si scarica dal portale Payroll nella sezione Documenti.",
},
{
"id": "it-password",
"tenant_id": "azienda-a",
"titolo": "Password",
"testo": "Per reimpostare la password usa il collegamento Recupera accesso.",
},
{
"id": "segreto-b",
"tenant_id": "azienda-b",
"titolo": "Budget",
"testo": "Il budget riservato di azienda-b è 900000 euro.",
},
]
def tokenizza(testo: str) -> list[str]:
token = re.findall(r"[a-zà-ù0-9]+", testo.lower())
return [parola for parola in token if parola not in STOP_WORDS]
def cerca(query: str, tenant_id: str, limite: int = 3) -> list[dict]:
autorizzati = [
documento
for documento in DOCUMENTI
if documento["tenant_id"] == tenant_id
]
if not autorizzati:
return []
query_token = tokenizza(query)
documenti_token = [tokenizza(d["testo"]) for d in autorizzati]
lunghezza_media = sum(map(len, documenti_token)) / len(documenti_token)
risultati = []
for documento, token in zip(autorizzati, documenti_token, strict=True):
frequenze = Counter(token)
punteggio = 0.0
for parola in query_token:
documenti_con_parola = sum(parola in t for t in documenti_token)
rarita = math.log(
1 + (len(documenti_token) - documenti_con_parola + 0.5)
/ (documenti_con_parola + 0.5)
)
frequenza = frequenze[parola]
normalizzazione = frequenza + 1.2 * (
0.25 + 0.75 * len(token) / lunghezza_media
)
if frequenza:
punteggio += rarita * (frequenza * 2.2) / normalizzazione
if punteggio > 0:
risultati.append({**documento, "punteggio": round(punteggio, 3)})
return sorted(
risultati,
key=lambda risultato: risultato["punteggio"],
reverse=True,
)[:limite]
def rispondi(query: str, tenant_id: str) -> dict:
fonti = cerca(query, tenant_id)
if not fonti:
return {
"risposta": "Non trovo informazioni sufficienti nelle fonti autorizzate.",
"fonti": [],
}
migliore = fonti[0]
return {
"risposta": migliore["testo"],
"fonti": [migliore["id"]],
}
if __name__ == "__main__":
casi = [
("Dove scarico la busta paga?", "azienda-a"),
("Come cambio la password?", "azienda-a"),
("Qual è il budget riservato?", "azienda-a"),
]
for domanda, tenant in casi:
risultato = rispondi(domanda, tenant)
print(f"Domanda: {domanda}")
print(f"Risposta: {risultato['risposta']}")
print(f"Fonti: {risultato['fonti']}\n")
Esegui:
Output atteso:
Domanda: Dove scarico la busta paga?
Risposta: La busta paga si scarica dal portale Payroll nella sezione Documenti.
Fonti: ['hr-payroll']
Domanda: Come cambio la password?
Risposta: Per reimpostare la password usa il collegamento Recupera accesso.
Fonti: ['it-password']
Domanda: Qual è il budget riservato?
Risposta: Non trovo informazioni sufficienti nelle fonti autorizzate.
Fonti: []
Il filtro tenant_id viene applicato prima del punteggio. Il documento di
azienda-b non entra quindi nel retrieval di azienda-a. rispondi è una
generazione estrattiva molto semplice: riusa una fonte anziché chiamare un LLM. È
voluto, perché permette di capire e testare il retrieval separatamente. La piccola
lista STOP_WORDS evita che parole comuni come il bastino da sole a creare una
falsa corrispondenza. In produzione lista, lingua e soglia vanno valutate su casi
reali, non scelte a intuito.
11. Laboratorio guidato
- Crea
rag_locale.pycon il programma. Verifica:python rag_locale.pytermina senza errori. - Controlla il caso busta paga.
Verifica: la fonte è
hr-payroll. - Controlla l'isolamento.
Verifica: la domanda sul budget fatta da
azienda-anon restituiscesegreto-b. - Ripeti come
azienda-b. Cambia il tenant dell'ultima domanda. Verifica: ora la fonte può esseresegreto-b. - Aggiungi un documento sulle note spese. Verifica: una query con parole presenti lo recupera.
- Prova un sinonimo assente.
Cerca
credenziali dimenticate. Verifica: il sistema lessicale può non trovarepassword; annota il limite. - Crea cinque query attese. Per ciascuna scrivi prima l'identificatore rilevante o “senza risposta”. Verifica: calcola Recall@3 solo sui casi con risposta e astensione sui restanti.
12. Esercizi graduati
Base
- Aggiungi il metadato
ruolie filtra anche per ruolo. - Restituisci titolo e punteggio oltre all'identificatore.
- Impedisci valori
limiteminori di 1 o maggiori di 10.
Intermedio
- Implementa
reciprocal_rank_fusionper unire due classifiche di identificatori. - Calcola automaticamente Recall@3 e Precision@3 per cinque query con risposta.
- Calcola separatamente la percentuale di astensione corretta per query senza risposta.
Avanzato
Aggiungi un vero modello di embedding locale e confrontalo con BM25 sugli stessi casi. Poi combina le classifiche con RRF. Cambia una sola variabile per esperimento e misura qualità e tempo.
13. Soluzioni ragionate
Filtro per ruolo:
def autorizzato(documento: dict, tenant_id: str, ruolo: str) -> bool:
stesso_tenant = documento["tenant_id"] == tenant_id
ruolo_ammesso = ruolo in documento["ruoli"]
return stesso_tenant and ruolo_ammesso
Questa funzione deve essere usata prima di calcolare punteggi o costruire prompt.
Per mostrare titolo e punteggio, seleziona i campi già restituiti da cerca:
[
{
"id": risultato["id"],
"titolo": risultato["titolo"],
"punteggio": risultato["punteggio"],
}
for risultato in cerca("busta paga", "azienda-a")
]
Valida limite all'inizio di cerca:
Soluzione RRF:
from collections import defaultdict
def reciprocal_rank_fusion(
classifiche: list[list[str]],
costante: int = 60,
) -> list[str]:
punteggi: dict[str, float] = defaultdict(float)
for classifica in classifiche:
for posizione, documento_id in enumerate(classifica, start=1):
punteggi[documento_id] += 1 / (costante + posizione)
return sorted(punteggi, key=punteggi.__getitem__, reverse=True)
assert reciprocal_rank_fusion(
[["A", "B", "C"], ["B", "A", "D"]]
)[:2] == ["A", "B"]
A e B sono presenti in entrambe le classifiche e ottengono lo stesso punteggio:
uno è primo e secondo, l'altro secondo e primo. Python conserva qui l'ordine di
inserimento e mette A prima di B. In una valutazione reale devi gestire
esplicitamente anche i pareggi.
Metriche per una query:
def metriche(rilevanti: set[str], risultati: list[str], k: int) -> tuple[float, float]:
primi_k = risultati[:k]
trovati = len(rilevanti.intersection(primi_k))
recall = trovati / len(rilevanti)
precision = trovati / k
return recall, precision
assert metriche({"A", "C"}, ["B", "A", "D", "C"], 3) == (0.5, 1 / 3)
Chiama questa funzione solo quando rilevanti non è vuoto. I casi senza risposta
richiedono la metrica di astensione separata.
Per più query, calcola ogni coppia di metriche e poi la media. Per i casi senza risposta usa invece:
La percentuale è il numero di True diviso per il totale dei casi senza risposta.
Soluzione avanzata: criteri di verifica
Una soluzione adeguata usa lo stesso golden set per lessicale, embedding e ibrido; misura almeno Recall@3, MRR e latenza; registra modello e versione; applica gli stessi filtri di autorizzazione prima di ogni ranking. Il risultato non deve essere "l'embedding sembra migliore", ma una tabella comparabile che mostra su quali query ogni variante vince o perde.
14. Errori comuni
- Partire dal database vettoriale. Prima definisci documenti, query e misura; lo strumento non corregge dati scadenti.
- Usare solo embedding. Codici, nomi e numeri possono favorire BM25.
- Creare chunk enormi. Aumentano rumore, costo e rischio di perdere il dettaglio.
- Filtrare permessi dopo il retrieval. Dati vietati possono già arrivare a log, cache, reranker o modello.
- Valutare soltanto la risposta finale. Se la fonte non viene recuperata, cambiare prompt non risolve il problema.
- Trattare il punteggio come probabilità. È un valore di ordinamento dipendente dal sistema.
- Forzare sempre una risposta. Le query senza fonte devono poter produrre astensione.
- Accettare citazioni inventate. Verifica che ogni identificatore sia fra i chunk forniti al modello.
- Mescolare casi con e senza risposta nel Recall. Misurali in segmenti distinti.
15. Domande di autoverifica
Perché Ctrl+F non basta su un grande corpus?
Perché cerca parole esatte, spesso in un file, senza ranking semantico e filtri
completi.
Che differenza c'è fra documento e chunk?
Il documento è l'unità originale; il chunk è una sua porzione indicizzata.
A che cosa servono i metadati?
A filtrare, citare, aggiornare e applicare permessi.
Qual è il punto forte di BM25?
Parole esatte, termini rari, codici e nomi.
Che cosa rappresenta un embedding?
Un testo come vettore numerico in uno spazio appreso.
Perché usare retrieval ibrido?
Per combinare i punti forti della ricerca lessicale e semantica.
Che cosa misura Recall@3?
La quota di tutti i documenti rilevanti trovata nei primi tre risultati.
Come valuti una domanda senza risposta nel corpus?
Con astensione corretta e false risposte, non con Recall.
Quando va applicato il filtro tenant?
Prima o durante il retrieval, mai affidandolo al modello.
Approfondimento tecnico
Dopo il laboratorio, continua con la GUIDA tecnica su RAG in produzione.