Chatbot RAG sulla Tua Documentazione: Tutorial Completo

Chatbot RAG sulla Tua Documentazione: Tutorial Completo

Perché un Chatbot RAG sulla Documentazione nel 2026

Nel 2026 ogni azienda con più di dieci developer ha lo stesso problema: la documentazione cresce più velocemente di quanto riesca a essere letta. Wiki con 3000 pagine, Confluence con sezioni mai aggiornate, README sparsi in decine di repository. Il risultato—i team perdono ore a cercare informazioni che esistono già, ma sono irraggiungibili.

Un chatbot RAG (Retrieval-Augmented Generation) risolve esattamente questo problema. Invece di chiedere a un LLM ciò che ha memorizzato durante il training, il sistema recupera i documenti rilevanti al volo e li passa al modello come contesto. Il risultato è una risposta precisa, aggiornata e ancorata alla tua documentazione reale—non a un’allucinazione del modello.

Se hai già letto la guida su RAG con Claude API step-by-step, sai già i fondamenti teorici. Questo tutorial è il passo successivo: costruiamo un sistema completo, funzionante, deployabile in produzione. Useremo Node.js, Claude API come LLM e un vector store locale (con opzione cloud) per il retrieval. Ogni blocco di codice è reale e testato.

Architettura del Sistema RAG: I Tre Pilastri

Prima di scrivere una riga di codice, è fondamentale capire i tre pilastri dell’architettura RAG. Saltare questo passaggio porta invariabilmente a sistemi lenti, imprecisi o impossibili da debuggare.

  • Ingestion pipeline: carica i documenti, li divide in chunk, genera gli embedding e li salva nel vector store. Si esegue offline, una volta sola o in modo incrementale.
  • Retrieval engine: quando arriva una query, genera l’embedding della domanda, cerca i chunk più simili nel vector store (cosine similarity o dot product) e restituisce i top-K risultati.
  • Generation layer: assembla un prompt con i chunk recuperati e la domanda originale, chiama Claude API e restituisce la risposta all’utente.

La scelta del vector database è critica. Per sviluppo locale usiamo vectra (JSON su disco, zero dipendenze). Per produzione, le opzioni migliori sono Pinecone, Qdrant o pgvector—ho confrontato le tre soluzioni nella guida sui vector database pgvector, Pinecone e Qdrant. Per questo tutorial useremo Qdrant in Docker perché è gratuito, self-hostable e ha un’API REST pulita.

Step 1: Setup del Progetto e Dipendenze

Iniziamo con la struttura del progetto. Creiamo una directory rag-docs-chatbot con questa organizzazione:

mkdir rag-docs-chatbot && cd rag-docs-chatbot
npm init -y

# Dipendenze core
npm install @anthropic-ai/sdk @qdrant/js-client-rest dotenv
npm install glob marked unified remark-parse remark-stringify

# Dev
npm install -D typescript @types/node tsx

# Struttura directory
mkdir -p src/{ingestion,retrieval,api} docs

Configura il file .env con le credenziali necessarie:

# .env
ANTHROPIC_API_KEY=sk-ant-...
QDRANT_URL=http://localhost:6333
QDRANT_COLLECTION=docs_embeddings
EMBEDDING_MODEL=text-embedding-3-small
CHAT_MODEL=claude-sonnet-4-5
CHUNK_SIZE=800
CHUNK_OVERLAP=100
TOP_K=5

Avvia Qdrant in locale con Docker:

docker run -d   --name qdrant-rag   -p 6333:6333   -v $(pwd)/qdrant_storage:/qdrant/storage   qdrant/qdrant:latest

Step 2: Ingestion Pipeline — Dal Documento all’Embedding

Il cuore del sistema RAG è la pipeline di ingestion. Questo modulo legge i file Markdown dalla cartella docs/, li divide in chunk semantici e genera gli embedding tramite l’API di OpenAI (o un modello open source). Nota importante: Claude non ha ancora un’API di embedding nativa—useremo text-embedding-3-small di OpenAI che ha un costo irrisorio (~$0.02 per 1M token) ed è più che sufficiente per documentazione tecnica.

// src/ingestion/chunker.ts
import { readFileSync } from "fs";
import { glob } from "glob";
import path from "path";

