Python & AI Tutorials Logo
LangChain & LangGraph

9. Costruire il tuo primo sistema RAG

Ogni applicazione che abbiamo costruito finora si basava solo sulla conoscenza pre-addestrata dell'LLM. Ecco perché non poteva rispondere a domande su informazioni che l'LLM non aveva mai appreso—come i documenti interni della tua azienda o i manuali dei prodotti.

RAG (Retrieval-Augmented Generation) risolve questo problema. Quando arriva una domanda dell'utente, prima recupera i documenti rilevanti, poi passa il contenuto recuperato insieme alla domanda all'LLM in modo che possa rispondere basandosi su quel contenuto. Stai combinando la capacità di ragionamento dell'LLM con la conoscenza dei tuoi documenti.

In questo capitolo, costruiremo una pipeline RAG completa dalla preparazione dei documenti (caricamento, suddivisione, embedding) alla generazione di risposte basate sul recupero. Il sistema finito recupera i documenti rilevanti quando arriva una domanda, poi li passa insieme alla domanda all'LLM in modo che risponda basandosi su quel contenuto documentale. Risponde accuratamente quando l'informazione è nei documenti, e dice onestamente "Non lo so" quando non lo è—questa è l'essenza di un RAG affidabile.

9.1) Comprendere RAG

9.1.1) Il problema: gli LLM non conoscono i tuoi dati

Gli LLM sono addestrati su dati internet come Wikipedia, articoli di notizie e codice pubblico. Non conoscono i documenti interni della tua azienda o il contratto che hai ricevuto ieri. Quindi non possono rispondere a domande come:

  • "Qual è la politica ferie della nostra azienda?"
  • "Riassumi il rapporto vendite di questo trimestre"
  • "Quali sono i termini di rimborso nel contratto che ho appena ricevuto?"
python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# Chiedi informazioni su un documento privato che l'LLM non ha mai visto
response = llm.invoke("Qual è la politica di rimborso per Acme Corp?")
print(response.content)

Output:

Non ho informazioni specifiche sulla politica di rimborso di Acme Corp. Ti consiglio
di controllare il loro sito web ufficiale o di contattare direttamente il loro team
di assistenza clienti per le informazioni più accurate e aggiornate.

In questo esempio, l'LLM dice onestamente che non lo sa. (Oppure potrebbe allucinare una risposta plausibile.)

Ma cosa succederebbe se fornissimo il documento della politica di rimborso insieme alla domanda? L'LLM darebbe una risposta accurata basata sul contenuto fornito. Questa è l'idea centrale dietro RAG.

9.1.2) Come dovremmo fornire il documento?

L'approccio più semplice è copiare e incollare l'intero documento nel prompt. Questo funziona effettivamente bene per documenti brevi.

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# In realtà, questo sarebbe molto più lungo, ma supponiamo che quanto segue sia l'intero documento
document_text = """
Politica di Rimborso (Effettiva da Gennaio 2026):
- Rimborso completo entro 30 giorni dall'acquisto con ricevuta originale.
- Dopo 30 giorni, solo credito negozio.
- I prodotti digitali non sono rimborsabili dopo il download.
- Gli articoli difettosi possono essere restituiti in qualsiasi momento per un rimborso completo.
"""
 
response = llm.invoke(
    f"""Rispondi basandoti sul seguente documento:
{document_text}
 
Domanda: Qual è la politica di rimborso per i prodotti digitali?"""
)
print(response.content)

Output:

I prodotti digitali non sono rimborsabili dopo il download.
...

Questo funziona bene per documenti brevi. Ma cosa succede se il documento è molto grande? Questo causa i seguenti problemi seri:

1. Limiti della finestra di contesto: Gli LLM hanno un numero limitato di token che possono elaborare contemporaneamente. Per GPT-5-mini, sono 400K token. Tuttavia, l'intera documentazione della tua azienda può facilmente superare questo limite. Anche se ci sta, le risposte diventano più lente e meno accurate man mano che il contesto diventa più lungo.

2. Costo: Le API LLM addebitano per token. Inviare l'intero documento quando sono necessari solo uno o due paragrafi fa schizzare i costi alle stelle.

3. Degradazione dell'accuratezza: Quando includi l'intero documento, l'informazione di cui hai effettivamente bisogno viene sepolta in contenuto irrilevante. L'attenzione dell'LLM viene deviata da informazioni non correlate, degradando la qualità della risposta.

RAG risolve tutti e tre i problemi recuperando e fornendo solo le parti rilevanti del documento.

9.1.3) Idea centrale: recuperare le parti rilevanti e fornirle con la domanda

L'essenza di RAG è semplice: Prima di inviare la domanda all'LLM, trova prima le parti rilevanti dei tuoi documenti e forniscile insieme alla domanda.

