Cosa ti serve

  • Python 3.11+ e familiarità di base con FastAPI
  • Un LLM: Ollama in locale (va bene qualsiasi modello da 7B in su) o una API key (con prezzi in classe DeepSeek/Gemini il costo è quasi nullo)
  • Nessuna GPU richiesta — a questa scala gli embedding girano bene su CPU

Passo 0 — Capisci la pipeline (5 min)

Il RAG (retrieval-augmented generation) è un motore di ricerca imbullonato a un LLM. Tutto il resto è dettaglio:

documents → chunks → embeddings → ChromaDB ↑ question → embedding → top-k similar chunks ┘→ prompt → LLM → cited answer

La qualità della risposta finale si decide quasi interamente nella parte sinistra del diagramma. In mesi di log di produzione, quasi ogni risposta sbagliata risaliva a un retrieval sbagliato — i chunk errati — non a un LLM debole. Investi il tuo sforzo in chunking e retrieval prima di cercare un modello più grande. (Per capire quando il RAG è lo strumento giusto, vedi RAG vs fine-tuning.)

Passo 1 — Installa e suddividi in chunk (15 min)

$ pip install chromadb sentence-transformers fastapi uvicorn httpx

Una regola di chunking che ha retto alla prova della produzione: dividi prima sulla struttura, poi sulla dimensione. I confini di paragrafi e titoli portano significato; i tagli a lunghezza fissa spezzano le frasi a metà e avvelenano il retrieval. Punta a ~300–500 parole per chunk con un overlap del 10–15%:

def chunk_text(text: str, target_words=400, overlap_words=50): paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks, current, count = [], [], 0 for p in paragraphs: words = len(p.split()) if count + words > target_words and current: chunks.append("\n\n".join(current)) # keep the tail as overlap so context bridges the cut tail = " ".join(" ".join(current).split()[-overlap_words:]) current, count = [tail, p], overlap_words + words else: current.append(p); count += words if current: chunks.append("\n\n".join(current)) return chunks

Passo 2 — Embedding e indice in ChromaDB (15 min)

Per contenuti in italiano (o qualsiasi lingua non inglese) usa un modello di embedding multilingue — il MiniLM inglese di default rovina silenziosamente il retrieval cross-lingua. paraphrase-multilingual-MiniLM-L12-v2 è piccolo, veloce su CPU, ed è quello che usa questo sito:

import chromadb from chromadb.utils import embedding_functions client = chromadb.PersistentClient(path="./chroma_store") embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction( model_name="paraphrase-multilingual-MiniLM-L12-v2") collection = client.get_or_create_collection( name="knowledge", embedding_function=embed_fn, metadata={"hnsw:space": "cosine"}) def index_document(doc_id: str, title: str, url: str, text: str): chunks = chunk_text(text) collection.upsert( ids=[f"{doc_id}:{i}" for i in range(len(chunks))], documents=chunks, metadatas=[{"title": title, "url": url, "doc_id": doc_id} for _ in chunks])

Due note di produzione. Primo, upsert con ID deterministici (doc_id:chunk_n) rende la re-indicizzazione idempotente — puoi rilanciare l'ingest senza duplicati. Secondo, metti subito l'URL sorgente nei metadati: le citazioni sono impossibili da aggiungere a posteriori se non hai salvato la provenienza.

Passo 3 — Retrieval e costruzione del prompt (15 min)

def retrieve(question: str, k: int = 5): res = collection.query(query_texts=[question], n_results=k) hits = [] for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]): if dist < 0.65: # similarity floor — tune on YOUR data hits.append({"text": doc, "title": meta["title"], "url": meta["url"]}) return hits PROMPT = """Rispondi alla domanda usando SOLO le fonti qui sotto. Se le fonti non contengono la risposta, dillo — non inventare. Cita le fonti come [n]. FONTI: {sources} DOMANDA: {question}"""

La soglia di similarità è il dettaglio che la maggior parte dei tutorial salta ed è quello che conta di più in produzione. Senza, una domanda fuori tema restituisce comunque i k chunk "meno dissimili", e l'LLM allucinerà volenterosamente una risposta da contesto irrilevante. Con la soglia, puoi rispondere onestamente "non ho questa informazione" — che è ciò che separa uno strumento interno affidabile da un rischio.

Passo 4 — L'endpoint FastAPI (15 min)

import httpx from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Ask(BaseModel): question: str @app.post("/ask") async def ask(body: Ask): hits = retrieve(body.question) if not hits: return {"answer": "Nessuna fonte pertinente trovata.", "sources": []} sources = "\n\n".join(f"[{i+1}] {h['title']}\n{h['text']}" for i, h in enumerate(hits)) async with httpx.AsyncClient(timeout=120) as cx: r = await cx.post("http://localhost:11434/api/chat", json={ "model": "qwen3.6:27b", # o qualsiasi modello Ollama / sostituisci con una chiamata API "messages": [{"role": "user", "content": PROMPT.format(sources=sources, question=body.question)}], "stream": False}) return {"answer": r.json()["message"]["content"], "sources": [{"title": h["title"], "url": h["url"]} for h in hits]}

Lancialo con uvicorn main:app e hai un chatbot privato funzionante: domanda in ingresso, risposta fondata più fonti cliccabili in uscita. Sostituire Ollama con un provider API è una modifica di cinque righe alla chiamata HTTP — al layer RAG non importa chi genera.

Passo 5 — Le lezioni di produzione (la parte che nessuno ti dice)

Tutto quello che segue viene dall'esecuzione di questo stack dal vivo sull'internet pubblico:

  • Indicizza in modo incrementale, non da zero. Ri-embeddare l'intero corpus a ogni aggiornamento è la prima cosa che smette di scalare. Embedda solo i documenti nuovi/modificati al momento dell'ingest (la nostra pipeline indicizza ogni nuovo articolo appena creato).
  • I bot troveranno il tuo endpoint e lo martelleranno. Ogni chiamata a /ask costa calcolo vero (retrieval + LLM). Nel giro di poche settimane i crawler ci stavano prosciugando la macchina con domande spazzatura. Difesa minima: rate limiting per IP, Disallow nel robots.txt, e mai esporre l'endpoint senza autenticazione se è interno.
  • Logga domanda + chunk recuperati + risposta. Questa tripla è la tua miniera d'oro per il debugging e il tuo set di valutazione. Quando una risposta è sbagliata, il log ti dice all'istante se ha fallito il retrieval o la generazione.
  • Lingua della risposta ≠ lingua dei documenti. Con un embedder multilingue, le domande in italiano recuperano chunk in inglese e viceversa — ottimo per la copertura, ma di' esplicitamente all'LLM in che lingua rispondere, o rispecchierà le fonti.
  • ChromaDB basta più a lungo di quanto pensi. Fino a centinaia di migliaia di chunk su una macchina, semplicemente funziona. Non passare a un vector database distribuito finché non hai misurato un limite reale.

Dove andare dopo

Due migliorie ripagano la complessità in più quando il RAG di base si impianta: il retrieval ibrido (aggiungi keyword/BM25 accanto ai vettori — nomi esatti e codici sono il punto debole degli embedding puri) e un reranker (un piccolo cross-encoder che riordina i tuoi top-20 in un top-5 più preciso). Aggiungili solo dopo che i log mostrano errori di retrieval; entrambi si innestano su questa architettura senza stravolgerla.