Intermedio 22 minRAG

RAG locale con ChromaDB e Ollama : tutorial Python

Realizzare un RAG in locale con ChromaDB, Ollama e Python significa combinare tre componenti: un vector store che conserva i dati su disco (ChromaDB), un modello di embedding che trasforma i tuoi chunk in vettori (nomic-embed-text tramite Ollama) e un LLM di chat che risponde basandosi sui passaggi recuperati. Nessuna chiave API, nessuna fuga di dati. Questa guida ti porta da un PDF grezzo a un chatbot che cita le sue fonti in 22 minuti.

Di Mohamed Meguedmi·Agg. 2026-08-27·Testato su Windows, macOS e Linux

#Perché questo stack per un RAG locale

Molti tutorial sul RAG iniziano con LangChain o LlamaIndex. Questi framework sono potenti ma nascondono ciò che avviene dietro le quinte. Qui scriviamo la pipeline a mano con solo tre dipendenze. Capirai ogni passaggio e saprai cosa ottimizzare in seguito.

ChromaDB
Vector store open source, interamente in Python, modalità persistente integrata (SQLite + indice HNSW). Nessun server da avviare.
Ollama
Serve sia il modello di embedding (nomic-embed-text) che il LLM di chat (Qwen 3.5, Granite 4.2, Gemma 4). Endpoint HTTP unico su localhost:11434.
Python nativo
Alcune funzioni, nessun framework. Potrai collegare LangChain in seguito se necessario, ma non è necessario per iniziare.
i
Cosa ottieni
Uno script Python di circa 150 righe che acquisisce una cartella di PDF, li divide in blocchi, li indicizza in ChromaDB e risponde a domande in francese con citazioni. Tutto in locale, zero richieste in uscita.

#Prerequisiti

Il kit Copilota Locale

Questa guida ti porta al modello. Il kit ti porta al copilota che scrive codice nel tuo editor.

  • Spazio online a vita
  • PDF + file
  • Aggiornamenti a vita
Python 3.10+
ChromaDB richiede almeno 3.10. Verifica con python --version.
Ollama installato e avviato
Il daemon ascolta di default su http://localhost:11434. Se inizi da zero, segui prima la guida di installazione di Ollama.
8 GB di RAM
16 GB comodi. Il modello di chat 9B in Q4 occupa circa 6 GB, il modello di embedding circa 300 MB.
Una GPU non è obbligatoria
L'inferenza su CPU funziona, è solo più lenta. Per l'ingestione di un corpus di grandi dimensioni, una GPU da almeno 6 GB accelera molto il calcolo degli embedding.

#1. Installare ChromaDB e preparare Ollama

Si crea un ambiente virtuale pulito, si installano le tre librerie necessarie e si scaricano i modelli tramite Ollama.

Ambiente Python
python -m venv .venv
source .venv/bin/activate  # sous Windows : .venv\Scripts\activate
pip install chromadb ollama pypdf

Tre pacchetti: chromadb per lo store vettoriale, ollama per il client Python ufficiale, pypdf per leggere i PDF. È tutto.

Modelli Ollama
ollama pull nomic-embed-text
ollama pull qwen3.5:9b

nomic-embed-text è un modello di embedding da 137M parametri, multilingue, che genera vettori di dimensione 768. Leggero, veloce, buono in francese. Qwen 3.5 9B (6,6 GB, 256k di contesto, multilingue, Apache 2.0) serve per la chat finale: è la scelta predefinita per 8 GB nel 2026. Puoi sostituirlo con granite4.2:8b (più parsimonioso) o gemma4:12b senza cambiare nulla nel codice.

→
Verificare che Ollama risponda
Un semplice curl http://localhost:11434/api/tags dovrebbe elencare i tuoi modelli. Se non viene restituito nulla, il daemon non è avviato: esegui ollama serve in un altro terminale.

#2. Configurare il modello di embeddings

Un embedding è un vettore che rappresenta il senso di un testo. Due testi semanticamente vicini hanno vettori vicini. È il motore del RAG: si cercano i chunk il cui embedding si avvicina di più a quello della domanda.

embed.py — test rapido
import ollama

resp = ollama.embeddings(
    model="nomic-embed-text",
    prompt="Le contrat est résilié de plein droit en cas de manquement grave."
)

vec = resp["embedding"]
print(f"Dimension du vecteur : {len(vec)}")
print(f"5 premières valeurs : {vec[:5]}")

Dovresti vedere Dimensione del vettore: 768. Se l'esecuzione fallisce con model not found, significa che ollama pull nomic-embed-text non è stato eseguito.