Ecco come funziona:

  1. L'utente fa una domanda.
  2. Il sistema recupera (Retrieval) il contenuto rilevante dall'archivio documenti.
  3. Il contenuto recuperato viene aggiunto (Augmentation) al prompt insieme alla domanda.
  4. L'LLM genera (Generation) una risposta basata sul contenuto recuperato.

Questi tre passaggi sono da dove RAG (Retrieval-Augmented Generation) prende il suo nome.

9.1.4) Come recuperiamo il contenuto rilevante? (Limitazioni della corrispondenza per parole chiave)

Il passaggio di recupero è cruciale per RAG. Devi fornire contenuto rilevante per ottenere risposte corrette. Quindi come recuperiamo il contenuto correlato alla domanda?

Il metodo più semplice è la corrispondenza per parole chiave: trovare documenti che contengono parole dalla domanda. Ad esempio, se qualcuno chiede "Qual è la politica di rimborso per i prodotti digitali?" cercheresti documenti contenenti le parole "rimborso", "digitali" e "prodotti".

Ma la corrispondenza per parole chiave ha una debolezza critica: può trovare solo corrispondenze esatte di parole.

Supponiamo che tu abbia un documento sulla politica di rimborso con questo contenuto:

"Rimborso completo disponibile entro 30 giorni dall'acquisto."

Cosa succede quando un utente chiede "Come posso riavere i miei soldi?" Questo documento non verrà recuperato. Il documento non contiene la frase "riavere i miei soldi". Gli esseri umani capiscono che "rimborso" e "riavere i miei soldi" hanno lo stesso significato, ma la ricerca per parole chiave corrisponde solo alle parole, quindi non riesce a trovarlo.

La ricerca per parole chiave corrisponde solo alle parole. Anche quando il significato è lo stesso, se le parole differiscono, non lo troverà.

La soluzione è la ricerca semantica. E ciò che la rende possibile sono gli embedding.

9.1.5) Embedding: convertire il testo in vettori numerici

Gli embedding rappresentano il significato del testo come una lista di numeri (un vettore). Quando inserisci del testo in un modello di embedding, lo converte in un vettore di centinaia o migliaia di numeri.

python
from langchain_openai import OpenAIEmbeddings
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Incorpora una singola frase
vector = embeddings_model.embed_query("Come posso riavere i miei soldi?")
 
print(f"Dimensioni del vettore: {len(vector)}")
print(f"Primi 5 valori: {vector[:5]}")

Output:

Dimensioni del vettore: 1536
Primi 5 valori: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]

Dimensione è il numero di valori che compongono il vettore. Il modello text-embedding-3-small rappresenta tutto il testo come 1.536 numeri.

Perché abbiamo bisogno di così tanti numeri? Perché ogni dimensione cattura diversi aspetti del significato:

  • Alcune dimensioni potrebbero distinguere "azione/stato"
  • Altre potrebbero rappresentare gradi di "concreto/astratto"
  • Altre ancora potrebbero indicare il sentimento "positivo/negativo"
  • ... (1.536 caratteristiche semantiche—anche se non possiamo effettivamente interpretare cosa rappresenta ogni dimensione)

Proprio come le coordinate 2D (x, y) rappresentano un punto su un piano, un vettore a 1.536 dimensioni rappresenta un punto in uno "spazio di significato" a 1.536 dimensioni. Più dimensioni consentono distinzioni più fini nel significato.

Significati simili sono posizionati vicini nello spazio di significato. "Metodo di rimborso" e "riavere i soldi" usano parole diverse, ma poiché hanno significati simili, sono posizionati vicini nello spazio di significato.

9.1.6) Ricerca semantica: significato simile, distanza più vicina

Una volta convertiti sia i documenti che le query in vettori, puoi trovare i documenti più rilevanti misurando la similarità tra i vettori. Questa è chiamata ricerca semantica — cercare per similarità semantica piuttosto che per corrispondenza di parole chiave.

La misura di similarità più comune è la similarità del coseno, che misura l'angolo tra due vettori. Quando i vettori puntano in direzioni simili, la similarità è più alta. Più vicino a 1.0 significa significato molto simile, mentre più vicino a 0 significa bassa rilevanza.

Qual è il periodo di rimborso?

[0.12, -0.45, 0.78, ...]

Rimborso completo disponibile entro 30 giorni dall'acquisto.

[0.14, -0.42, 0.80, ...]

Il nostro ufficio è a Seattle

[-0.67, 0.33, -0.11, ...]

Vicini!
(similarità ≈ 0.6415)

Lontani
(similarità ≈ 0.1706)

Calcoliamolo effettivamente:

python
from langchain_openai import OpenAIEmbeddings
import numpy as np
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Incorpora la query e due documenti candidati
query_vec = embeddings_model.embed_query("Qual è il periodo di rimborso?")
doc1_vec = embeddings_model.embed_query("Rimborso completo disponibile entro 30 giorni dall'acquisto.")  # Correlato
doc2_vec = embeddings_model.embed_query("Il nostro ufficio si trova nel centro di Seattle.")  # Non correlato
 
