Vai al contenuto
AI.info

Guide pratiche

Costruire un sistema RAG da zero

Una guida completa, passo dopo passo, alla costruzione di un sistema di generazione aumentata tramite recupero pronto per l’uso in produzione. Tratta l’elaborazione dei documenti, la suddivisione in chunk, gli embedding, gli archivi vettori

Costruire un sistema RAG da zero

AI.info Team ·

Architettura

Un sistema RAG impiegato in produzione ha cinque componenti: una pipeline di ingestione che trasforma i documenti grezzi in chunk ricercabili, un modello di embedding che converte il testo in vettori, un archivio vettoriale che indicizza quei vettori per la ricerca approssimata dei vicini più prossimi (ANN), un livello di recupero e reranking che restringe i candidati ai pochi passaggi da mostrare al modello e una fase di generazione che compone un prompt e produce una risposta con citazioni. La valutazione accompagna tutte queste fasi, non viene dopo: serve un modo per misurare la qualità del recupero indipendentemente da quella della generazione, perché un sistema può fallire in una delle due fasi per ragioni diverse.

Il flusso dei dati è:

documents -> loaders -> chunker -> embedder -> vector store (index)
                                                      |
query -> embedder -> ANN search (top-k) -> reranker -> top-n -> prompt -> LLM -> answer + citations

Trattate fin dal primo giorno l’ingestione e il recupero al momento della query come servizi separati. L’ingestione è un processo batch o in streaming da eseguire secondo una pianificazione o in risposta a trigger webhook quando cambiano i documenti di origine; il recupero è un percorso di lettura a bassa latenza. Confonderli è il motivo principale per cui i prototipi RAG non reggono il traffico di produzione.

Ingestione e suddivisione in chunk

Si comincia caricando i documenti. Per i PDF, pypdf o la libreria unstructured (che gestisce anche DOCX, HTML, PPTX e immagini scansionate, ricorrendo all’OCR quando necessario) possono fornire il testo insieme ai metadati per pagina o per elemento, anche se la qualità dell’estrazione di pypdf varia a seconda del programma che ha generato il PDF. Conservate i metadati di origine (ID del documento, numero di pagina, titolo della sezione, URL, data dell’ultima modifica) durante il caricamento: recuperarli in seguito non è semplice né economico, e servono per le citazioni e per filtrare il recupero (per esempio, «cerca solo nei documenti aggiornati dopo X»).

La suddivisione in chunk è la decisione che incide di più sull’intera pipeline. Prevalgono due tipi di errore: chunk troppo grandi diluiscono l’embedding (il vettore rappresenta una media di più idee, rendendo meno precisa la ricerca semantica), mentre chunk troppo piccoli perdono il contesto circostante di cui il LLM ha bisogno per rispondere correttamente. RecursiveCharacterTextSplitter di langchain_text_splitters è una scelta predefinita ragionevole: suddivide prima in corrispondenza delle interruzioni di paragrafo, poi delle frasi e infine delle parole, ricorrendo a tagli netti in base ai caratteri solo quando un paragrafo supera di per sé chunk_size:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,       # characters, not tokens
    chunk_overlap=120,    # preserves context across chunk boundaries
    separators=["\n\n", "\n", ". ", " ", ""],
)

chunks = splitter.split_text(document_text)

Per contenuti tecnici o strutturati (documentazione API, contratti legali, codice), è preferibile una suddivisione che tenga conto della struttura: dividete prima in corrispondenza dei titoli Markdown o dei tag HTML, poi suddividete ricorsivamente le sezioni ancora troppo grandi. In questo modo, il contesto recuperato per ogni chunk resta allineato a un’unità coerente (una funzione, una clausola, una voce delle FAQ), anziché a un intervallo arbitrario di caratteri. Per la prosa, una dimensione dei chunk di 500–1.000 caratteri (circa 150–300 token), con una sovrapposizione del 10–20%, è un buon punto di partenza; regolatela in base al vostro set di valutazione, invece di affidarvi al valore predefinito suggerito da un post di un blog.

Assegnate ai chunk ID stabili e deterministici (un hash dell’ID della fonte e dell’offset), così che una nuova ingestione di un documento invariato non crei vettori duplicati e possiate eliminare o sostituire esattamente i chunk appartenenti a un documento di origine aggiornato.

Embedding