i
Perché nomic-embed-text
Su benchmark FR (MTEB-fr), nomic-embed-text si classifica tra i primi 5 tra i modelli con meno di 200M di parametri. Per il francese puro, mxbai-embed-large fa spesso meglio ma pesa 670M. nomic è un eccellente compromesso qualità/velocità per iniziare.

#3. Acquisizione di PDF in francese

L'ingestione fa tre cose: legge le pagine di un PDF, taglia il testo in pezzi di dimensione ragionevole e salva ogni pezzo insieme al suo embedding in ChromaDB in modalità persistente.

ingest.py
import os
import chromadb
import ollama
from pypdf import PdfReader

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

def chunk_text(text, size=800, overlap=100):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunks.append(text[start:end])
        start += size - overlap
    return chunks

def ingest_pdf(path):
    reader = PdfReader(path)
    name = os.path.basename(path)
    for page_num, page in enumerate(reader.pages):
        text = page.extract_text() or ""
        for i, chunk in enumerate(chunk_text(text)):
            emb = ollama.embeddings(
                model="nomic-embed-text",
                prompt=chunk
            )["embedding"]
            collection.add(
                ids=[f"{name}-p{page_num}-c{i}"],
                embeddings=[emb],
                documents=[chunk],
                metadatas=[{"source": name, "page": page_num + 1}],
            )
    print(f"OK : {name} ingéré ({len(reader.pages)} pages)")

if __name__ == "__main__":
    for f in os.listdir("./pdfs"):
        if f.endswith(".pdf"):
            ingest_pdf(f"./pdfs/{f}")

Il chunker suddivide il testo in blocchi di 800 caratteri con una sovrapposizione di 100 caratteri. È un punto di partenza: blocchi né troppo piccoli (contesto insufficiente) né troppo grandi (il segnale si diluisce). Per contenuti giuridici molto densi, scendi a 500. Per manuali tecnici con un'impaginazione più ariosa, sali a 1200.

→
La modalità persistente di ChromaDB
PersistentClient(path="./chroma_db") crea una cartella che sopravvive ai riavvii. SQLite memorizza i metadati, un indice HNSW i vettori. Nessun server da avviare, nessun Docker. Per passare in modalità client/server in seguito, basta sostituire con HttpClient.

Avvia l'ingestione su una cartella ./pdfs/ contenente i tuoi documenti:

Avviare l'ingestione
mkdir -p pdfs
# placez vos PDF dans ./pdfs/
python ingest.py
!
PDF scansionati = nessun testo
pypdf estrae solo il testo nativo. Se i tuoi PDF sono scansioni di immagini, extract_text() restituirà un risultato vuoto. Occorre quindi ricorrere a un OCR (Tesseract, oppure un modello di visione come Qwen 3.5 9B, multimodale, tramite Ollama) prima dell'ingestione.

#4. Ricerca top-k in ChromaDB

Una volta indicizzati i chunk, la ricerca consiste nel generare l'embedding della domanda e poi chiedere a Chroma i k vettori più vicini in base alla distanza coseno. È istantaneo, anche su 100.000 chunk.

search.py
import chromadb
import ollama

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

def search(question, k=4):
    q_emb = ollama.embeddings(
        model="nomic-embed-text",
        prompt=question
    )["embedding"]
    results = collection.query(
        query_embeddings=[q_emb],
        n_results=k,
    )
    chunks = results["documents"][0]
    metas = results["metadatas"][0]
    return list(zip(chunks, metas))

if __name__ == "__main__":
    hits = search("Quelles sont les conditions de résiliation ?")
    for chunk, meta in hits:
        print(f"[{meta['source']} p.{meta['page']}]")
        print(chunk[:200], "...\n")

k=4 è un buon valore predefinito. Se è troppo basso, perdi parte del contesto pertinente; se è troppo alto, sommergi il LLM di rumore e superi la capacità della finestra di contesto. Per domande molto precise, k=2 basta. Per domande trasversali, aumenta il valore a 6.

#5. Ciclo di chat con citazioni

Ora mettiamo insieme i vari elementi: cerchiamo i chunk pertinenti, costruiamo un prompt con il contesto, lo inviamo a Qwen 3.5 tramite Ollama e chiediamo al modello di citare le sue fonti.

chat.py
import ollama
from search import search

SYSTEM = """Tu es un assistant qui répond uniquement à partir du CONTEXTE fourni.
Si la réponse n'est pas dans le contexte, dis-le clairement.
Cite tes sources entre crochets sous la forme [source.pdf p.X]."""