def cosine_similarity(a, b):
    """Calcola la similarità del coseno tra due vettori."""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
 
print(f"Query vs doc 'rimborso':    {sim1:.4f}")
print(f"Query vs doc 'ufficio':   {sim2:.4f}")

Output:

Query vs doc 'rimborso':    0.6415
Query vs doc 'ufficio':   0.1706

(I valori effettivi possono variare in base al modello)

Il documento sul rimborso ottiene un punteggio molto più alto. Il modello di embedding comprende che "periodo di rimborso" e "rimborso completo entro 30 giorni" sono semanticamente correlati. Questa è la ricerca semantica, ed è il meccanismo centrale di RAG.

9.1.7) Panoramica della pipeline RAG

Combinando i concetti che abbiamo appreso si crea la seguente pipeline RAG:

Fase 2: Recupero e generazione risposta

Fase 1: Ingestione della conoscenza

📄 Documento completo

✂️ Dividi in chunk

🔢 Incorpora chunk

Vector Store

❓ Domanda utente

🔢 Incorpora domanda

🔍 Ricerca per similarità

📋 Estrai chunk rilevanti

📝 Augmentation del prompt
(Domanda + Chunk rilevanti)

🤖 LLM

Risposta basata su chunk rilevanti

La pipeline consiste di due fasi:

Ingestione della conoscenza (eseguita una volta inizialmente, o quando i documenti cambiano):

  1. Caricamento documenti: Estrai dati testuali da varie fonti (Markdown, PDF, ecc.).
  2. Suddivisione del testo (Chunking): Dividi documenti lunghi in chunk più piccoli per migliorare la precisione del recupero e rispettare i limiti di input dell'LLM.
  3. Conversione vettoriale (Embedding): Usa un modello di embedding per convertire i chunk in vettori numerici basati sul significato.
  4. Archiviazione vettoriale: Memorizza i vettori convertiti e il testo originale in un database vettoriale (indicizzazione).

Recupero e generazione risposta (eseguita per ogni domanda dell'utente):

  1. Embedding della domanda: Converti la domanda dell'utente in un vettore numerico usando lo stesso modello usato durante l'ingestione.
  2. Ricerca per similarità (Retrieval): Estrai i top-K chunk semanticamente più vicini al vettore della domanda dal database vettoriale.
  3. Augmentation del prompt: Combina la domanda originale con i chunk recuperati per aumentare il prompt.
  4. Generazione risposta: L'LLM fa riferimento ai chunk forniti per generare una risposta fondata.

9.2) Caricamento e suddivisione dei documenti

Questa sezione copre i primi due passaggi della fase di ingestione della conoscenza della pipeline RAG:

  1. Caricamento documenti: Lettura di dati testuali da file
  2. Suddivisione del testo (Chunking): Suddivisione dei dati testuali in pezzi piccoli e ricercabili

Nella prossima sezione (9.3), impareremo come convertire questi chunk in vettori e memorizzarli.

9.2.1) Caricamento di documenti da file

Il primo passo in una pipeline RAG è caricare i documenti in oggetti Python. LangChain fornisce document loader — classi che supportano una varietà di formati di file. I principali loader sono:

  • TextLoader: File di testo semplice (.txt) e Markdown (.md)
  • PyPDFLoader: File PDF (.pdf), caricati pagina per pagina
  • CSVLoader: File CSV (.csv), con ogni riga caricata come documento separato
  • UnstructuredMarkdownLoader: File Markdown (.md), con consapevolezza strutturale (intestazioni, liste, ecc.)

Indipendentemente dal loader che usi, il risultato viene sempre restituito come una lista di oggetti Document. Ogni Document ha due attributi chiave:

  • page_content: Il contenuto testuale del documento
  • metadata: Un dizionario contenente meta-informazioni come percorso del file e numero di pagina

In questo tutorial, useremo TextLoader per caricare file Markdown.

Preparazione di documenti di esempio

Prima, creiamo alcuni documenti di esempio con cui lavorare. Crea una cartella data/docs/ nel tuo progetto e aggiungi i seguenti file:

bash
mkdir -p data/docs

Crea data/docs/refund_policy.md:

markdown
# Politica di Rimborso
 
**Data di Entrata in Vigore**: 1 Gennaio 2026
 
## Resi Standard
 
Tutti i prodotti fisici possono essere restituiti entro 30 giorni dall'acquisto per un rimborso completo.
È richiesta la ricevuta originale o l'email di conferma dell'ordine. Gli articoli devono essere nella loro
confezione originale e in condizioni non utilizzate.
 
Dopo 30 giorni, i resi sono accettati solo per credito negozio. Il credito negozio non scade.
 
