Ricerca ibrida e reranking: come far trovare al tuo RAG i pezzi giusti
Hai costruito il tuo primo RAG. Funziona, quasi sempre. Poi un collega chiede: "Cosa dice la clausola 14.3 del contratto?" e il sistema risponde con grande sicurezza parlando della clausola 4.1. Controlli i chunk recuperati e capisci subito cosa è successo: il testo giusto era nel database, ma non è arrivato tra i primi risultati. Il modello ha fatto il suo lavoro su materiale sbagliato.
Questo è il guasto più comune di un RAG, e la buona notizia è che raramente si risolve cambiando modello. Si risolve migliorando il recupero, cioè la fase che decide quali pezzi di testo arrivano al modello. In questo articolo vedi due rinforzi che funzionano in quasi tutti i casi, la ricerca ibrida e il reranking, e li metti insieme in un esempio Python che puoi adattare ai tuoi documenti.
Perché la sola ricerca semantica a volte sbaglia
Gli embedding trasformano un testo in un vettore di numeri, e testi dal significato simile finiscono vicini. È un'idea potente, ma ha un punto cieco: i termini esatti. Un codice prodotto come "XR-2045", una sigla, il numero di un articolo di legge, un nome proprio poco comune: per un embedding sono quasi rumore, perché il modello li ha visti poco e li rappresenta in modo vago. Chiedi della "clausola 14.3" e ottieni chunk che parlano di clausole in generale.
La vecchia ricerca per parole chiave, quella che sta dietro ai motori di ricerca classici, ha il problema opposto. L'algoritmo più diffuso si chiama BM25: conta quante volte i termini della domanda compaiono in un documento, dando più peso ai termini rari. Trova "14.3" al volo, ma se l'utente scrive "disdetta" e nel documento c'è scritto "recesso", BM25 non vede nessun legame.
Due strumenti, due punti ciechi opposti. L'idea della ricerca ibrida è semplicemente usarli entrambi.
Primo rinforzo: la ricerca ibrida
Fai girare la stessa domanda su due indici, uno semantico e uno per parole chiave, e ottieni due classifiche. Il problema pratico è come unirle: i punteggi non sono confrontabili, perché la similarità del coseno sta tra 0 e 1 mentre BM25 produce numeri senza una scala fissa. Sommarli direttamente significa far vincere sempre quello con i numeri più grandi.
La soluzione più usata è elegante nella sua semplicità e si chiama Reciprocal Rank Fusion (RRF). Ignora i punteggi e guarda solo le posizioni: ogni documento riceve 1 / (k + posizione) da ciascuna classifica, e i contributi si sommano. Un chunk che compare alto in entrambe le liste vince; uno che compare alto in una sola resta comunque in corsa. La costante k, di solito 60, evita che il primo posto pesi troppo rispetto al secondo.
Secondo rinforzo: il reranking
Il secondo rinforzo cambia l'ordine di lavoro. Invece di chiedere al sistema di recupero di essere perfetto, gli chiedi di essere generoso: recupera i primi 20-50 candidati, anche con qualche intruso. Poi un modello specializzato, il cross-encoder, li rilegge uno per uno insieme alla domanda e assegna a ogni coppia un punteggio di pertinenza.
Perché non usarlo subito su tutto? Per via del costo. Un embedding (il cosiddetto bi-encoder) calcola il vettore di ogni documento una volta sola, in anticipo, e poi la ricerca è un confronto veloce. Un cross-encoder deve invece leggere domanda e testo insieme, ogni volta: è molto più accurato nel giudicare la pertinenza, ma troppo lento per scorrere un archivio intero. Per questo si usano in sequenza: il bi-encoder (e BM25) restringono da migliaia di chunk a poche decine, il cross-encoder sceglie i migliori tra quelle decine. Recupero largo, selezione severa.
Quanto aiutano davvero? Nel test pubblicato da Anthropic sul Contextual Retrieval, partendo dai primi 20 chunk recuperati, unire embedding e BM25 insieme alle tecniche di contesto riduce i recuperi falliti di circa la metà, e aggiungere il reranking porta la riduzione a circa due terzi. I numeri dipendono dal loro dataset e non vanno presi come promessa per il tuo, ma l'ordine di grandezza spiega perché queste due tecniche sono le prime da provare.
Un esempio in Python
Installa le librerie e preparati una lista di chunk di testo, per esempio quelli del tuo RAG precedente:
pip install rank-bm25 sentence-transformers
Poi questo codice mette insieme i tre pezzi: indice per parole chiave, indice semantico, fusione RRF e reranking finale.
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer, CrossEncoder, util
chunks = [...] # la tua lista di testi: un elemento per chunk
# 1) indice per parole chiave
bm25 = BM25Okapi([c.lower().split() for c in chunks])
# 2) indice semantico (embedding multilingue, adatto all'italiano)
bi_encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
emb_chunks = bi_encoder.encode(chunks, convert_to_tensor=True)
# 3) reranker (verifica che il modello scelto supporti l'italiano)
reranker = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1")
def classifica_bm25(domanda, n=30):
punteggi = bm25.get_scores(domanda.lower().split())
return sorted(range(len(chunks)), key=lambda i: punteggi[i], reverse=True)[:n]
def classifica_semantica(domanda, n=30):
q = bi_encoder.encode(domanda, convert_to_tensor=True)
punteggi = util.cos_sim(q, emb_chunks)[0]
return sorted(range(len(chunks)), key=lambda i: float(punteggi[i]), reverse=True)[:n]
def fusione_rrf(classifiche, k=60):
totale = {}
for classifica in classifiche:
for posizione, i in enumerate(classifica, start=1):
totale[i] = totale.get(i, 0) + 1 / (k + posizione)
return sorted(totale, key=totale.get, reverse=True)
def recupera(domanda, top_finale=5, candidati=25):
ibrido = fusione_rrf([classifica_bm25(domanda), classifica_semantica(domanda)])[:candidati]
coppie = [(domanda, chunks[i]) for i in ibrido]
punteggi = reranker.predict(coppie)
ordinati = sorted(zip(ibrido, punteggi), key=lambda x: x[1], reverse=True)
return [chunks[i] for i, _ in ordinati[:top_finale]]
Nota la struttura: due classifiche indipendenti, una fusione che guarda solo le posizioni, 25 candidati dati in pasto al cross-encoder e solo i 5 migliori che arrivano al modello linguistico. Ogni passo si può spegnere da solo, ed è proprio questo che ti permette di misurare cosa serve davvero.
Quando conviene, e quando no
Non va usato tutto sempre. Ogni pezzo aggiunge latenza, costo e una parte in più da mantenere. Come regola pratica:
- Se i tuoi documenti contengono molti codici, sigle, nomi propri o riferimenti numerici, la ricerca ibrida è la prima cosa da provare.
- Se il materiale giusto compare tra i primi 20-30 risultati ma non tra i primi 5, il guaio è l'ordine, e il reranking è il rimedio naturale.
- Se il materiale giusto non compare affatto nemmeno tra i primi 50, nessun rinforzo ti salverà: il problema sta a monte, nei chunk (guarda la dispensa sul chunking) o nei documenti stessi.
Una cosa va detta con onestà: l'ibrido non è sempre meglio del semantico puro. Su domande molto discorsive, senza termini esatti, la parte BM25 aggiunge poco. Per questo serve misurare, non fidarsi.
Come capire se sta funzionando
Prepara un piccolo insieme di domande reali, venti bastano, e per ciascuna segna a mano quale chunk dovrebbe essere recuperato. Poi, per ogni configurazione, conta quante volte quel chunk compare tra i primi 5. Confronta quattro versioni: solo semantico, solo BM25, ibrido, ibrido con reranking. È un test di mezz'ora e ti dà un numero invece di un'impressione. Se la risposta finale è ancora sbagliata nonostante il chunk giusto sia tra i primi, il problema non è più il recupero ma la generazione: lì servono grounding e citazioni, che sono un altro capitolo.
Prova subito
Prendi il RAG che hai costruito e il suo elenco di chunk. Scrivi venti domande reali, e per almeno cinque fai contenere un termine esatto: un codice, un numero di sezione, una sigla. Indica per ciascuna il chunk corretto. Poi esegui le quattro configurazioni (semantico, BM25, ibrido, ibrido con reranking) e annota in una tabellina in quante domande il chunk giusto compare tra i primi 5.
Guarda in particolare le cinque domande con termine esatto: sono quelle dove vedrai la differenza più netta tra semantico e ibrido. E se il reranking migliora poco il tuo caso, hai appena scoperto che ti conveniva risparmiare la latenza. In entrambi i casi hai un dato in mano, che vale più di qualsiasi articolo, compreso questo.