Implementare RAG in una knowledge base aziendale: guida pratica
Tutorial pratico per implementare RAG su una knowledge base aziendale italiana nel 2026: chunking, embeddings, vector store, retrieval, prompt italiano. Codice TypeScript pronto.
RAG (Retrieval-Augmented Generation) è il pattern che permette a un LLM di rispondere usando documentazione aziendale specifica senza fare fine-tuning del modello. Nel 2026 è la strategia default per costruire assistenti AI che conoscono il vostro contesto. Vediamo come implementarlo concretamente con stack moderno, codice TypeScript pronto, e gli accorgimenti che fanno la differenza fra un RAG che funziona e uno che produce hallucination.
Cosa otterrete alla fine
Un servizio Node.js che:
- Indicizza documenti aziendali (PDF, Word, Markdown, pagine web) in un database vettoriale
- Su una domanda dell’utente, recupera i passaggi più rilevanti
- Costruisce un prompt con i passaggi recuperati e li passa a un LLM
- Restituisce una risposta in italiano nativo basata sui documenti, con citazione delle fonti
Stack target:
- Node.js + TypeScript
- PostgreSQL + pgvector come vector store (alternativa a Pinecone/Weaviate, più economico e self-hosted)
- OpenAI Embeddings API (
text-embedding-3-small) per gli embeddings - Claude 3.5 Sonnet o GPT-4o per la generazione finale
Prerequisiti
- Node.js 22+ e pnpm/npm
- PostgreSQL 15+ con estensione
pgvectorinstallata - Chiave OpenAI (per embeddings) e Anthropic o OpenAI (per generazione)
- Documenti aziendali da indicizzare: PDF, Markdown, file di testo
Step 1: setup ambiente
mkdir rag-knowledge-base && cd rag-knowledge-base
pnpm init
pnpm add openai @anthropic-ai/sdk pg dotenv zod pdf-parse markdown-it
pnpm add -D typescript tsx @types/pg @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext
Setup PostgreSQL con pgvector:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
source VARCHAR(500) NOT NULL,
title VARCHAR(500),
content TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX ON document_chunks (document_id);
vector(1536) perché text-embedding-3-small produce embeddings di dimensione 1536. L’indice ivfflat accelera la ricerca su grandi volumi (sopra 100k chunk). lists = 100 è un buon default sotto i 5M record.
Step 2: caricamento e chunking documenti
Il chunking è il singolo step che determina la qualità del RAG. Troppo piccolo = chunk senza contesto. Troppo grande = recupero di rumore.
Parametri empirici per knowledge base italiana:
- Chunk size: 800-1.200 caratteri (~200-300 token italiani)
- Overlap fra chunk: 150-200 caratteri (~30-50 token)
- Strategy: semantic chunking dove possibile (rispetta paragrafi e sezioni), fixed-size come fallback
// src/chunking.ts
export interface Chunk {
content: string
index: number
metadata?: Record<string, unknown>
}
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 180
/**
* Chunking semantico: tenta di rispettare paragrafi.
* Fallback a chunking fixed-size se i paragrafi sono troppo lunghi.
*/
export function chunkText(text: string): Chunk[] {
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
const chunks: Chunk[] = []
let currentChunk = ''
let chunkIndex = 0
for (const para of paragraphs) {
// Se il paragrafo da solo eccede CHUNK_SIZE, lo splittiamo fixed-size
if (para.length > CHUNK_SIZE) {
// Prima chiudiamo il chunk corrente se non vuoto
if (currentChunk.length > 0) {
chunks.push({ content: currentChunk.trim(), index: chunkIndex++ })
currentChunk = ''
}
// Poi spezziamo il paragrafo lungo
for (let i = 0; i < para.length; i += CHUNK_SIZE - CHUNK_OVERLAP) {
chunks.push({
content: para.slice(i, i + CHUNK_SIZE).trim(),
index: chunkIndex++,
})
}
continue
}
// Se aggiungendo il paragrafo superiamo CHUNK_SIZE, chiudiamo
if (currentChunk.length + para.length > CHUNK_SIZE) {
chunks.push({ content: currentChunk.trim(), index: chunkIndex++ })
// Overlap: portiamo le ultime CHUNK_OVERLAP caratteri nel chunk successivo
currentChunk = currentChunk.slice(-CHUNK_OVERLAP)
}
currentChunk += (currentChunk.length > 0 ? '\n\n' : '') + para
}
if (currentChunk.length > 0) {
chunks.push({ content: currentChunk.trim(), index: chunkIndex++ })
}
return chunks
}
Per PDF e Markdown:
// src/loaders.ts
import { readFile } from 'node:fs/promises'
import pdfParse from 'pdf-parse'
export async function loadPdf(filePath: string): Promise<string> {
const buffer = await readFile(filePath)
const parsed = await pdfParse(buffer)
return parsed.text
}
export async function loadMarkdown(filePath: string): Promise<string> {
return await readFile(filePath, 'utf-8')
}
export async function loadText(filePath: string): Promise<string> {
return await readFile(filePath, 'utf-8')
}
Step 3: generazione embeddings
// src/embeddings.ts
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const EMBEDDING_MODEL = 'text-embedding-3-small'
const BATCH_SIZE = 100
export async function embedTexts(texts: string[]): Promise<number[][]> {
const allEmbeddings: number[][] = []
// Batch per evitare timeout API
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE)
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: batch,
})
allEmbeddings.push(...response.data.map((d) => d.embedding))
}
return allEmbeddings
}
export async function embedText(text: string): Promise<number[]> {
const [embedding] = await embedTexts([text])
return embedding
}
text-embedding-3-small è il modello più costo-efficace nel 2026: dimensione 1536, costo ~0.02 USD per 1M token. Per knowledge base medie (10k-100k chunk) il costo iniziale di indicizzazione è 0.50-5 USD totali.
Step 4: indexing dei documenti
// src/indexing.ts
import { Client } from 'pg'
import { chunkText } from './chunking'
import { embedTexts } from './embeddings'
const dbClient = new Client({ connectionString: process.env.DATABASE_URL })
await dbClient.connect()
export async function indexDocument(
source: string,
title: string,
content: string,
metadata?: Record<string, unknown>,
): Promise<number> {
// 1. Salva documento
const docResult = await dbClient.query<{ id: number }>(
'INSERT INTO documents (source, title, content, metadata) VALUES ($1, $2, $3, $4) RETURNING id',
[source, title, content, metadata ?? {}],
)
const documentId = docResult.rows[0].id
// 2. Chunking
const chunks = chunkText(content)
// 3. Embeddings
const embeddings = await embedTexts(chunks.map((c) => c.content))
// 4. Bulk insert chunks
for (let i = 0; i < chunks.length; i++) {
await dbClient.query(
`INSERT INTO document_chunks (document_id, chunk_index, content, embedding, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[
documentId,
chunks[i].index,
chunks[i].content,
JSON.stringify(embeddings[i]),
chunks[i].metadata ?? {},
],
)
}
return documentId
}
Step 5: retrieval
// src/retrieval.ts
import { Client } from 'pg'
import { embedText } from './embeddings'
const dbClient = new Client({ connectionString: process.env.DATABASE_URL })
await dbClient.connect()
export interface RetrievedChunk {
content: string
source: string
title: string | null
similarity: number
document_id: number
}
const TOP_K = 5
const SIMILARITY_THRESHOLD = 0.65
export async function retrieve(
query: string,
topK: number = TOP_K,
threshold: number = SIMILARITY_THRESHOLD,
): Promise<RetrievedChunk[]> {
const queryEmbedding = await embedText(query)
const result = await dbClient.query<RetrievedChunk>(
`SELECT
c.content,
d.source,
d.title,
1 - (c.embedding <=> $1::vector) AS similarity,
c.document_id
FROM document_chunks c
JOIN documents d ON d.id = c.document_id
WHERE 1 - (c.embedding <=> $1::vector) > $2
ORDER BY c.embedding <=> $1::vector
LIMIT $3`,
[JSON.stringify(queryEmbedding), threshold, topK],
)
return result.rows
}
L’operatore <=> di pgvector calcola la cosine distance. 1 - cosine_distance = cosine_similarity. La soglia 0.65 elimina chunk troppo poco rilevanti che produrrebbero rumore nel prompt.
Step 6: generazione con prompt italiano nativo
// src/generation.ts
import Anthropic from '@anthropic-ai/sdk'
import { retrieve, type RetrievedChunk } from './retrieval'
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
const MODEL = 'claude-3-5-sonnet-20241022'
function buildPrompt(question: string, chunks: RetrievedChunk[]): string {
const sources = chunks
.map(
(c, i) => `[Fonte ${i + 1}: ${c.title ?? c.source}]\n${c.content.trim()}`,
)
.join('\n\n---\n\n')
return `Domanda: ${question}\n\nContesto disponibile:\n\n${sources}`
}
export interface RagResponse {
answer: string
sources: Array<{ source: string; title: string | null; document_id: number }>
}
export async function answer(question: string): Promise<RagResponse> {
const chunks = await retrieve(question)
if (chunks.length === 0) {
return {
answer:
"Non ho trovato informazioni nella documentazione disponibile per rispondere a questa domanda. Riformulala in modo più specifico oppure verifica se l'argomento è coperto dalla nostra knowledge base.",
sources: [],
}
}
const userPrompt = buildPrompt(question, chunks)
const response = await anthropic.messages.create({
model: MODEL,
max_tokens: 800,
temperature: 0.2,
system: `Sei un assistente che risponde a domande basandosi esclusivamente sul contesto fornito.
Regole vincolanti:
- Rispondi in italiano formale, conciso, professionale (max 250 parole salvo che la domanda richieda dettaglio).
- Usa solo le informazioni nel contesto. Se il contesto non basta per una risposta completa, dichiaralo esplicitamente.
- Quando citi un'informazione, indica la fonte fra parentesi quadre (es. [Fonte 2]).
- Non inventare. Non aggiungere informazioni che non siano nel contesto.
- Se la domanda è ambigua, chiedi chiarimento invece di indovinare.
- Non assumere conoscenze esterne al contesto (non dare per scontato che chi legge sa cose non scritte).`,
messages: [{ role: 'user', content: userPrompt }],
})
const answerText =
response.content[0].type === 'text' ? response.content[0].text : ''
// Dedupla le fonti (chunk diversi dello stesso documento)
const uniqueSources = Array.from(
new Map(
chunks.map((c) => [
c.document_id,
{ source: c.source, title: c.title, document_id: c.document_id },
]),
).values(),
)
return {
answer: answerText,
sources: uniqueSources,
}
}
Tre dettagli che valgono l’inchiostro:
temperature: 0.2per output deterministico business. RAG con temperatura alta = hallucination a richiesta.- System prompt vincolante in italiano nativo con regole esplicite. Mai dire “in italian style” o “professionally”: specificate le regole nella lingua di output.
- Citazione fonti richiesta esplicitamente nel prompt e dedupla sulla risposta finale: l’utente vede quali documenti sono stati usati.
Step 7: esempio di uso end-to-end
// src/example.ts
import { indexDocument } from './indexing'
import { loadPdf, loadMarkdown } from './loaders'
import { answer } from './generation'
async function main() {
// Indexing
const policyPdf = await loadPdf('./docs/policy-aziendale.pdf')
await indexDocument(
'policy-aziendale.pdf',
'Policy Aziendale 2026',
policyPdf,
)
const procMd = await loadMarkdown('./docs/procedure-onboarding.md')
await indexDocument('procedure-onboarding.md', 'Procedure Onboarding', procMd)
// Q&A
const result = await answer(
'Quanti giorni di ferie spettano a un dipendente con 5 anni di anzianità?',
)
console.log('RISPOSTA:\n', result.answer)
console.log('\nFONTI:')
result.sources.forEach((s) => console.log(` - ${s.title} (${s.source})`))
}
main().catch(console.error)
Trappole comuni
1. Chunk troppo piccoli o troppo grandi. Chunk di 200 caratteri = troppo narrow context, l’LLM ha frammenti senza senso. Chunk di 3.000 caratteri = troppo rumore nel prompt, risposta meno focalizzata. Il sweet spot italiano è 800-1.200 caratteri con overlap di 150-200.
2. Soglia di similarity troppo bassa. Sotto 0.6 di cosine similarity i chunk recuperati spesso sono rumore. Sopra 0.85 si rischia di non trovare nulla per query generiche. 0.65-0.75 è il range tipico utile.
3. Niente citation nelle risposte. Senza forzare l’LLM a citare le fonti, l’utente non può verificare. Verificabilità è il discrimine fra RAG affidabile e RAG che “sembra” affidabile.
4. Mescolare lingue. Embeddings di documenti in italiano + query in inglese funziona male anche con i modelli multilingua. Convertire la query nella stessa lingua dei documenti prima dell’embedding migliora significativamente la qualità.
5. Ignorare i metadata. Filtrare per tag/data/dipartimento prima della vector search migliora qualità e velocità. Esempio: “domande HR” → cerca solo nei chunk con metadata->>'department' = 'HR'.
6. Indici vettoriali sbagliati per la scala. ivfflat va bene fino a 5M chunk. Sopra serve hnsw (più memoria ma più veloce). Sotto 100k chunk anche una brute force sequenziale funziona accettabilmente.
7. Re-indexing non automatico. I documenti aziendali cambiano. Senza un pipeline che re-indicizza quando i documenti cambiano, dopo 6 mesi il RAG risponde con informazioni obsolete.
Variazioni dell’approccio
Hybrid search (vector + keyword). Combinare cosine similarity (semantica) con BM25 / full-text search (lessico esatto) per scenari dove keyword specifiche contano (codici prodotto, nomi propri, numeri di matricola). PostgreSQL supporta entrambi nativamente; reranking combinato migliora qualità del 10-20% in scenari business reali.
Re-ranking con LLM piccolo. Recuperare 15-20 chunk con vector search, poi passare a un LLM piccolo (gpt-4o-mini, Claude Haiku) per scegliere i 3-5 migliori prima della generazione finale. Migliora qualità di 10-15% al costo di una chiamata LLM aggiuntiva.
Self-hosted embeddings. Per dati molto sensibili, modelli di embedding open-source self-hosted (Nomic Embed Text v1.5, Mistral Embed, BAAI/bge-m3) eliminano il trasferimento di dati a OpenAI. Qualità leggermente inferiore (-10-15%), latency simile, costi di GPU 50-200 euro/mese ma compliance al massimo.
RAG conversazionale. Mantenere context della conversazione (precedenti domande + risposte) per gestire follow-up (“dimmi di più”, “approfondisci il punto X”). Richiede gestione di conversation state e re-formulazione delle query in fase di retrieval.
Limitazioni di questo approccio base
1. Non aggiorna i fatti in tempo reale. RAG cerca nei documenti indicizzati. Se i documenti sono vecchi di 6 mesi, le risposte rifletteranno la situazione di 6 mesi fa. Per dati che cambiano in tempo reale (es. inventario, prezzi), un agente AI con tool è più adatto di un RAG puro.
2. Pessimo su query “globali”. Domande tipo “quali sono i nostri 5 prodotti più venduti” o “riassumi tutta la documentazione HR” non funzionano con RAG standard: richiederebbero leggere tutti i chunk, non solo i più simili. Per queste serve graph RAG o approcci ibridi.
3. Hallucination residua. Anche con prompt rigoroso e citation, gli LLM possono “interpretare” troppo creativamente. Una guardrail esterna (es. fact-checking automatico su numeri specifici) è auspicabile in scenari critici (sanitario, fiscale, legale).
4. Costo a regime. Per knowledge base con 50.000+ query/mese, i costi LLM (~0.01-0.05 USD/query con Claude 3.5 Sonnet) si sommano: 500-2.500 USD/mese. Per scale grosse vale la pena valutare modelli più piccoli o self-hosted.
FAQ
Quanto costa implementare RAG su una knowledge base aziendale media?
Per una PMI italiana con 1.000-10.000 documenti aziendali:
- Setup iniziale: 15-40k euro (sviluppo + indicizzazione iniziale + UI di consultazione).
- Costi ricorrenti: 300-1.500 euro/mese (LLM, embedding refresh, hosting PostgreSQL).
ROI tipico se sostituisce ricerca manuale del personale: 6-15 mesi.
Si può usare pgvector invece di Pinecone/Weaviate/Qdrant?
Sì, e per la maggior parte dei casi è la scelta migliore. pgvector è open-source, parte di PostgreSQL che probabilmente avete già, scala fino a milioni di chunk senza problemi. Pinecone/Weaviate/Qdrant offrono performance superiore a scale enterprise (decine di milioni di vector) ma per scale PMI sono over-engineered.
Quale modello di embedding scegliere nel 2026?
- OpenAI text-embedding-3-small: ottimo default, prezzo basso (~0.02 USD/1M token), dimensione 1536.
- OpenAI text-embedding-3-large: 5x più caro, leggermente migliore, dimensione 3072. Conviene solo per scenari ad alta precisione.
- Cohere embed-multilingual-v3: bene per knowledge base in più lingue.
- Self-hosted (Nomic, Mistral, BGE-m3): per compliance / privacy. Qualità lievemente inferiore.
Per knowledge base solo italiane medie: OpenAI text-embedding-3-small è la scelta giusta.
Come gestire documenti che cambiano spesso?
Tre approcci:
- Re-indexing schedulato: notte, settimanale, mensile. Semplice, ma può lasciare gap di freschezza.
- Re-indexing event-driven: webhook dal sistema di gestione documentale (Confluence, Notion, SharePoint) che triggera re-indexing del documento modificato. Più complesso ma più aggiornato.
- Versioning: tenere più versioni dello stesso documento, filtrare per data al momento del retrieval.
La scelta dipende da quanto cambia la base documentale e quanto la freschezza è critica.
RAG si può usare per supportare codice / documentazione tecnica?
Sì ma con accorgimenti. Codice e documentazione tecnica hanno strutture specifiche (gerarchie, riferimenti incrociati, snippet con sintassi). Embeddings generalisti tendono a mescolare prose tecnica e codice in modo non ottimale. Soluzioni: embedding model specifici per code (es. CodeT5, BAAI/bge-code-v1), chunking che rispetta confini di funzioni/classi.
Come si misura la qualità di un sistema RAG?
Metriche standard:
- Precision@K: dei top K chunk recuperati, quanti sono effettivamente rilevanti?
- Recall@K: dei chunk rilevanti totali, quanti vengono recuperati nei top K?
- Faithfulness: la risposta è effettivamente supportata dalle fonti citate?
- Answer relevance: la risposta è pertinente alla domanda?
Strumenti di eval automatici (RAGAS, Trulens) automatizzano queste metriche. Una baseline ragionevole per RAG ben fatto: Precision@5 > 0.75, Faithfulness > 0.85.
Conclusione
Implementare RAG su una knowledge base aziendale italiana nel 2026 è fattibile con stack moderno, codice riutilizzabile, e budget contenuto. La differenza fra un RAG che funziona e uno che produce hallucination sta nei dettagli: chunking ben tarato, soglie di similarity sensate, prompt italiano nativo con regole vincolanti, citation obbligatoria delle fonti. Le aziende che ci investono ottengono un asset di produttività che restituisce risparmio composto nel tempo.
Se state valutando di implementare RAG sulla vostra knowledge base aziendale e volete supporto su scelte di scope e implementazione, parliamone. Possiamo costruire un POC su una porzione della vostra documentazione in 3-5 settimane.
Per approfondire: la pagina pilastro agenti AI, la pagina dedicata al RAG su knowledge base aziendale, e gli articoli correlati come integrare GPT in TeamSystem per un altro pattern tecnico complementare, e agenti AI vs chatbot per capire dove RAG si colloca nel landscape AI 2026.