## Prodotti Digitali
 
I prodotti digitali (licenze software, e-book, corsi online) non sono rimborsabili
una volta che il link di download o accesso è stato attivato. Se riscontri problemi tecnici
che impediscono l'accesso, contatta l'assistenza entro 7 giorni per una sostituzione o rimborso.
 
## Articoli Difettosi
 
Gli articoli difettosi possono essere restituiti in qualsiasi momento per un rimborso completo o sostituzione.
Si prega di includere una descrizione del difetto. I costi di spedizione per i resi difettosi
sono coperti dall'azienda.
 
## Servizi in Abbonamento
 
Gli abbonamenti mensili possono essere cancellati in qualsiasi momento. I rimborsi sono proporzionali in base ai
giorni rimanenti nel ciclo di fatturazione. Gli abbonamenti annuali possono essere rimborsati completamente
entro i primi 14 giorni. Dopo 14 giorni, non è disponibile alcun rimborso ma l'accesso continua fino alla fine del periodo di fatturazione.

Crea data/docs/shipping_info.md:

markdown
# Informazioni sulla Spedizione
 
## Spedizione Nazionale
 
Spedizione standard (5-7 giorni lavorativi): Gratuita per ordini superiori a $50, altrimenti $5.99.
Spedizione express (2-3 giorni lavorativi): $12.99.
Spedizione notturna (giorno lavorativo successivo): $24.99.
 
## Spedizione Internazionale
 
Gli ordini internazionali vengono spediti tramite posta aerea tracciata. I tempi di consegna variano in base alla
destinazione, tipicamente 10-21 giorni lavorativi. I costi di spedizione internazionale sono
calcolati al checkout in base al peso e alla destinazione.
 
I dazi doganali e le tasse di importazione sono a carico dell'acquirente e non sono inclusi nel costo di spedizione.
 
## Tracciamento Ordine
 
Tutti gli ordini includono un numero di tracciamento inviato via email entro 24 ore dalla spedizione.
Traccia il tuo ordine attraverso il link di tracciamento nella tua email o attraverso il sito web del corriere.
 
## Pacchi Smarriti o Danneggiati
 
Se il tuo pacco è smarrito o arriva danneggiato, contatta l'assistenza entro 48 ore.
Spediremo una sostituzione senza costi aggiuntivi. Per articoli danneggiati, si prega di
fornire foto del danno e dell'imballaggio.

Ora carichiamo questi file usando TextLoader:

python
from pathlib import Path
from langchain_community.document_loaders import TextLoader
 
# Carica tutti i file .md dalla directory data/docs
docs_dir = Path("data/docs")
 
for md_file in docs_dir.glob("*.md"):
    loader = TextLoader(str(md_file), encoding="utf-8")
    docs = loader.load()
 
    if docs:  # Verifica che il file non sia vuoto
        doc = docs[0]  # File singolo = singolo Document
        print(f"File: {doc.metadata['source']}")
        print(f"Lunghezza: {len(doc.page_content)} caratteri")
        print(f"Anteprima: {doc.page_content[:80]}...")
        print()

Output:

File: data/docs/refund_policy.md
Lunghezza: 1166 caratteri
Anteprima: # Politica di Rimborso
...
 
File: data/docs/shipping_info.md
Lunghezza: 972 caratteri
Anteprima: # Informazioni sulla Spedizione
...

Nota: TextLoader prende un singolo percorso di file come input, ma restituisce List[Document] per un'interfaccia coerente con altri loader. (Ad esempio, PDFLoader restituisce più Document — uno per pagina.)

9.2.2) Suddivisione dei documenti in chunk: Chunking

I due documenti sopra sono intenzionalmente brevi per questo tutorial. Nelle applicazioni reali, lavorerai spesso con documenti che sono lunghi centinaia o migliaia di pagine. Se incorpori un intero documento come un singolo vettore, migliaia di concetti vengono compressi in uno — rendendo impossibile recuperare accuratamente ciò di cui hai effettivamente bisogno.

Il chunking è il processo di suddivisione dei documenti in pezzi piccoli e significativi. L'obiettivo è semplice: quando un utente fa una domanda, solo i paragrafi specifici direttamente rilevanti per la risposta dovrebbero essere recuperati — non l'intero documento.

La dimensione del chunk influisce direttamente sia sul recupero che sulla qualità della risposta:

  • Troppo grande: Più argomenti vengono mescolati in un chunk, rendendo gli embedding meno accurati e il recupero più difficile. Anche quando viene trovato il chunk giusto, il contenuto irrilevante viene passato all'LLM, degradando la qualità della risposta.
  • Troppo piccolo: L'LLM potrebbe non ricevere abbastanza informazioni per rispondere correttamente. Ad esempio, se viene recuperata solo la frase "La spedizione standard è $5.99", l'LLM non può sapere che questo si applica solo agli ordini inferiori a $50.
  • Giusto: Ogni chunk copre un argomento con abbastanza contesto, consentendo recupero e risposte accurate.

