Salta al contenuto principale

Setup completo Ollama + RAG + RediSearch produzione: stack open-source 2026

Come costruire un recupero ibrido per i documenti aziendali: suddivisione, BM25, vettori e fusione, con un programma eseguito e lo schema per Redis.

Cosa ne pensi di questo articolo?

Setup completo Ollama + RAG + RediSearch produzione: stack open-source 2026
Sovereign AI PMI EUFoto:Tara WinsteadPexels

In Breve

Come costruire un recupero ibrido per i documenti aziendali: suddivisione, BM25, vettori e fusione, con un programma eseguito e lo schema per Redis.

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

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

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