text-embedding-3-small di OpenAI (1.536 dimensioni, 0,02 dollari per 1M di token di input) e text-embedding-3-large (3.072 dimensioni, 0,13 dollari per 1M di token di input) sono le opzioni standard come servizio esterno; entrambi supportano la tecnica di rappresentazione Matryoshka: passando un parametro dimensions, si ottiene un vettore più corto ma comunque utile (per esempio, di 256 o 512 dimensioni), riducendo i costi di archiviazione e ricerca a fronte di un lieve calo del richiamo.

Questi sono i prezzi di listino per le chiamate sincrone; usare la Batch API per l’ingestione li dimezza, una scelta conveniente per la prima indicizzazione completa di un corpus, che sarà la spesa per embedding più alta da sostenere in una sola volta.

Se avete bisogno di mantenere gli embedding sulla vostra infrastruttura o di evitare un costo per ogni chiamata, i modelli aperti dell’ecosistema sentence-transformers — BAAI/bge-small-en-v1.5 o sentence-transformers/all-MiniLM-L6-v2 — funzionano localmente su CPU o su una piccola GPU e sono competitivi nei benchmark di recupero in inglese.

Componente Specifiche
text-embedding-3-small 1.536 dimensioni, 0,02 dollari per 1M di token di input
text-embedding-3-large 3.072 dimensioni, 0,13 dollari per 1M di token di input
Cohere rerank-v4.0-pro / -fast contesto di 32K token, multilingue
from openai import OpenAI

client = OpenAI()