9.2.3) Controllo della dimensione e sovrapposizione dei chunk

Per suddividere i documenti in chunk, hai bisogno di un text splitter. Un text splitter è uno strumento LangChain che suddivide documenti lunghi in pezzi più piccoli. Scegliere il giusto splitter è importante.

  • RecursiveCharacterTextSplitter: Prova più separatori in ordine gerarchico per preservare il più possibile il contesto. Lo splitter più utilizzato per scopi generali.
  • CharacterTextSplitter: Divide su un singolo separatore (predefinito: \n\n). Adatto per documenti con struttura semplice.
  • MarkdownHeaderTextSplitter: Divide sulle intestazioni Markdown (#, ##). Efficace quando vuoi preservare la struttura dell'indice del documento.

Perché RecursiveCharacterTextSplitter è efficace?

Questo splitter funziona provando separatori dall'unità più grande alla più piccola per trovare il miglior punto di divisione. L'ordine predefinito è il seguente (può essere modificato tramite il parametro separators):

paragrafo (\n\n) → interruzione di riga (\n) → parola ( )

Prova sempre a dividere prima all'unità significativa più grande. Se un paragrafo supera chunk_size, ricorre alle interruzioni di riga, poi alle parole. Poiché trova sempre il punto di divisione più naturale piuttosto che tagliare arbitrariamente nel mezzo di una parola, i chunk risultanti hanno maggiori probabilità di contenere informazioni semanticamente complete.

Parametri chiave

  • chunk_size: Il numero massimo di caratteri per chunk. Ad esempio, chunk_size=400 significa che nessun chunk supererà i 400 caratteri.
  • chunk_overlap: Il numero di caratteri sovrapposti tra chunk adiacenti. Ad esempio, chunk_overlap=80 significa che gli ultimi 80 caratteri di un chunk vengono ripetuti all'inizio del successivo.
  • separators: La lista di separatori usati per dividere il testo, provati in ordine di priorità. Se la divisione sul separatore corrente supererebbe chunk_size, viene provato il separatore successivo per evitare di superare chunk_size.

Cos'è la sovrapposizione e perché è necessaria?

La sovrapposizione significa che i chunk adiacenti condividono parte del contenuto — la fine di un chunk è inclusa all'inizio del successivo.

Il motivo è garantire che ogni chunk possa stare in piedi da solo con contesto sufficiente. Quando leggi un pezzo di un documento senza alcuna conoscenza di ciò che è venuto prima, può essere difficile capire perché viene menzionato un certo contenuto. La sovrapposizione mantiene la fine di un chunk che fluisce nel successivo, in modo che qualunque chunk venga recuperato, il contenuto si legga naturalmente.

📄 Documento originale
Para 1 | Para 2 | Para 3 | Para 4

✂️ Dividi

📋 Chunk 1

Para 1

📋 Chunk 2

Fine di Para 1
+ Para 2

📋 Chunk 3

Fine di Para 2
+ Para 3

📋 Chunk 4

Fine di Para 3
+ Para 4

Ora dividiamo effettivamente un documento:

python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# Carica il documento
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
 
# Configura lo splitter
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " "],
)
 
chunks = text_splitter.split_documents(docs)
 
print(f"Diviso in {len(chunks)} chunk\n")
for i, chunk in enumerate(chunks):
    print(f"--- Chunk {i} (fonte: {chunk.metadata['source']}) ---")
    print(f"Lunghezza: {len(chunk.page_content)} caratteri")
    print(chunk.page_content[:120])
    print()

Output:

Diviso in 4 chunk
 
--- Chunk 0 (fonte: data/docs/refund_policy.md) ---
Lunghezza: 374 caratteri
# Politica di Rimborso
...
 
--- Chunk 1 (fonte: data/docs/refund_policy.md) ---
Lunghezza: 267 caratteri
## Prodotti Digitali
...
 
--- Chunk 2 (fonte: data/docs/refund_policy.md) ---
Lunghezza: 204 caratteri
## Articoli Difettosi
...
 
--- Chunk 3 (fonte: data/docs/refund_policy.md) ---
Lunghezza: 315 caratteri
## Servizi in Abbonamento
...

Nota: Nell'esempio sopra, non si è verificata sovrapposizione. Questo perché ogni paragrafo è stato diviso in modo pulito in base al primo separatore (\n## ) rimanendo entro chunk_size. La sovrapposizione si verifica solo quando un paragrafo specifico è più lungo di chunk_size e deve essere diviso in due o più pezzi.

9.3) Archiviazione e recupero vettoriale con ChromaDB

9.3.1) Cos'è un vector store?

