Il recupero decide la qualità della risposta più del modello
Un assistente che risponde sui documenti della tua azienda lavora in due tempi: prima trova i passaggi pertinenti, poi chiede al modello di rispondere usandoli. Il secondo tempo è quello che si guarda; il primo è quello che decide. Se il passaggio giusto non arriva al modello, nessuna taglia di modello lo salva, e se arriva, anche un modello piccolo spesso basta. Questo schema si chiama RAG (generazione aumentata dal recupero), e questo articolo ne costruisce la parte che conta, il recupero, con un programma eseguibile e uno schema per portarlo su Redis.
Un esempio quotidiano: un dipendente chiede «come ottengo il rimborso?». La procedura che gli serve si intitola «nota spese» e non contiene la parola «rimborso». Una ricerca per parole esatte non la trova; una ricerca per significato sì. Al contrario, se chiede «SOP-014», cioè il codice di una procedura, la ricerca per significato può sbagliare mentre quella per parole esatte è infallibile. Per questo si usano entrambe e si fondono i risultati.
I pezzi di un recupero ibrido
Un sistema di recupero ha cinque componenti, ciascuno con una decisione che gli appartiene. Elencarli aiuta a vedere dove si perde qualità, perché gli errori si concentrano nei primi due.
- Suddivisione (chunking): i documenti si tagliano in passaggi di dimensione limitata, spesso con una piccola sovrapposizione fra passaggi vicini.
- Embedding: ogni passaggio diventa un vettore di numeri; passaggi dal significato simile hanno vettori vicini.
- Indice: una struttura che trova rapidamente i vettori più vicini a quello della domanda.
- Ricerca per parole (BM25): un secondo modo di classificare i passaggi, basato sui termini condivisi con la domanda.
- Fusione: un metodo che combina le due classifiche in una sola.
La suddivisione è la decisione più sottovalutata. Passaggi troppo piccoli perdono il contesto, e la domanda trova una frase senza il resto del ragionamento; passaggi troppo grandi diluiscono il significato in un vettore solo, e il modello riceve molto testo inutile. Non esiste una dimensione giusta per tutti: si sceglie provando sul proprio insieme di domande, come mostra l'articolo sulla valutazione su dati propri.
Una pipeline completa che gira senza modelli
Il programma seguente implementa tutti e cinque i pezzi in Python standard, senza librerie e senza scaricare nulla, su un corpus inventato di sei procedure aziendali. Una avvertenza essenziale: l'embedding qui non è un modello. È un surrogato deterministico che raggruppa a mano alcune parole sinonime (ferie e permesso, rimborso e nota spese) e le proietta in un vettore con un hash stabile. Serve a eseguire l'intera pipeline e a vedere come si comportano la fusione e la ricerca per parole; i risultati, per costruzione, non dicono niente sulla qualità di un modello vero.
Le funzioni da guardare sono quattro. chunk taglia un testo in finestre di parole con sovrapposizione; i valori 12 e 3 sono didattici, in produzione si ragiona in centinaia di token. embed sostituisce ogni parola con il suo gruppo di sinonimi prima di calcolare il vettore, ed è questa riga a dare al surrogato una «semantica» da gioco. La classe Bm25 calcola il punteggio classico per termine, con peso maggiore per le parole rare e un tetto alla frequenza. rrf somma per ogni passaggio l'inverso della sua posizione in ciascuna classifica, con la costante 60 proposta dagli autori del metodo.
"""Recupero ibrido su un piccolo corpus: BM25 + vettori + fusione RRF.
ATTENZIONE: l'"embedding" qui sotto NON e' un modello. E' un surrogato
deterministico: raggruppa a mano alcune parole sinonime e le proietta con
un hash stabile (sha256). Serve a far girare l'intera pipeline senza scaricare
alcun modello; i punteggi non dicono nulla sulla qualita' di un modello vero.
"""
import hashlib
import math
import re
from collections import Counter
DOCUMENTI = {
"SOP-001": "Le ferie si richiedono sul portale almeno dieci giorni prima. Il responsabile approva o rifiuta entro tre giorni.",
"SOP-007": "La nota spese va presentata entro trenta giorni dalla trasferta, con gli scontrini originali allegati.",
"SOP-011": "Un incidente informatico si segnala subito all'helpdesk, indicando ora, computer e che cosa e' successo.",
"SOP-014": "Gli acquisti oltre cinquecento euro richiedono l'approvazione del direttore amministrativo prima dell'ordine.",
"SOP-019": "Le chiavi dell'archivio sono custodite dalla segreteria e consegnate solo con firma sul registro.",
"SOP-023": "La formazione sulla sicurezza si ripete ogni anno; il rimborso dei corsi esterni segue la procedura delle spese.",
}
CORPUS = {d: f"{d}. {testo}" for d, testo in DOCUMENTI.items()} # il codice fa parte del testo indicizzato
SINONIMI = [ # IPOTESI: gruppi scritti a mano, il surrogato di una 'semantica'
{"ferie", "permesso", "assenza"},
{"rimborso", "nota", "spese", "spesa"},
{"incidente", "guasto", "problema", "violazione"},
{"acquisti", "acquisto", "ordine", "fornitore"},
]
DIM = 128
STOP = {"il", "lo", "la", "le", "i", "gli", "di", "da", "in", "su", "per", "con", "e", "o", "che", "si", "un", "una", "come", "chi", "ogni", "del", "dei", "al", "alla", "ho", "ottengo", "dalla", "dell", "sono"}
def token(testo):
return [w for w in re.findall(r"[a-z0-9]+", testo.lower()) if w not in STOP]
def chunk(testo, parole=12, sovrapposizione=3):
w = testo.split()
passo = parole - sovrapposizione
return [" ".join(w[i:i + parole]) for i in range(0, max(1, len(w) - sovrapposizione), passo)]
def slot(parola):
return int(hashlib.sha256(parola.encode()).hexdigest(), 16) % DIM
def embed(testo):
v = [0.0] * DIM
for t in token(testo):
gruppo = next((g for g in SINONIMI if t in g), {t})
for parola in gruppo:
v[slot(parola)] += 1.0 / len(gruppo)
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
def coseno(a, b):
return sum(x * y for x, y in zip(a, b))
class Bm25:
def __init__(self, docs, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.tf = {d: Counter(token(t)) for d, t in docs.items()}
self.len = {d: sum(c.values()) for d, c in self.tf.items()}
self.avg = sum(self.len.values()) / len(docs)
self.df = Counter(t for c in self.tf.values() for t in c)
self.n = len(docs)
def punteggio(self, query, d):
s = 0.0
for t in token(query):
if t not in self.tf[d]:
continue
idf = math.log(1 + (self.n - self.df[t] + 0.5) / (self.df[t] + 0.5))
f = self.tf[d][t]
s += idf * f * (self.k1 + 1) / (f + self.k1 * (1 - self.b + self.b * self.len[d] / self.avg))
return s
def classifica(punteggi):
ordinati = sorted(punteggi.items(), key=lambda kv: (-kv[1], kv[0]))
return [d for d, s in ordinati if s > 0]
def rrf(*liste, k=60):
"""Reciprocal Rank Fusion: somma 1/(k + posizione) su ogni classifica."""
tot = Counter()
for lista in liste:
for pos, d in enumerate(lista, start=1):
tot[d] += 1.0 / (k + pos)
return [d for d, _ in sorted(tot.items(), key=lambda kv: (-kv[1], kv[0]))]
bm = Bm25(CORPUS)
vett = {d: embed(t) for d, t in CORPUS.items()}
for q in ["SOP-014", "come ottengo il rimborso", "chi custodisce le chiavi"]:
a = classifica({d: bm.punteggio(q, d) for d in CORPUS})
qv = embed(q)
b = classifica({d: coseno(qv, vett[d]) for d in CORPUS})
print(f"query: {q!r}")
print(" BM25 :", a[:3])
print(" vett.:", b[:3])
print(" RRF :", rrf(a, b)[:3])
print("\nchunk di SOP-001 con finestra di 12 parole e sovrapposizione 3:")
for c in chunk(DOCUMENTI["SOP-001"]):
print(" -", c)
# Output:
# query: 'SOP-014'
# BM25 : ['SOP-014', 'SOP-019', 'SOP-007']
# vett.: ['SOP-014', 'SOP-007', 'SOP-011']
# RRF : ['SOP-014', 'SOP-007', 'SOP-019']
# query: 'come ottengo il rimborso'
# BM25 : ['SOP-023']
# vett.: ['SOP-007', 'SOP-023', 'SOP-001']
# RRF : ['SOP-023', 'SOP-007', 'SOP-001']
# query: 'chi custodisce le chiavi'
# BM25 : ['SOP-019']
# vett.: ['SOP-019', 'SOP-001']
# RRF : ['SOP-019', 'SOP-001']
#
# chunk di SOP-001 con finestra di 12 parole e sovrapposizione 3:
# - Le ferie si richiedono sul portale almeno dieci giorni prima. Il responsabile
# - prima. Il responsabile approva o rifiuta entro tre giorni.
La seconda domanda mostra il motivo della fusione. La ricerca per parole trova solo SOP-023, che contiene la parola «rimborso» ma parla di corsi esterni; il surrogato vettoriale mette al primo posto SOP-007, la nota spese, che è quella giusta, e tiene SOP-023 secondo. La fusione le porta entrambe nelle prime due posizioni, ed è il risultato che vuoi: il modello riceve entrambi i passaggi e può rispondere. La prima domanda, il codice esatto, è risolta da tutte e due le ricerche. Un effetto da conoscere, che il programma nasconde scartando i punteggi nulli: una ricerca KNN restituisce sempre i k vicini più prossimi, anche quando nessuno è pertinente, mentre quella per parole restituisce una lista vuota se nessuna parola coincide. In produzione serve una soglia di distanza, da tarare sul proprio insieme di domande.
La formula della fusione è semplice e il suo pregio è non richiedere di confrontare punteggi di natura diversa: un punteggio BM25 e una distanza coseno non sono commensurabili, mentre le posizioni sì. Il metodo ha una debolezza: usa solo l'ordine, quindi due candidati quasi identici per punteggio e uno molto distante occupano posizioni consecutive. Se serve più finezza, si aggiunge un secondo passaggio che riordina i primi candidati con un modello apposito; è un'ottimizzazione che si valuta dopo aver misurato il recupero semplice.
Portare il recupero su Redis
Redis offre la ricerca vettoriale tramite il suo motore di ricerca: si crea un indice con FT.CREATE sui campi dei tuoi hash, di cui uno di tipo VECTOR, e si interroga con FT.SEARCH usando una clausola KNN. Il tipo di indice è una scelta con un criterio dichiarato nella documentazione di Redis: FLAT per set di dati piccoli (sotto un milione di vettori) o quando conta più la precisione perfetta della latenza, HNSW per set più grandi o quando contano scalabilità e velocità. Per l'archivio documentale di una PMI, che raramente supera poche decine di migliaia di passaggi, FLAT è la scelta di partenza.
# Schema da adattare, NON eseguito (nessun Redis e' stato avviato o scritto).
# Sintassi dalla documentazione Redis sulla ricerca vettoriale.
# 1) Un indice FLAT su hash con prefisso "chunk:".
# DIM deve essere identico alla lunghezza dei vettori del TUO modello di embedding.
FT.CREATE idx:chunk ON HASH PREFIX 1 chunk: SCHEMA
testo TEXT
fonte TAG
vettore VECTOR FLAT 6 TYPE FLOAT32 DIM 384 DISTANCE_METRIC COSINE
# 2) Ricerca dei 5 vicini piu' prossimi, filtrata per fonte.
# $v e' il vettore della domanda in byte grezzi (float32 little-endian).
FT.SEARCH idx:chunk "(@fonte:{regolamento})=>[KNN 5 @vettore $v AS distanza]"
PARAMS 2 v <blob> SORTBY distanza DIALECT 2
Lo schema ha tre punti delicati. DIM 384 deve coincidere con la lunghezza dei vettori del modello di embedding: 384 è la dimensione dello strato nascosto della configurazione di all-MiniLM-L6-v2, ed è un valore d'esempio; il tuo modello può averne un'altra, e l'indice non accetta vettori di lunghezza diversa. Il vettore della domanda deve essere calcolato con lo stesso modello usato per i passaggi: cambiare modello significa ricalcolare e reindicizzare tutto. E la clausola DIALECT 2 è richiesta dalla documentazione per le query vettoriali. Lo schema non è stato eseguito: nessun Redis è stato avviato né scritto durante la preparazione di questo articolo.
I vettori si ottengono dall'endpoint /api/embed di Ollama, che accetta un testo o una lista di testi e restituisce la lista embeddings. La lunghezza di un vettore restituito è il valore da mettere in DIM: leggila dalla risposta, non dalla memoria.
# Schema da adattare, NON eseguito (nessun modello chiamato).
# Endpoint documentato: POST /api/embed. La risposta contiene "embeddings",
# una lista di vettori: la lunghezza di ciascuno e' il DIM dell'indice.
curl http://127.0.0.1:11434/api/embed -d '{
"model": "<modello-di-embedding>",
"input": ["testo del primo chunk", "testo del secondo chunk"]
}'
La chiamata mostra una scelta di progetto: il campo input accetta una lista, quindi i testi si inviano a gruppi invece che uno per volta, e si paga una richiesta sola ogni gruppo. Il parametro keep_alive della stessa API decide quanto il modello resta in memoria dopo la richiesta; il valore predefinito è di cinque minuti. Se il modello di embedding e il modello di risposta non stanno insieme nella RAM, il sistema li scambia a ogni richiesta e la latenza esplode: è un caso da misurare sul tuo server, come spiega l'articolo sul dimensionamento della memoria.
Che cosa va storto in un recupero reale
- Il modello di embedding cambia e l'indice no: le distanze diventano senza senso, ma le query continuano a rispondere.
- I documenti si aggiornano e l'indice resta vecchio: l'assistente risponde con una versione superata della procedura.
- Il passaggio giusto è tagliato a metà: la risposta si trova in due passaggi, e nessuno dei due la contiene intera.
- Dati personali nei passaggi: l'indice li copia, e cancellarli dall'origine non li cancella dall'indice.
- Il modello ignora i passaggi: senza un'istruzione esplicita risponde dalla sua memoria, non dal contesto.
L'ultimo punto è un problema di prompt, non di recupero: l'istruzione deve dire di rispondere solo con i passaggi forniti e di citarne il codice, e la risposta va verificata a campione. Il quarto è un problema di conformità che ha un articolo dedicato, privacy by design, con il programma che mostra come la cancellazione deve raggiungere anche l'indice. Sulla misura della qualità del recupero e della risposta, la serie ha un pezzo sulla valutazione del RAG.
Domande frequenti
RAG significa addestrare il modello sui miei documenti?
No. Il modello non cambia: a ogni domanda gli vengono dati in ingresso i passaggi recuperati. I documenti restano nell'indice, dove si possono aggiornare o cancellare senza toccare il modello.
Serve la ricerca vettoriale o basta la ricerca per parole?
Per documenti con terminologia uniforme e codici esatti la ricerca per parole può bastare. Quando le domande usano parole diverse da quelle dei documenti, la ricerca vettoriale aiuta, e la fusione delle due è la scelta più robusta.
Quale tipo di indice scegliere in Redis, FLAT o HNSW?
La documentazione di Redis indica FLAT per set di dati piccoli, sotto un milione di vettori, o quando conta la precisione perfetta, e HNSW per set più grandi. Per un archivio aziendale tipico si parte da FLAT.
Che cosa sai fare adesso
Sai scomporre un sistema di recupero nei suoi cinque pezzi e sai dove si perde qualità; hai un programma che mostra la fusione di una ricerca per parole e una per significato, e sai che cosa il suo surrogato non dimostra. Sai tradurre lo schema in un indice Redis con la dimensione giusta e le due trappole del modello di embedding. Il modello che genera la risposta si sceglie con i criteri di quale modello scegliere. Per il passo successivo, la valutazione del recupero misura se funziona sui tuoi dati, e il pezzo sulla scalabilità dice che cosa succede con più utenti.
Nella stessa serie trovi anche il conto dei costi di un server in proprio e il riepilogo con la scheda di decisione.
Fonti
- Cormack, Clarke, Büttcher, «Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods» (SIGIR 2009) - la formula di fusione e la costante k = 60, scelta in uno studio pilota e risultata quasi ottimale nelle loro prove, consultata il 2026-11-13
- Redis, Vectors (documentazione di ricerca vettoriale) - sintassi di FT.CREATE e FT.SEARCH con KNN, DIALECT 2, e il criterio di scelta fra FLAT e HNSW, consultata il 2026-11-13
- Ollama, riferimento dell'API: Generate Embeddings - endpoint /api/embed, parametro input, campo embeddings e keep_alive, consultata il 2026-11-13
- config.json di all-MiniLM-L6-v2 - hidden_size 384, usato come esempio di DIM, consultata il 2026-11-13
Il programma è stato eseguito con Python 3.14.4 (python3 -I), due volte, con uscita identica; l'hash è sha256, non hash(), per essere deterministico. Il surrogato di embedding non è un modello e i suoi risultati non misurano alcun modello. Schema di Redis e chiamata a Ollama sono schemi da adattare, non eseguiti: nessun servizio avviato, nessun modello chiamato.

Commenti
Caricamento commenti...
Accedi per commentare