def ask(question):
    hits = search(question, k=4)
    context = "\n\n".join(
        f"[{m['source']} p.{m['page']}]\n{c}" for c, m in hits
    )
    prompt = f"CONTEXTE :\n{context}\n\nQUESTION : {question}"
    resp = ollama.chat(
        model="qwen3.5:9b",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": prompt},
        ],
        options={"temperature": 0.2, "num_ctx": 8192},
    )
    return resp["message"]["content"]

if __name__ == "__main__":
    while True:
        q = input("\nQuestion (vide pour quitter) > ").strip()
        if not q:
            break
        print("\n" + ask(q))

Tre dettagli che contano. Primo, temperature=0.2: vogliamo una risposta fattuale, non creativa. Secondo, num_ctx=8192: la finestra predefinita di Ollama (2048) è troppo corta quando si inseriscono 4 chunk di 800 caratteri. Terzo, il system prompt costringe il modello a dire 'non so' piuttosto che a produrre allucinazioni: è la principale misura di protezione contro le allucinazioni nel RAG.

→
Streaming per una migliore esperienza utente
Sostituisci ollama.chat con ollama.chat(..., stream=True) e itera sulla risposta per visualizzare i token man mano che arrivano. È fondamentale non appena si integra questo codice in un'interfaccia reale (FastAPI + WebSocket o Streamlit).

#6. Esempio concreto: chatbot giuridico su contratti

Immaginiamo uno studio che voglia interrogare 200 contratti di prestazione di servizi in PDF. Con lo stack descritto sopra, in meno di un'ora si ha un assistente che risponde a domande del tipo:

Domanda tipica
«Quali contratti prevedono una clausola di non concorrenza dopo la cessazione del rapporto, di durata superiore a 12 mesi?»
Cosa succede
L'embedding della domanda individua i chunk che contengono parole chiave semanticamente vicine (non concorrenza, dopo la cessazione del rapporto, durata). Qwen 3.5 legge questi 4 passaggi e risponde con i nomi dei file interessati.
Garanzia di confidenzialità
Nessun dato esce dal computer. Nessuna chiave API. Nessuna telemetria. Questo è ciò che distingue un RAG locale da un wrapper OpenAI.
!
Limiti da conoscere
Un RAG di base risponde bene alle domande mirate (« qual è la clausola X »), ma male a quelle che richiedono un'aggregazione (« quanti contratti hanno X »). Per queste ultime, serve un agente che interroghi la base di dati in più fasi oppure un GraphRAG. È un'altra storia.

#Risoluzione dei problemi

ChromaDB lento nell'ingestione
Il collo di bottiglia è quasi sempre la chiamata a Ollama per gli embedding. Verifica che nomic-embed-text sia in esecuzione su GPU con ollama ps. Su CPU, calcola circa 50 chunk al secondo; su GPU, circa 500.
« model not found »
Ollama non trova nomic-embed-text. Esegui di nuovo ollama pull nomic-embed-text e verifica con ollama list.
Risposte che inventano fonti
Un modello 9B produce ancora talvolta allucinazioni. Passa a mistral-small (24B, ~14 GB, molto buono in francese) o a qwen3.8:27b se hai abbastanza VRAM. Oppure aggiungi un reranker (cross-encoder) dopo ChromaDB per filtrare i falsi positivi.
Embedding di scarsa qualità in francese
nomic-embed-text è multilingue, ma non è ottimale per contenuti esclusivamente in francese. Per contenuti giuridici o medici, prova Solon-embeddings-large-0.1 o bge-m3 (da caricare tramite sentence-transformers, al di fuori di Ollama).
ChromaDB cresce senza limiti
Ogni reindicizzazione aggiunge duplicati. Prima di acquisire nuovamente un PDF, esegui collection.delete(where={"source": name}) per eliminare i vecchi chunk.

#Per approfondire

Hai un RAG funzionante. Ecco i prossimi interventi per svilupparlo ulteriormente:

Confrontare i modelli di embedding FR
La nostra guida «I migliori modelli di embedding FR» confronta BGE, E5, Solon e nomic su contenuti in lingua francese.
Migliorare il chunking
«Strategie di chunking» illustra in dettaglio il chunking semantico, per titoli markdown o per paragrafi: spesso è ciò che permette di ottenere il maggiore miglioramento della precisione.
Aggiungere un reranker
«Aggiungere un reranker alla propria pipeline»: +15% di pertinenza inserendo un cross-encoder dopo Chroma. Il passo successivo logico.
Ricerca ibrida
«Ricerca ibrida BM25 + vettoriale» combina ricerca lessicale e semantica ed è indispensabile quando ci sono molti termini specialistici o nomi propri.
Questa guida ti è stata utile?

Un feedback, un errore, una precisazione? Facci sapere, così la guida migliora per tutti.