Un vector store (chiamato anche database vettoriale) è un database ottimizzato per memorizzare e cercare dati usando vettori di embedding. A differenza di un database tradizionale dove interroghi per valori di campo esatti (SELECT * FROM products WHERE category = 'electronics'), un vector store trova gli elementi con il significato più simile alla tua query.

In RAG, il vector store contiene i chunk dei documenti insieme ai loro embedding. Quando un utente fa una domanda, la domanda viene convertita in un vettore, e il vector store recupera i chunk con i vettori più simili.

9.3.2) Scelta di un vector store e configurazione di ChromaDB

I vector store popolari includono ChromaDB, Pinecone, Weaviate e pgvector (estensione PostgreSQL). Differiscono per modello di hosting (locale vs. cloud), scala e complessità operativa. Per questo libro, useremo ChromaDB — è open-source, funziona interamente sulla tua macchina locale senza configurazione server, ed è utile non solo per lo sviluppo ma anche per carichi di lavoro di produzione piccoli-medi.

ChromaDB può essere usato in diversi modi:

  • Modalità locale (pip): Installalo come libreria Python e usalo immediatamente. Puoi memorizzare e caricare dati in una directory locale senza alcuna infrastruttura server separata.
  • Server standalone (Docker): Esegui ChromaDB come processo server separato. Utile quando più applicazioni devono condividere lo stesso vector store.
  • Servizio cloud gestito (Chroma Cloud): Usa ChromaDB come servizio cloud. Chroma Cloud gestisce hosting, scaling e manutenzione, permettendoti di fornire un servizio stabile senza il peso della gestione dell'infrastruttura.

Installiamo ChromaDB usando pip:

bash
pip install chromadb langchain-chroma

chromadb è la libreria core del vector store, e langchain-chroma è un pacchetto di integrazione che ti permette di usare ChromaDB direttamente all'interno della libreria LangChain.

9.3.3) Scelta del modello di embedding

La prima cosa da decidere è quale modello di embedding usare. I vettori di embedding possono essere confrontati solo quando sono generati dallo stesso modello. Pertanto, devi usare lo stesso modello di embedding sia per memorizzare i documenti che per interrogarli.

OpenAI fornisce i seguenti modelli di embedding:

ModelloDimensioniNote
text-embedding-3-small1536Buon equilibrio tra qualità e costo
text-embedding-3-large3072Qualità superiore, costo superiore

Per questo libro, useremo il modello text-embedding-3-small di OpenAI. Offre alta efficienza a basso costo, rendendolo una scelta pratica per ricerca generale, RAG e progetti attenti ai costi.

python
from langchain_openai import OpenAIEmbeddings
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Verifica che funzioni
test_vector = embedding_model.embed_query("test")
print(f"Dimensioni embedding: {len(test_vector)}")

Output:

Dimensioni embedding: 1536

Nota sui costi: Le chiamate API di embedding sono molto più economiche delle chiamate LLM, ma comportano comunque costi. Quando si memorizzano documenti nel database (indicizzazione), è richiesta una chiamata API per chunk, e quando un utente fa una domanda (recupero), è richiesta una chiamata API per la domanda. Per i prezzi attuali, consulta la pagina dei prezzi OpenAI.

9.3.4) Memorizzazione dei chunk in ChromaDB

Ora mettiamo tutto insieme. Caricheremo i documenti, li divideremo in chunk, incorporeremo i chunk e li memorizzeremo insieme ai loro vettori di embedding in ChromaDB.

python
# ingest.py - Pipeline di ingestione completa
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Passo 1: Carica i documenti
# DirectoryLoader: scansiona una directory e carica i file corrispondenti.
# Il caricamento effettivo è delegato al loader specificato in loader_cls.
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Caricati {len(documents)} documenti")
 
# Passo 2: Dividi in chunk
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Creati {len(chunks)} chunk")
 
# Passo 3: Crea il modello di embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Passo 4: Crea il vector store e ingerisci i chunk
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
 
print(f"Memorizzati {len(chunks)} chunk in ChromaDB in data/chroma_db/")

Output:

Caricati 2 documenti
Creati 8 chunk
Memorizzati 8 chunk in ChromaDB in data/chroma_db/

Il metodo Chroma.from_documents() esegue due compiti in una singola chiamata:

  1. Passa i chunk forniti tramite il parametro documents attraverso il modello di embedding per ottenere i vettori di embedding.
  2. Memorizza ogni chunk insieme al suo vettore di embedding in ChromaDB.

9.3.5) Caricamento di un vector store persistente

Nella sezione precedente, abbiamo memorizzato i documenti nel vector store. Questa operazione di memorizzazione deve essere eseguita solo una volta inizialmente (o quando i documenti cambiano). Dopo di che, puoi semplicemente caricare il vector store persistente e usarlo direttamente.

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Carica un vector store persistente — non è necessario ri-incorporare
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
print(f"Caricato vector store con {len(vector_store.get()['ids'])} chunk")