export interface DocumentChunk {
  id: string;
  content: string;
  metadata: {
    source: string;
    title: string;
    chunkIndex: number;
  };
}

function splitIntoChunks(
  text: string,
  chunkSize: number,
  overlap: number
): string[] {
  const paragraphs = text.split(/

+/);
  const chunks: string[] = [];
  let current = "";

  for (const para of paragraphs) {
    if ((current + "

" + para).length > chunkSize && current) {
      chunks.push(current.trim());
      // Mantieni overlap: prendi le ultime N parole
      const words = current.split(" ");
      const overlapWords = words.slice(-Math.floor(overlap / 5));
      current = overlapWords.join(" ") + "

" + para;
    } else {
      current = current ? current + "

" + para : para;
    }
  }

  if (current.trim()) chunks.push(current.trim());
  return chunks;
}

export async function ingestDocuments(
  docsDir: string,
  chunkSize = 800,
  overlap = 100
): Promise<DocumentChunk[]> {
  const files = await glob(`${docsDir}/**/*.md`);
  const allChunks: DocumentChunk[] = [];

  for (const file of files) {
    const content = readFileSync(file, "utf-8");
    const title = content.split("
")[0].replace(/^#+ /, "").trim();
    const chunks = splitIntoChunks(content, chunkSize, overlap);

    chunks.forEach((chunk, idx) => {
      allChunks.push({
        id: `${path.basename(file, ".md")}_chunk_${idx}`,
        content: chunk,
        metadata: {
          source: file,
          title,
          chunkIndex: idx,
        },
      });
    });
  }

  console.log(`[Ingestion] ${files.length} file, ${allChunks.length} chunk totali`);
  return allChunks;
}

Ora il modulo di embedding e upload su Qdrant. Nota l’uso del batching: mandare 100 embedding in una sola chiamata API riduce la latenza del 90% rispetto alle chiamate singole.

// src/ingestion/embedder.ts
import OpenAI from "openai";
import { QdrantClient } from "@qdrant/js-client-rest";
import type { DocumentChunk } from "./chunker";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const qdrant = new QdrantClient({ url: process.env.QDRANT_URL });

const COLLECTION = process.env.QDRANT_COLLECTION!;
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL ?? "text-embedding-3-small";
const BATCH_SIZE = 100;

async function ensureCollection(vectorSize: number) {
  const collections = await qdrant.getCollections();
  const exists = collections.collections.some((c) => c.name === COLLECTION);

  if (!exists) {
    await qdrant.createCollection(COLLECTION, {
      vectors: { size: vectorSize, distance: "Cosine" },
    });
    console.log(`[Qdrant] Collezione "${COLLECTION}" creata`);
  }
}

async function generateEmbeddings(texts: string[]): Promise<number[][]> {
  const response = await openai.embeddings.create({
    model: EMBEDDING_MODEL,
    input: texts,
  });
  return response.data.map((d) => d.embedding);
}

export async function embedAndStore(chunks: DocumentChunk[]) {
  // Prima batch per determinare la dimensione del vettore
  const firstBatch = await generateEmbeddings([chunks[0].content]);
  const vectorSize = firstBatch[0].length;

  await ensureCollection(vectorSize);

  // Processa in batch
  for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
    const batch = chunks.slice(i, i + BATCH_SIZE);
    const texts = batch.map((c) => c.content);
    const embeddings = await generateEmbeddings(texts);

    const points = batch.map((chunk, idx) => ({
      id: Math.abs(hashString(chunk.id)) % 2147483647,
      vector: embeddings[idx],
      payload: {
        content: chunk.content,
        ...chunk.metadata,
        originalId: chunk.id,
      },
    }));

    await qdrant.upsert(COLLECTION, { points });
    console.log(`[Qdrant] Batch ${i / BATCH_SIZE + 1}: ${batch.length} chunk caricati`);
  }

  console.log(`[Ingestion] Completata: ${chunks.length} chunk nel vector store`);
}

function hashString(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0;
  }
  return hash;
}

Step 3: Retrieval Engine e Generazione con Claude

Il modulo di retrieval è il più critico per la qualità delle risposte. Un buon retrieval recupera chunk semanticamente rilevanti, non solo quelli che contengono le stesse parole chiave. La cosine similarity tra l’embedding della query e gli embedding del corpus fa esattamente questo.

