Introduzione e contesto
Il 23 ottobre 2026 Chroma ha annunciato l’accordo definitivo per l’ingresso in Replit e ha fissato a 60 giorni la chiusura di Chroma Cloud, come racconta la ricostruzione di explainx.ai. Chi aveva costruito il proprio sistema RAG AI sopra il servizio gestito ha due mesi per spostare i dati. Chi lavora in locale non deve cambiare nulla, e questa guida mostra come farlo bene.
In circa 90 minuti, con 16 GB di RAM, è possibile creare un assistente che risponde a domande sui propri documenti (Markdown, testo e PDF) senza inviare una riga a servizi esterni. Il progetto utilizza Python, Ollama 0.40.10 (versione stabile rilasciata il 23 ottobre 2026), un database vettoriale ChromaDB salvato su disco, una ricerca ibrida che combina vettori e BM25, citazioni delle fonti, un’API FastAPI e uno script di valutazione.
1. Cos’è un sistema RAG AI e perché conviene eseguirlo in locale
RAG sta per retrieval‑augmented generation, cioè generazione aumentata dal recupero. L’idea è semplice: un modello linguistico non risponde a memoria, ma legge prima i passaggi dei tuoi documenti pertinenti alla domanda e solo dopo scrive la risposta. Un sistema RAG AI separa così due compiti che un modello da solo svolge male: ricordare fatti precisi e ragionare sul testo che ha davanti.
Il vantaggio pratico si vede dal primo giorno. Aggiorni un file e la risposta cambia, senza riaddestrare nulla. Ogni risposta porta con sé le fonti, quindi chi legge può controllare. E se il modello non trova niente di pertinente, puoi obbligarlo ad ammetterlo invece di inventare. Per un’azienda che risponde a domande su procedure interne, contratti o ticket di assistenza, questo comportamento vale più di qualche punto in una classifica di benchmark.
Eseguire tutto in locale aggiunge tre cose: i documenti restano sulla tua macchina, il che semplifica la conversazione con il responsabile della protezione dei dati; il costo per domanda scende a zero, a parte l’elettricità; nessun fornitore può spegnere il servizio sotto i tuoi piedi, come ha ricordato proprio questa settimana la chiusura annunciata di Chroma Cloud.
Le quattro fasi di una pipeline RAG LLM
- Indicizzazione: spezzi i documenti in chunk, trasformi ogni chunk in un vettore (embedding) e salvi tutto nel database vettoriale.
- Recupero: trasformi la domanda in un vettore, cerchi i chunk più vicini e affianchi una ricerca per parole chiave.
- Aumento: incolli i chunk migliori nel prompt, ciascuno con l’etichetta della sua fonte.
- Generazione: il modello legge il prompt e risponde in italiano, citando le etichette.
La qualità finale dipende dall’anello più debole. Nella maggior parte dei progetti reali l’anello debole è il recupero, non il modello. Per questo il passo 12 costruisce uno script di valutazione e non un’interfaccia carina.
2. Cosa è cambiato a ottobre 2026: Ollama, Chroma e i modelli aperti
Ottobre 2026 segna tre evoluzioni chiave:
- Ollama 0.40.10 introduce il supporto nativo per i modelli di embedding e per la generazione in streaming, riducendo il tempo di latenza di circa il 30 % rispetto alla versione 0.39.
- ChromaDB è ora disponibile come libreria pure‑Python senza dipendenze di server esterno; la sua API locale consente di salvare gli indici su disco in formato SQLite, rendendo il backup triviale.
- Modelli aperti come Qwen3.8‑27B e Llama‑3‑8B sono ottimizzati per l’esecuzione su laptop con 32 GB di RAM, grazie a tecniche di quantizzazione a 4 bit.
Queste novità permettono di costruire pipeline più rapide, più leggere e completamente indipendenti dal cloud.
3. Prerequisiti e versioni da installare
Prima di iniziare, assicurati di avere:
- Python 3.11 o superiore.
- Ollama 0.40.10 (installabile da
curl -fsSL https://ollama.com/install.sh | sh). - ChromaDB 0.4.0 (installabile con
pip install chromadb). - FastAPI 0.111.0 e Uvicorn 0.30.0 per l’API.
- Un set di documenti di prova (almeno tre file in formato .md, .txt o .pdf).
Verifica le versioni con i comandi python --version, ollama --version e pip show chromadb.
4. Architettura del progetto
L’intera struttura si basa su una cartella radice chiamata ragproject con i seguenti sotto‑folder:
data/: contiene i documenti originali.src/: contiene tutti gli script Python (config, ingest, retrieve, generate, api, eval).models/: cache dei modelli di embedding e di generazione scaricati da Ollama.store/: directory dove Chroma salva il database vettoriale.
Il flusso è:
- Creazione dell’ambiente virtuale e installazione delle dipendenze.
- Download dei modelli di embedding (
nomic-embed-text) e di generazione (llama3.1). - Indicizzazione dei documenti con
ingest.py. - Recupero ibrido con
retrieve.py(vettoriale + BM25). - Generazione della risposta con
generate.pye citazione delle fonti. - Esposizione via API con
api.py. - Valutazione delle performance con
eval.py.
5. Preparare l’ambiente: passi 1‑3
Passo 1: crea la cartella e l’ambiente virtuale
Esegui i seguenti comandi:
mkdir ragproject && cd rag_project
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
Quindi crea le sottocartelle data src models store.
Passo 2: installa Ollama e controlla la versione
Scarica Ollama dal sito ufficiale, avvialo in background e verifica la versione:
ollama serve &
ollama --version # deve restituire 0.40.10
Passo 3: scarica il modello di embedding e quello di generazione
Usa i comandi di Ollama:
ollama pull nomic-embed-text
ollama pull llama3.1
Questi modelli verranno salvati in ~/.ollama/models e saranno riutilizzati da tutti gli script.
6. Indicizzare i documenti con un database vettoriale: passi 4‑7
Passo 4: scrivi config.py
Il file src/config.py contiene le costanti di progetto:
EMBEDDING_MODEL = "nomic-embed-text"
LLM_MODEL = "llama3.1"
CHROMAPERSISTDIR = "../store/chroma"
DATA_DIR = "../data"
CHUNK_SIZE = 500
CHUNK_OVERLAP = 50
TOP_K = 5
Passo 5: prepara tre documenti di prova
Metti nella cartella data i seguenti file:
intro.md: breve presentazione del progetto.guide.txt: istruzioni operative.specs.pdf: specifiche tecniche (esempio scaricabile).
Assicurati che i file contengano almeno 300 parole ciascuno per avere chunk significativi.
Passo 6: scrivi llm.py e store.py
llm.py gestisce le chiamate a Ollama:
import subprocess, json
def generate(prompt: str) -> str:
result = subprocess.run(
["ollama", "run", LLM_MODEL],
input=prompt.encode(),
capture_output=True,
check=True,
)
return result.stdout.decode()
store.py crea e restituisce l’istanza di Chroma:
from chromadb import PersistentClient
from config import CHROMAPERSISTDIR
def get_client():
return PersistentClient(path=CHROMAPERSISTDIR)
Passo 7: scrivi ingest.py ed eseguilo
Lo script legge tutti i file in data, li suddivide in chunk (usando tiktoken o nltk), calcola gli embedding con Ollama e li inserisce in Chroma.
import os, glob
from langchain.text_splitter import RecursiveCharacterTextSplitter
from llm import embed_text # funzione da aggiungere
from store import get_client
from config import *
def ingest():
client = get_client()
collection = client.getorcreate_collection(name="docs")
splitter = RecursiveCharacterTextSplitter(chunksize=CHUNKSIZE,
chunkoverlap=CHUNKOVERLAP)
for filepath in glob.glob(os.path.join(DATADIR, "*")):
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
embedding = embed_text(chunk)
metadata = {"source": os.path.basename(filepath), "chunkid": i}
collection.add(
ids=[f"{os.path.basename(filepath)}{i}"],
documents=[chunk],
embeddings=[embedding],
metadatas=[metadata],
)
if _name == "main_":
ingest()
Esegui con python src/ingest.py. Alla fine troverai i vettori salvati in store/chroma.
7. Recupero ibrido e generazione con citazioni: passi 8‑10
Passo 8: crea retrieve.py con la ricerca vettoriale
Il modulo accetta una query, genera l’embedding e restituisce i TOPK chunk più vicini.
from llm import embedtext
from store import get_client
from config import TOP_K
def retrieve(query: str):
client = get_client()
collection = client.get_collection("docs")
queryemb = embedtext(query)
results = collection.query(
queryembeddings=[queryemb],
nresults=TOPK,
include=["documents", "metadatas", "distances"],
)
return results
Passo 9: aggiungi BM25 e la fusione RRF
Per migliorare il recupero, combina il risultato vettoriale con una ricerca basata su parole chiave usando rankbm25. Poi applica la fusione Reciprocal Rank Fusion (RRF) per ottenere un ranking finale.
from rankbm25 import BM25Okapi
import numpy as np
def bm25_search(query: str, docs: list[str]):
tokenized_corpus = [doc.split() for doc in docs]
bm25 = BM25Okapi(tokenized_corpus)
scores = bm25.get_scores(query.split())
return scores
def rrffusion(vectscores, bm25_scores, k=60):
# RRF formula: 1 / (k + rank)
vectrank = np.argsort(vectscores)[::-1] + 1
bm25rank = np.argsort(bm25scores)[::-1] + 1
rrf = 1 / (k + vectrank) + 1 / (k + bm25rank)
return np.argsort(rrf)[::-1]
Integra questi metodi in retrieve.py per restituire i chunk ordinati secondo RRF.
Passo 10: scrivi generate.py e ask.py
generate.py costruisce il prompt includendo i chunk e le loro fonti, poi chiama llm.generate:
from llm import generate
from retrieve import retrieve, rrf_fusion
def build_prompt(query: str, chunks: list[dict]):
prompt = "Rispondi alla domanda usando solo le informazioni seguenti e cita le fonti.\n\n"
for i, chunk in enumerate(chunks):
src = chunk["metadatas"][0]["source"]
prompt += f"[{i+1}] ({src}): {chunk['documents'][0]}\n\n"
prompt += f"Domanda: {query}\nRisposta:"
return prompt
def answer(query: str):
vect_res = retrieve(query)
# BM25 su tutti i documenti
alldocs = [doc for doc in vectres["documents"]]
bm25scores = bm25search(query, all_docs)
finalorder = rrffusion(vectres["distances"], bm25scores)
selected = [vectres[i] for i in finalorder[:TOP_K]]
prompt = build_prompt(query, selected)
return generate(prompt)
ask.py è un semplice wrapper da riga di comando:
import sys
from generate import answer
if _name == "main_":
query = " ".join(sys.argv[1:])
print(answer(query))
Esegui python src/ask.py "Qual è la procedura di backup?" per vedere la risposta con le citazioni.
8. Esporre il RAG come API e misurarlo: passi 11‑12
Passo 11: crea api.py con FastAPI
Il file espone un endpoint /ask che riceve una query JSON e restituisce risposta e fonti.
from fastapi import FastAPI
from pydantic import BaseModel
from generate import answer
app = FastAPI()
class Query(BaseModel):
question: str
@app.post("/ask")
async def ask_endpoint(q: Query):
resp = answer(q.question)
return {"answer": resp}
Avvia con uvicorn src.api:app --reload.
Passo 12: valuta il recupero con eval.py
Lo script legge un file CSV con coppie domanda,risposta_attesa, genera le risposte e calcola metriche di precisione, recall e F1 sul set di chunk citati.
import csv
from generate import answer
from sklearn.metrics import precisionscore, recallscore, f1_score
def evaluate(csv_path):
scores = []
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
pred = answer(row["question"])
# estrai le fonti citate da pred (regex [\d+] )
predicted_sources = set(...)
truesources = set(row["expectedsources"].split(";"))
precision = precisionscore(list(truesources), list(predicted_sources), average="binary")
recall = recallscore(list(truesources), list(predicted_sources), average="binary")
f1 = f1_score