Output:

Caricato vector store con 8 chunk

Ora puoi iniziare a cercare immediatamente semplicemente caricando il vector store persistente, senza dover ri-incorporare i tuoi documenti.

9.3.6) Ricerca per similarità

Con il vector store caricato, ora puoi cercare chunk semanticamente simili a una query. Il parametro top-K specifica quanti risultati restituire:

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
# Cerca chunk correlati a una domanda
query = "Posso restituire un prodotto digitale?"
results = vector_store.similarity_search(query, k=2)
 
print(f"Query: {query}")
print(f"Trovati {len(results)} risultati\n")
 
for i, doc in enumerate(results):
    print(f"--- Risultato {i + 1} (fonte: {doc.metadata['source']}) ---")
    print(doc.page_content[:200])
    print()

Output:

Query: Posso restituire un prodotto digitale?
Trovati 2 risultati
 
--- Risultato 1 (fonte: data/docs/refund_policy.md) ---
## Prodotti Digitali
 
...
 
--- Risultato 2 (fonte: data/docs/refund_policy.md) ---
# Politica di Rimborso
 
**Data di Entrata in Vigore**: 1 Gennaio 2026
 
## Resi Standard
 
...

Per questa query, il chunk sui prodotti digitali è stato recuperato con la similarità più alta, seguito dal chunk sulla politica di rimborso.

Puoi anche recuperare i risultati con i loro punteggi di similarità usando similarity_search_with_score:

python
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
 
for doc, score in results_with_scores:
    # ChromaDB restituisce la distanza (più bassa = più simile)
    print(f"Punteggio: {score:.4f} | Fonte: {doc.metadata['source']}")
    print(f"  {doc.page_content[:200]}...")
    print()

Output:

Punteggio: 0.5942 | Fonte: data/docs/refund_policy.md
  ## Prodotti Digitali
 
...
 
Punteggio: 0.9577 | Fonte: data/docs/refund_policy.md
  # Politica di Rimborso
 
...

Nota che ChromaDB usa punteggi di distanza (più basso è più simile), non punteggi di similarità (più alto è più simile). Il chunk sui prodotti digitali ha la distanza più bassa di 0.5942, rendendolo il risultato più rilevante.

9.4) Costruzione della catena RAG completa

Ora costruiremo un sistema RAG completo: recuperare prima i documenti rilevanti, poi passarli insieme alla domanda all'LLM per generare risposte basate sulle informazioni fornite.

9.4.1) Progettazione del template del prompt

La parte più importante del template del prompt è istruire l'LLM a rispondere basandosi solo sul contesto fornito. Senza questa istruzione, l'LLM potrebbe ignorare i risultati della ricerca e fabbricare risposte basate sui suoi dati di addestramento.

python
from langchain_core.prompts import ChatPromptTemplate
 
rag_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "Sei un rappresentante del servizio clienti. "
     "Rispondi alla domanda dell'utente usando SOLO il contesto fornito. "
     "Se il contesto non contiene informazioni sufficienti per rispondere, "
     "di' \"Non ho abbastanza informazioni per rispondere a quella domanda.\"\n\n"
     "Contesto:\n{context}"),
    ("human", "{question}"),
])

Il messaggio di sistema forza l'LLM a rispondere usando solo il contesto fornito. Crucialmente, l'istruzione di dire "Non ho abbastanza informazioni" quando il contesto è insufficiente impedisce all'LLM di fabbricare risposte plausibili ma non supportate.

9.4.2) Costruzione della catena RAG

Ora abbiamo tutti i componenti pronti. Dobbiamo solo collegare il retriever, il template del prompt e l'LLM.

Il sistema RAG completato funzionerà come segue:

  1. Ricevere la domanda dell'utente
  2. Recuperare i chunk rilevanti dal vector store
  3. Passare i chunk e la domanda al template del prompt per generare il prompt
  4. Generare una risposta con l'LLM

context

question

Domanda utente

Ricerca nel Vector Store

Chunk recuperati
(combinati in una singola stringa)

Generazione prompt

LLM

Risposta

Colleghiamo la catena RAG usando l'operatore LCEL | del Capitolo 6.

python
# rag_chain.py - Pipeline RAG completa
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
 
 
def format_docs(docs):
    """Unisce i documenti recuperati in una singola stringa di contesto."""
    return "\n\n---\n\n".join(doc.page_content for doc in docs)
 
 