Il sistema di retrieval si integra direttamente con Claude tramite il prompt caching di Anthropic—se hai molti documenti, puoi pre-caricare il contesto e risparmiare fino al 90% dei token come descritto nella guida al prompt caching Claude API.

// src/retrieval/retriever.ts
import OpenAI from "openai";
import { QdrantClient } from "@qdrant/js-client-rest";
import Anthropic from "@anthropic-ai/sdk";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const qdrant = new QdrantClient({ url: process.env.QDRANT_URL });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const COLLECTION = process.env.QDRANT_COLLECTION!;
const TOP_K = parseInt(process.env.TOP_K ?? "5");
const CHAT_MODEL = process.env.CHAT_MODEL ?? "claude-sonnet-4-5";

interface RetrievedChunk {
  content: string;
  source: string;
  title: string;
  score: number;
}

async function retrieveRelevantChunks(query: string): Promise<RetrievedChunk[]> {
  // Genera embedding per la query
  const embResponse = await openai.embeddings.create({
    model: process.env.EMBEDDING_MODEL ?? "text-embedding-3-small",
    input: query,
  });
  const queryVector = embResponse.data[0].embedding;

  // Cerca i chunk più simili
  const results = await qdrant.search(COLLECTION, {
    vector: queryVector,
    limit: TOP_K,
    with_payload: true,
    score_threshold: 0.65, // Filtra risultati poco rilevanti
  });

  return results.map((r) => ({
    content: r.payload?.content as string,
    source: r.payload?.source as string,
    title: r.payload?.title as string,
    score: r.score,
  }));
}

function buildSystemPrompt(chunks: RetrievedChunk[]): string {
  const context = chunks
    .map(
      (c, i) =>
        `[DOCUMENTO ${i + 1} - ${c.title} (score: ${c.score.toFixed(2)})]
${c.content}`
    )
    .join("

---

");

  return `Sei un assistente tecnico esperto. Rispondi SOLO basandoti sui documenti forniti nel contesto.
Se l'informazione non è presente nel contesto, dillo chiaramente senza inventare.
Cita sempre il documento di riferimento nella risposta.

## CONTESTO DOCUMENTAZIONE
${context}`;
}

export async function askDocumentationChatbot(
  question: string,
  conversationHistory: Array<{ role: "user" | "assistant"; content: string }> = []
): Promise<{ answer: string; sources: string[] }> {
  // Recupera chunk rilevanti
  const chunks = await retrieveRelevantChunks(question);

  if (chunks.length === 0) {
    return {
      answer: "Non ho trovato informazioni rilevanti nella documentazione per questa domanda.",
      sources: [],
    };
  }

  const systemPrompt = buildSystemPrompt(chunks);

  // Chiama Claude con contesto RAG
  const response = await anthropic.messages.create({
    model: CHAT_MODEL,
    max_tokens: 1024,
    system: systemPrompt,
    messages: [
      ...conversationHistory,
      { role: "user", content: question },
    ],
  });

  const answer = response.content[0].type === "text"
    ? response.content[0].text
    : "";

  const sources = [...new Set(chunks.map((c) => c.source))];

  return { answer, sources };
}

Step 4: API HTTP e Interfaccia Chat

Con ingestion e retrieval pronti, esponiamo tutto tramite un’API HTTP minimale. Questo permette di integrare il chatbot in qualsiasi frontend—React, Vue, o un semplice widget JavaScript. Se vuoi aggiungere streaming real-time delle risposte, il meccanismo è identico a quello descritto nella guida al Claude API streaming in JavaScript.

// src/api/server.ts
import http from "http";
import { URL } from "url";
import { askDocumentationChatbot } from "../retrieval/retriever";

interface ChatSession {
  history: Array<{ role: "user" | "assistant"; content: string }>;
  lastActivity: number;
}

const sessions = new Map<string, ChatSession>();

// Pulizia sessioni inattive (>30min)
setInterval(() => {
  const now = Date.now();
  for (const [id, session] of sessions) {
    if (now - session.lastActivity > 1800000) sessions.delete(id);
  }
}, 60000);

const server = http.createServer(async (req, res) => {
  res.setHeader("Content-Type", "application/json");
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.writeHead(204); res.end(); return;
  }

  const url = new URL(req.url!, `http://${req.headers.host}`);

  if (req.method === "POST" && url.pathname === "/chat") {
    let body = "";
    for await (const chunk of req) body += chunk;

    const { question, sessionId = "default" } = JSON.parse(body);

    if (!question?.trim()) {
      res.writeHead(400);
      res.end(JSON.stringify({ error: "question è obbligatoria" }));
      return;
    }

    // Recupera o crea sessione
    if (!sessions.has(sessionId)) {
      sessions.set(sessionId, { history: [], lastActivity: Date.now() });
    }
    const session = sessions.get(sessionId)!;

    try {
      const { answer, sources } = await askDocumentationChatbot(
        question,
        session.history
      );

      // Aggiorna history per conversazione multi-turno
      session.history.push(
        { role: "user", content: question },
        { role: "assistant", content: answer }
      );
      // Mantieni max 10 turni in memoria
      if (session.history.length > 20) session.history = session.history.slice(-20);
      session.lastActivity = Date.now();

      res.writeHead(200);
      res.end(JSON.stringify({ answer, sources, sessionId }));
    } catch (err) {
      console.error("[API] Errore:", err);
      res.writeHead(500);
      res.end(JSON.stringify({ error: "Errore interno del server" }));
    }
    return;
  }

  res.writeHead(404);
  res.end(JSON.stringify({ error: "Route non trovata" }));
});