def embed(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
    resp = client.embeddings.create(model=model, input=texts)
    return [d.embedding for d in resp.data]

Raggruppate le chiamate per generare gli embedding: inviate 100–500 chunk per richiesta anziché uno alla volta, dato che entrambi i modelli accettano liste input in batch, e memorizzate i risultati in una cache usando l’ID del chunk come chiave, così le successive esecuzioni del processo di ingestione saltano i chunk invariati. Qualunque modello scegliate per l’ingestione, dovete usare esattamente lo stesso modello (e la stessa impostazione di dimensions) per le query al momento del recupero: gli embedding prodotti da modelli diversi non sono confrontabili.

Archivio vettoriale

Per la maggior parte dei team che usano già Postgres, pgvector è la scelta più pragmatica: un sistema in meno da gestire, coerenza transazionale con le tabelle di metadati esistenti e capacità di scalare fino a decine di milioni di vettori con un indice HNSW:

CREATE TABLE chunks (
    id UUID PRIMARY KEY,
    document_id UUID NOT NULL,
    content TEXT NOT NULL,
    metadata JSONB,
    embedding VECTOR(1536)
);

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

HNSW costruisce l’indice in modo incrementale (a differenza di IVFFlat, non richiede una fase di addestramento separata) e offre il miglior compromesso tra richiamo e latenza per i carichi di lavoro in cui prevalgono le letture, al prezzo di un maggiore uso della memoria e di inserimenti più lenti. È un compromesso accettabile per la maggior parte dei corpus RAG, dove prevalgono le letture. Se una singola istanza Postgres non basta più, o se serve un servizio ANN completamente gestito e scalabile orizzontalmente, vale la pena migrare a un archivio specializzato (Chroma per l’uso locale o integrato, oppure un database vettoriale ospitato).

Chroma è la via più rapida per ottenere un prototipo locale funzionante:

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs")

collection.add(ids=chunk_ids, embeddings=chunk_vectors,
                documents=chunk_texts, metadatas=chunk_metadata)

results = collection.query(query_embeddings=[query_vector], n_results=20)

FAISS (faiss-cpu/faiss-gpu) è la libreria a cui ricorrere se si vogliono le massime prestazioni ANN e si è disposti a gestire direttamente la persistenza e l’archiviazione dei metadati: è un indice di ricerca, non un database.

import faiss

index = faiss.IndexHNSWFlat(1536, 32)   # d=1536, M=32
index.hnsw.efConstruction = 64
index.hnsw.efSearch = 64
index.add(vectors)                       # normalize vectors first for cosine similarity
distances, ids = index.search(query_vector.reshape(1, -1), k=20)

Qualunque archivio si scelga, bisogna sempre applicare filtri sui metadati insieme alla ricerca vettoriale (tipo di documento, intervallo di date, etichette di controllo degli accessi): la ricerca puramente semantica, senza filtri, è il modo in cui i sistemi RAG finiscono per divulgare contenuti tra tenant diversi o mostrare documenti obsoleti prima di quelli aggiornati.

Recupero e riordinamento dei risultati

Recuperate più risultati di quanti prevedete di usare. Estraete i primi 15–30 candidati per similarità vettoriale (oppure con una ricerca ibrida: vettoriale + BM25/ricerca per parole chiave, particolarmente importante per termini esatti come codici SKU dei prodotti, codici di errore o nomi che gli embedding tendono a confondere), poi riordinateli e riduceteli ai 3–8 segmenti che entreranno effettivamente nel prompt. Il riordinamento supera sistematicamente il recupero diretto ad alta precisione, perché un cross-encoder valuta insieme la query e il passaggio (con attenzione completa tra i due), mentre gli embedding bi-encoder usati per la ricerca ANN iniziale comprimono ogni testo separatamente e perdono i segnali delle interazioni più fini.

Ci sono due opzioni pratiche per il riordinamento. La prima è Rerank 4 di Cohere, un servizio ospitato annunciato l’11 dicembre 2025 e disponibile in due modelli tra cui scegliere: rerank-v4.0-pro se si privilegia la precisione e rerank-v4.0-fast se si privilegia la latenza. Entrambi sono multilingue e accettano un contesto di 32K token, otto volte i 4.096 token della generazione rerank-v3.5 che sostituiscono: questo significa che non è più necessario troncare un segmento lungo prima di sottoporlo al cross-encoder. La seconda è un cross-encoder locale di sentence-transformers, come cross-encoder/ms-marco-MiniLM-L6-v2, se è necessario gestire il riordinamento internamente:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")

pairs = [(query, c["content"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_chunks = [c for c, _ in ranked[:5]]

Nella pratica, la combinazione che conta di più è ricerca ibrida più riordinamento: la sola ricerca vettoriale in genere non trova le query che richiedono una corrispondenza esatta, mentre il solo riordinamento è troppo lento per essere applicato a un intero corpus. Usate quindi ANN (o ANN + ricerca per parole chiave, combinati con la reciprocal rank fusion) per ottenere rapidamente un insieme di candidati con un richiamo elevato, poi riordinateli per aumentare la precisione.

Generazione con citazioni

Componete il prompt in modo che il modello possa rispondere soltanto sulla base di ciò che gli avete fornito e che ogni affermazione sia riconducibile a un segmento della fonte. Numerate i segmenti recuperati e chiedete al modello di citarli per numero; conservate la corrispondenza tra ID dei segmenti e fonti insieme alla chiamata di generazione, così da poter trasformare [2] in un link al documento nell’interfaccia.

def build_prompt(query: str, chunks: list[dict]) -> str:
    context = "\n\n".join(
        f"[{i+1}] (source: {c['document_id']}, p.{c['page']})\n{c['content']}"
        for i, c in enumerate(chunks)
    )
    return f"""Answer the question using ONLY the numbered sources below.
Cite sources inline as [1], [2], etc. If the sources don't contain
the answer, say so explicitly instead of guessing.

Sources:
{context}

Question: {query}
Answer:"""

Inviate il prompt a un attuale modello conversazionale di frontiera (o a un modello open-weight comparabile, addestrato a seguire istruzioni) con una temperatura bassa (0–0,3) per le domande fattuali: qui serve coerenza, non creatività. Elaborate poi la risposta per verificare che ogni citazione [n] corrisponda effettivamente a una fonte dell’elenco numerato, e scartate o rigenerate le risposte prive di citazioni quando la domanda richiedeva chiaramente un fondamento nelle fonti. Il prompt di sistema deve imporre rigorosamente di non rispondere al di fuori del contesto fornito: è questa singola istruzione a impedire che un sistema RAG torni silenziosamente ad attingere alle conoscenze parametriche (e non verificabili) del modello quando il recupero produce pochi risultati.

Valutazione

Valuta separatamente il recupero e la generazione: una risposta scadente basata su un recupero perfetto indica un problema nel prompt o nella generazione; una buona risposta costruita sulle fonti sbagliate indica un problema nel recupero. Confondere i due, limitandosi a esaminare le risposte finali, rende quasi impossibile risolvere entrambi.

Crea un insieme di valutazione etichettato di 50–200 query realistiche, identificando a mano i chunk di origine corretti. Monitora le metriche standard del recupero delle informazioni nella fase di recupero: recall@k (il chunk giusto è entrato tra i primi k?) e MRR (quanto in alto si è classificato?). Per la qualità complessiva, RAGAS è il framework open source più utilizzato e, usando un LLM come giudice, calcola le metriche più importanti:

Esegui questa suite di valutazione dopo ogni modifica alla strategia di suddivisione in chunk, al modello di embedding o al template del prompt: questi quattro valori individuano peggioramenti che i controlli manuali a campione tendono a non rilevare, soprattutto i cali nella precisione del contesto dovuti a un recupero top-k troppo ampio.

Errori comuni

Esplora

Altri articoli