def build_rag_chain():
    """Costruisce e restituisce la catena RAG completa."""
    # Carica il vector store
    embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
    vector_store = Chroma(
        persist_directory="data/chroma_db",
        collection_name="company_docs",
        embedding_function=embedding_model,
    )
 
    # Crea un retriever (k=3 significa restituire i top 3 chunk)
    retriever = vector_store.as_retriever(search_kwargs={"k": 3})
 
    # Definisci il prompt
    rag_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "Sei un rappresentante del servizio clienti. "
         "Rispondi alla domanda dell'utente usando SOLO il contesto fornito. "
         "Se il contesto non contiene informazioni sufficienti per rispondere, "
         "di' \"Non ho abbastanza informazioni per rispondere a quella domanda.\"\n\n"
         "Contesto:\n{context}"),
        ("human", "{question}"),
    ])
 
    # Inizializza l'LLM
    llm = ChatOpenAI(model="gpt-5-mini")
 
    # Componi la catena usando LCEL
    rag_chain = (
        {"context": retriever | format_docs, "question": lambda x: x}
        | rag_prompt
        | llm
        | StrOutputParser()
    )
 
    return rag_chain
 
 
if __name__ == "__main__":
    chain = build_rag_chain()
    answer = chain.invoke("Posso restituire un prodotto digitale?")
    print(answer)

Output:

I prodotti digitali non sono rimborsabili una volta che il link di download o accesso è stato attivato.
Se riscontri problemi tecnici che impediscono l'accesso, contatta l'assistenza entro 7 giorni per una sostituzione o rimborso.

Analizziamo la composizione della catena passo dopo passo:

python
rag_chain = (
    {"context": retriever | format_docs, "question": lambda x: x}
    | rag_prompt
    | llm
    | StrOutputParser()
)

Quando chiami chain.invoke("Posso restituire un prodotto digitale?"), ecco cosa succede:

  1. Passo dizionario:
    • retriever | format_docs: Cerca nel vector store con la domanda e combina i chunk in una singola stringa
    • lambda x: x: Passa la domanda invariata
    • Risultato: {"context": "chunk recuperati (combinati in una singola stringa)", "question": "Posso restituire un prodotto digitale?"}
  2. rag_prompt: Riempie i segnaposto {context} e {question} nel template del prompt con i valori del dizionario
  3. llm: Invia il prompt completato all'LLM
  4. StrOutputParser(): Estrae solo il testo dalla risposta dell'LLM

Per maggiori dettagli su come funziona LCEL, vedi il Capitolo 6.

9.4.3) Test con domande a cui si può e non si può rispondere

Un sistema RAG deve gestire sia le domande a cui può rispondere (l'informazione esiste nei documenti) sia le domande a cui non può rispondere (l'informazione non è nei documenti). Testiamo entrambi gli scenari:

python
# test_rag.py - Testa la catena RAG con varie domande
from rag_chain import build_rag_chain
 
chain = build_rag_chain()
 
test_questions = [
    # A cui si può rispondere — l'informazione è nei documenti
    "Qual è la politica di rimborso per i prodotti fisici?",
    "Quanto costa la spedizione express?",
    "Posso restituire un articolo difettoso dopo 6 mesi?",
    # A cui non si può rispondere — l'informazione NON è nei documenti
    "Qual è la politica ferie dei dipendenti?",
    "Quali linguaggi di programmazione vengono usati?",
]
 
for question in test_questions:
    print(f"D: {question}")
    answer = chain.invoke(question)
    print(f"R: {answer}\n")
    print("-" * 60)

Output:

D: Qual è la politica di rimborso per i prodotti fisici?
R: Tutti i prodotti fisici possono essere restituiti entro 30 giorni dall'acquisto per un rimborso completo. ...
 
------------------------------------------------------------
D: Quanto costa la spedizione express?
R: La spedizione express (2–3 giorni lavorativi) costa $12.99.
 
------------------------------------------------------------
D: Posso restituire un articolo difettoso dopo 6 mesi?
R: Sì. Gli articoli difettosi possono essere restituiti in qualsiasi momento per un rimborso completo o sostituzione. ...
 
------------------------------------------------------------
D: Qual è la politica ferie dei dipendenti?
R: Non ho abbastanza informazioni per rispondere a quella domanda.
 
------------------------------------------------------------
D: Quali linguaggi di programmazione vengono usati?
R: Non ho abbastanza informazioni per rispondere a quella domanda.
 
------------------------------------------------------------

I risultati dimostrano esattamente il comportamento che vogliamo:

  • Domande a cui si può rispondere: Forniscono risposte accurate basate sui documenti recuperati. L'LLM non aggiunge informazioni non contenute nei documenti.
  • Domande a cui non si può rispondere: Rispondono con "Non ho abbastanza informazioni per rispondere a quella domanda." L'LLM identifica correttamente che il contesto recuperato manca di informazioni rilevanti e rifiuta di fabbricare una risposta.

Questo è il potere di RAG. Il tuo LLM risponde a domande sui tuoi dati e ammette onestamente quando non sa. Ogni risposta è supportata da documenti, rendendo il sistema molto più affidabile di un LLM standard.