server.listen(3001, () => {
  console.log("[API] RAG Chatbot in ascolto su http://localhost:3001");
});

Step 5: Script di Ingestion e Deploy

L’ultimo pezzo del puzzle è lo script principale che orchestra l’intero processo di ingestion. In produzione, questo script va eseguito ogni volta che la documentazione viene aggiornata—idealmente come GitHub Action al push su main, come abbiamo visto nella guida al code review automatico con Claude e GitHub Actions.

// src/ingest.ts - Script principale di ingestion
import "dotenv/config";
import { ingestDocuments } from "./ingestion/chunker";
import { embedAndStore } from "./ingestion/embedder";
import path from "path";

async function main() {
  console.log("=== RAG Ingestion Pipeline ===");
  const startTime = Date.now();

  const docsDir = path.resolve(process.env.DOCS_DIR ?? "./docs");
  const chunkSize = parseInt(process.env.CHUNK_SIZE ?? "800");
  const overlap = parseInt(process.env.CHUNK_OVERLAP ?? "100");

  console.log(`[Config] Docs: ${docsDir}, ChunkSize: ${chunkSize}, Overlap: ${overlap}`);

  // Step 1: Chunking
  const chunks = await ingestDocuments(docsDir, chunkSize, overlap);

  // Step 2: Embedding + Storage
  await embedAndStore(chunks);

  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  console.log(`
[Done] Ingestion completata in ${elapsed}s`);
  console.log(`[Summary] ${chunks.length} chunk pronti per il retrieval`);
}

main().catch((err) => {
  console.error("[Fatal]", err);
  process.exit(1);
});

Per testare rapidamente il sistema senza un’interfaccia frontend, puoi usare questo one-liner con curl:

# Avvia il server
npx tsx src/api/server.ts &

# Test query
curl -X POST http://localhost:3001/chat   -H "Content-Type: application/json"   -d '{
    "question": "Come si configura l autenticazione JWT?",
    "sessionId": "test-session-001"
  }'

# Output atteso:
# {
#   "answer": "Secondo il documento [Auth Guide] ...",
#   "sources": ["./docs/authentication.md"],
#   "sessionId": "test-session-001"
# }

Ottimizzazioni per la Produzione

Un RAG funzionante in locale non è automaticamente production-ready. Queste sono le ottimizzazioni prioritarie prima del deploy:

  • Re-ranking: dopo il retrieval vettoriale, applica un cross-encoder per riordinare i chunk. Riduce i falsi positivi del 30-40%.
  • Hybrid search: combina cosine similarity (semantico) con BM25 (keyword). Qdrant supporta entrambi nativamente con la sparse vector API.
  • Metadata filtering: aggiungi filtri per versione, categoria o autore. Riduce il noise nei dataset grandi.
  • Score threshold dinamico: se tutti i chunk hanno score < 0.65, rispondi “non so” invece di inventare. Critico per evitare allucinazioni.
  • Cache delle query frequenti: salva domanda + risposta in Redis con TTL 1 ora. Per documentazione stabile riduce i costi API dell’80%.

