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.py e 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