Se stai costruendo un sistema più complesso con agenti che interagiscono con più strumenti, il modulo di retrieval può diventare uno dei tool disponibili in un agente Claude—pattern che abbiamo esplorato nella guida al tool use con Claude API per agenti.

FAQ e Domande Frequenti

Posso usare questo sistema con documenti PDF o Word, non solo Markdown?

Sì, il sistema è facilmente estendibile. Per PDF usa pdf-parse (npm), per Word usa mammoth. Il modulo chunker accetta qualsiasi stringa in input: basta aggiungere un parser specifico per formato nella fase di lettura file. L’unica accortezza è che l’estrazione da PDF scansionati (immagini) richiede OCR—usa tesseract.js o un servizio cloud come AWS Textract.

Quanto costa in produzione questo sistema RAG?

Con documentazione da ~1000 pagine e ~100 query al giorno: embedding (one-time) ~$0.30, query embedding ~$0.001/giorno, Claude claude-sonnet-4-5 ~$0.005-0.015 per risposta. Totale stimato: $0.50-1.50 al giorno. Usando il prompt caching di Claude per il contesto fisso, puoi ridurre i costi LLM del 70-80%. Il vector store Qdrant self-hosted su un VPS da $6/mese è più che sufficiente fino a ~10M chunk.

Come gestisco gli aggiornamenti incrementali alla documentazione?

Qdrant supporta upsert nativamente: puoi reinserire solo i chunk modificati senza rielaborare tutto il corpus. La strategia più efficace è tenere un hash del contenuto di ogni file. Al run della pipeline, confronti l’hash attuale con quello salvato in un file JSON di stato: processi solo i file cambiati. Con questa ottimizzazione, un aggiornamento incrementale su 1000 file dove ne cambiano 10 richiede <5 secondi e costo quasi zero.

Qual è la dimensione ottimale del chunk?

Dipende dal tipo di documentazione. Per documentazione tecnica con molti esempi di codice, 600-900 token per chunk è il range ottimale: abbastanza contesto da essere utile, abbastanza specifico da avere alta cosine similarity. Chunk troppo grandi (>1500 token) abbassano la precisione del retrieval perché “diluiscono” il segnale semantico. Chunk troppo piccoli (<200 token) perdono il contesto e richiedono più overlap. Testa sempre con il tuo dataset specifico: valuta la precisione manualmente su 20-30 query rappresentative.

Conclusione

Hai costruito un chatbot RAG completo: dalla pipeline di ingestion con chunking semantico, all’embedding batch-ottimizzato, al retrieval con score threshold, fino all’API HTTP con gestione delle sessioni multi-turno. Il sistema è production-ready con le ottimizzazioni descritte nell’ultimo step.

La vera potenza di questo approccio è la composabilità: il modulo di retrieval può essere integrato in un agente più complesso, in una pipeline n8n, o esposto come MCP server. La documentazione tecnica smette di essere un cimitero di informazioni e diventa un’interfaccia conversazionale per il tuo team.

Il passo successivo naturale è aggiungere la valutazione automatica della qualità delle risposte (RAG evaluation) e un pannello di analytics per capire quali domande il sistema non sa rispondere—queste sono le lacune nella documentazione da colmare.

Suggerimenti e Risorse

🔧 Setup rapido: Usa docker-compose per orchestrare Qdrant + Redis (cache) + il tuo server Node.js in un unico docker-compose up. Aggiungi un healthcheck su Qdrant prima di avviare l’ingestion per evitare race condition al cold start.

💡 Pro tip: Prima di passare a un modello di embedding più potente (ada-002 o text-embedding-3-large), assicurati che il problema sia davvero l’embedding e non il chunking. Il 70% dei problemi di qualità RAG vengono da chunk mal formati, non da embedding scadenti. Testa il chunker manualmente su 10 documenti campione.

🎯 Strategia produzione: Implementa un feedback loop semplice: aggiungi un pollice su/giù alla risposta del chatbot. Salva le coppie domanda+risposta con rating negativo—sono il dataset di fine-tuning o il segnale per migliorare il prompt di sistema. Anche 50 esempi negativi ben analizzati rivelano pattern di failure sistematici risolvibili in poche ore.

Condividi

Articoli Recenti

Categorie popolari