knowledge graph on-demand

knowledge graph on-demand

C’è un’idea che per anni ha dominato il mondo del GraphRAG, ed è insieme la più potente e la più pericolosa: costruire prima l’intero knowledge graph, e solo dopo iniziare a rispondere alle domande.

Sulla carta è sensato: estrai tutte le entità, tutte le relazioni e tutti i fatti dai tuoi documenti, li salvi in un database a grafo, e da quel momento in poi ogni domanda trova una rete già pronta da attraversare. In pratica, è una strategia che su corpus grandi non regge: costa tantissimo, produce rumore, satura memoria e CPU, e ti costringe a pagare un costo fisso enorme prima ancora di aver ricevuto una singola domanda.

Nel progetto RAGraph Ontological Engine (ROE) abbiamo preso la strada opposta. Invece di costruire preventivamente un grafo colossale, il sistema recupera i chunk rilevanti per la domanda, materializza un micro-grafo solo su quei chunk, verifica che i diversi intenti della domanda siano coperti e conserva il risultato per non doverlo rifare.

In questo articolo spiego esattamente come funziona questo meccanismo, perché è un cambio di prospettiva più profondo di quanto sembri, e dove ancora fa fatica. Non serve essere un ingegnere per seguirlo: il concetto di fondo è semplice anche se il codice non lo è.

Il problema: il grafo “prima di tutto” non scala

Prima di arrivare alla soluzione, serve capire perché l’approccio classico fallisce. Nel GraphRAG, il knowledge graph non viene calcolato da un reasoner simbolico: viene estratto da un LLM, chunk per chunk, con uno schema JSON e regole conservative per non inventare.

Questo è anche il punto più doloroso. Secondo l’analisi dei costi pubblicata dal team di Microsoft, la fase di estrazione del grafo e della gerarchia di community assorbe circa il 75% del costo totale di indicizzazione. Non è un dettaglio: è il motivo per cui, su corpus di centinaia di documenti, l’indicizzazione diventa proibitiva prima ancora di aver fatto una sola domanda.

A questo si aggiungono altri tre problemi noti:

  1. Il grafo estratto è rumoroso. Un grafo generato da un LLM contiene entità inventate, relazioni sovrapposte, fatti che si contraddicono nello stesso documento.
  2. Memoria e CPU. Se per privacy o costi estrai il grafo con un modello locale, l’estrazione su un corpus grande satura la macchina e non puoi parallelizzare senza farla collassare.
  3. Il costo è pagato in anticipo. Estrai il grafo di tutto il corpus, anche dei documenti che nessuno interrogherà mai. Su domini in cui si consulta il 5% dei manuali, il 95% del lavoro è sprecato.

La domanda che ci siamo posti è quindi banale da formulare e radicale nelle conseguenze:

E se invece di estrarre il grafo di tutto il corpus, estraessimo solo il grafo che ci serve, solo quando ci serve, solo sui chunk che la domanda tocca davvero?

È esattamente quello che fa ROE.

L’idea in una frase

Il meccanismo si può riassumere in quattro passi, che corrispondono all’ossatura dell’articolo:

1. RECUPERA    -> la domanda fa partire il retrieval e individua i chunk-seme utili
2. MATERIALIZZA -> sui chunk-seme selezionati viene estratto un micro-grafo on-demand
3. VERIFICA    -> il sistema controlla che ogni intento della domanda sia coperto dai fatti
4. CONSERVA    -> il micro-grafo viene fuso con quello esistente e salvato in MongoDB

La parola chiave è on-demand: non si costruisce nulla in anticipo. Si costruisce al momento del bisogno, e solo nella quantità strettamente necessaria. È la differenza tra comprare un’intera biblioteca e fotocopiare la pagina che ti serve.

Primo passo: l’ingestion può saltare il grafo

Il primo tassello è una scelta di configurazione. ROE prevede una modalità di ingestion chiamata differita (KG_INGESTION_MODE=deferred): il documento viene parsato, diviso in chunk e vettorializzato, ma il grafo viene saltato.

KG_INGESTION_MODE="deferred"

Il documento viene marcato come processato, ma con zero facts, zero nodi e zero archi. In pratica l’ingestion fa solo il lavoro che deve fare subito — mettere il testo in condizione di essere cercato — e rimanda tutto il resto a quando servirà davvero.

Nella modalità full il comportamento è quello classico: ontologia e grafo vengono estratti subito. Ma la modalità differita è la leva che rende possibile il resto dell’architettura. Senza di essa, il “grafo on-demand” non avrebbe nulla su cui innestarsi.

Secondo passo: recuperare i chunk-seme

Quando arriva una domanda, il motore fa quello che farebbe una RAG tradizionale: la analizza, la divide in sotto-query se è multi-intento, e usa Qdrant per recuperare i chunk semanticamente più vicini. A questi si aggiungono il retrieval ibrido keyword+vettoriale e il Semantic Traversal Retrieval, che partono dai chunk-seme per seguire facts e archi già esistenti.

Il punto importante è che il risultato di questa fase è una lista di chunk candidati. Non tutti, però, meritano di entrare nel micro-grafo. Qui entra in gioco il terzo passo, ed è il cuore dell’intera architettura.

Terzo passo: materializzare il micro-grafo (e solo quello che serve)

La logica vive nel file server/on_demand_kg.ts, ed è sorprendentemente piccola per il lavoro che fa.

Il punto di partenza è una funzione che seleziona pochi chunk tra quelli recuperati:

function selectChunksForMicroKg(query, chunks) {
  return selectChunksForRelationIntent(
    query,
    uniqueChunks(chunks),
    ON_DEMAND_KG_MAX_CHUNKS,           // default 6
    ON_DEMAND_KG_MAX_CHUNKS_PER_DOC,   // default 4
  );
}

Non si estrae il grafo di tutto il corpus. Non si estrae nemmeno il grafo di tutti i chunk recuperati. Si estrae il grafo di un massimo di 6 chunk, con un tetto di 4 per documento, scelti in base a quanto sono rilevanti per la domanda.

È un dettaglio che cambia tutto: il costo di estrazione, che nell’approccio classico è un multiplo della dimensione del corpus, qui diventa un multiplo di una manciata di chunk per domanda. Su un corpus di 3 GB, la differenza è tra un sistema che non parte e uno che risponde in pochi secondi.

Estrazione guidata dall’intento

Il micro-grafo non viene estratto “in generale”: viene estratto in funzione della domanda. Prima di chiamare l’LLM, ROE deriva dall’interrogazione un intento relazionale, cioè la famiglia di relazioni che la domanda sta chiedendo di ricostruire.

La funzione deriveQueryRelationIntent riconosce nove famiglie:

Famiglia Esempio di segnale nella domanda Relazioni preferite
obligation “deve”, “vietato”, “obbligo” MUST, REQUIRES, HAS_DUTY
responsibility “di chi è la responsabilità”, “datore” RESPONSIBLE_FOR, PERFORMS
condition_exception “solo se”, “salvo”, “eccezione” APPLIES_WHEN, EXCEPTS
cause_consequence “comporta”, “determina”, “conseguenza” CAUSES, RESULTS_IN
procedure_sequence “prima”, “dopo”, “passaggi” PRECEDES, FOLLOWED_BY
classification_comparison “si definisce”, “differenza” IS_A, DIFFERS_FROM
authority_evidence “articolo”, “ai sensi”, “norma” GOVERNED_BY, DEFINED_BY
temporal_threshold “entro”, “scadenza”, “soglia” HAS_DEADLINE, HAS_THRESHOLD
risk_control “rischio”, “DPI”, “prevenzione” CREATES_RISK, MITIGATED_BY

Questo intento viene passato direttamente al prompt di estrazione (extractKnowledgeGraph), sotto forma di un obiettivo relazionale:

OBIETTIVO RELAZIONALE DELLA DOMANDA:
Domanda: "Quali obblighi ha il datore in caso di modifica della macchina?"
Catena richiesta: Ricostruisci la catena obligation -> responsibility -> condition_exception ...
Relazioni preferite quando esplicitamente provate dal testo: MUST, REQUIRES, RESPONSIBLE_FOR ...

Concentrati sulle relazioni necessarie a rispondere alla domanda: soggetto, azione,
condizione, eccezione, obbligo, responsabilità e conseguenza.

La conseguenza è importante: il micro-grafo non è una fotografia neutra del testo, ma una vista orientata alla domanda. Se la domanda chiede di una catena di obblighi, il grafo estratto privilegia le relazioni di obbligo e responsabilità, invece di disperdersi in tutte le relazioni possibili.

Lo schema resta stabile

Un dettaglio tecnico che vale la pena notare: i micro-grafi guidati dalla domanda usano uno schema universale stabile (MICRO_KG_ONTOLOGY), non una ontologia nuova per ogni documento. Concetti come Entity, Actor, Rule, Requirement, Process, Risk e relazioni come IS_A, REQUIRES, APPLIES_TO, CAUSES, RESPONSIBLE_FOR, MITIGATES.

Il motivo è pratico: fare il bootstrap di un’ontologia dedicata per ogni documento costerebbe una chiamata LLM in più, senza migliorare la piccola catena di relazioni che serve alla risposta corrente. Meglio un vocabolario fisso e leggero.

Quarto passo: verificare la copertura degli intenti

Qui si tocca la parte più sottile dell’architettura. Il sistema non si limita a generare il micro-grafo: controlla se i fatti già in cache coprono davvero ciò che la domanda chiede.

Il controllo avviene in due punti, con due domande diverse.

Prima di estrarre: “questo chunk è già coperto?”

Prima di chiamare l’LLM, la funzione hasIntentCoverageForChunk verifica se i fatti già salvati per un determinato chunk rispondono all’intento della domanda:

function hasIntentCoverageForChunk(query, facts, chunk) {
  const intent = deriveQueryRelationIntent(query);
  const factsForChunk = facts.filter(f => f.referenceSource === chunkReference(chunk));
  if (factsForChunk.length === 0) return false;
  return factsForChunk.some(fact =>
    scoreTextForRelationIntent(`${fact.subject} ${fact.predicate} ${fact.object}`, intent) >= 7
  );
}

In buildMicroKgForDocument, i chunk vengono filtrati proprio con questa funzione: si estraggono solo i chunk mancanti. Se tutti i chunk selezionati sono già coperti dai fatti in cache, l’estrazione viene saltata del tutto:

🧩 On-demand KG skipped: cached facts already cover selected chunks.

Questo è il meccanismo che rende il sistema incrementale: la prima domanda su un argomento paga il costo di estrazione, la seconda e la terza trovano già i fatti pronti e non rifanno nulla.

Prima di rispondere: “il contesto ontologico è abbastanza forte?”

Il secondo controllo, assessOntologyRelationStrength, decide se il grafo già disponibile è sufficiente a rispondere subito, oppure se serve materializzare nuovo grafo prima di generare la risposta.

La funzione conta gli elementi rilevanti (fatti e percorsi che superano una soglia di affinità con l’intento) e calcola la copertura dei termini chiave della domanda:

const relevantItems = items.filter(i => scoreTextForRelationIntent(i, intent) >= 7).length;
const focusCoverage = coveredTerms / intent.focusTerms.length;   // da 0 a 1
const minimumRelevantItems = intent.relationFamilies.length >= 2 ? 3 : 2;
strong = relevantItems >= minimumRelevantItems && focusCoverage >= 0.18;

Da qui nasce una decisione a tre vie, visibile nei log del server:

  • Micro-KG forte → si risponde subito e l’arricchimento viene rinviato a dopo la risposta;
  • Micro-KG debole ma retrieval forte (almeno 12 chunk, punteggio alto) → si risponde subito e si materializza in background;
  • Micro-KG debole e retrieval debole → si materializza prima di rispondere (modalità “eager”), si aggiorna il contesto ontologico e solo dopo si genera la risposta.
Micro-KG relazionale pertinente (14 elementi, copertura 32%): arricchimento rinviato dopo la risposta
Micro-KG semanticamente debole (1 elemento, copertura 4%), ma retrieval forte: risposta immediata e materializzazione in background
Micro-KG presente ma semanticamente debole (0 elementi, copertura 0%): materializzazione eager richiesta

Il punto è che la copertura non è un concetto binario “c’è/non c’è”, ma una misura continua che guida la strategia: rispondere subito, rispondere e arricchire dopo, oppure aspettare di avere il grafo giusto.

Quinto passo: conservare il risultato

L’ultimo tassello è quello che rende l’intero meccanismo un investimento invece di un costo ripetuto. Il micro-grafo estratto non viene buttato via dopo la risposta: viene fuso con il grafo esistente e salvato.

La fusione è fatta per id, per evitare duplicati:

const nodes  = mergeNewById(graph.nodes,  new Set(existingGraph.nodes.map(n => n.id)));
const edges  = mergeNewById(graph.edges,  new Set(existingGraph.edges.map(e => e.id)));
const newFacts = mergeNewById(graph.facts, new Set(existingFacts.map(f => f.id)));

if (nodes.length || edges.length) await saveGraph(nodes, edges);
if (newFacts.length) await saveFacts(newFacts);
await updateDocumentGraphCounters(docId);

Il risultato finisce in MongoDB, accanto ai fatti e ai percorsi già estratti. E i contatori del documento (numero di facts, nodi e archi) vengono aggiornati di conseguenza.

La conseguenza è elegante: il grafo cresce solo dove serve. Non diventa mai un grafo enorme costruito “a tappeto”, ma una collezione di micro-grafi, ciascuno nato da una domanda reale e conservato per il futuro. Con il passare delle domande, la conoscenza strutturata copre gradualmente le zone del corpus che gli utenti consultano davvero — e lascia intatto il resto.

Due modalità: eager e background

Il meccanismo on-demand ha due velocità, governate dalla configurazione.

La modalità background (scheduleOnDemandKnowledgeGraph) mette il lavoro in coda e lo esegue quando l’utente è inattivo, dopo un ritardo configurabile (ON_DEMAND_KG_IDLE_DELAY_MS, default 45 secondi). Non blocca mai la risposta: la domanda viene soddisfatta con ciò che c’è già, e il micro-grafo si materializza in silenzio per la prossima volta.

La modalità eager (ensureOnDemandKnowledgeGraph) è quella descritta sopra: materializza prima di rispondere, ma solo quando il contesto è davvero scarso. Ha un timeout (ON_DEMAND_KG_EAGER_TIMEOUT_MS, default 6,5 secondi in configurazione) e un tetto di chunk ancora più basso (ON_DEMAND_KG_EAGER_MAX_CHUNKS, default 2), proprio per non far aspettare l’utente.

ON_DEMAND_KG_ENABLED=true
ON_DEMAND_KG_EAGER=true
ON_DEMAND_KG_EAGER_TIMEOUT_MS=6500
ON_DEMAND_KG_EAGER_MAX_CHUNKS=2
ON_DEMAND_KG_MAX_CHUNKS=6
ON_DEMAND_KG_MAX_CHUNKS_PER_DOC=4
ON_DEMAND_KG_IDLE_DELAY_MS=45000
ON_DEMAND_KG_CONCURRENCY=2

Il bilanciamento è il solito, ed è il filo rosso di tutto il progetto: non far mai pagare all’utente il costo dell’arricchimento, a meno che non sia strettamente necessario per la qualità della risposta.

Perché questo è un cambio di prospettiva

Vale la pena fermarsi un attimo sul significato profondo di questa scelta.

Il GraphRAG classico è un sistema pre-calcolato: paga tutto in anticipo, e risponde veloce solo se ha già costruito il grafo. ROE è un sistema lazy, in senso buono: paga il costo dell’estrazione solo quando una domanda lo giustifica, e lo paga in proporzione a quanto serve davvero.

Le conseguenze pratiche sono tre:

  1. Costo proporzionale all’uso, non alla dimensione. Il costo di indicizzazione smette di essere un multiplo del corpus e diventa un multiplo delle domande realmente poste.
  2. Niente grafo spazzatura. Non si estrae conoscenza da documenti che nessuno leggerà mai. Il grafo cresce dove c’è domanda, quindi tende a restare pertinente.
  3. Risposta immediata, arricchimento invisibile. Nella maggior parte dei casi l’utente non percepisce nemmeno l’arricchimento: riceve la risposta e, in background, il sistema si prepara meglio per la prossima.

È lo stesso principio che negli ultimi anni è emerso nella ricerca sul GraphRAG scalabile: l’attenzione si sposta dall’estrazione massiva al retrieval efficiente di sotto-grafi. E infatti è esattamente ciò che ROE fa, in modo concreto, con un file di poche centinaia di righe.

I limiti, detti chiaramente

Per onestà intellettuale, questo approccio non è una bacchetta magica, e i limiti sono visibili nei test.

Un’analisi di stress condotta sul motore ha evidenziato un divario interessante: il voto sulla ricostruzione delle relazioni è alto (8,89/10), ma il voto sulla copertura del grafo resta modesto (4,76/10). Tradotto: il micro-grafo viene usato, ma spesso non rappresenta l’intera catena richiesta dalla domanda; una parte rilevante della sintesi viene ancora fatta dal modello sui chunk recuperati, non da catene semantiche già rappresentate nel grafo.

Questo significa alcune cose concrete:

  • il micro-grafo copre bene le relazioni toccate dai chunk selezionati, ma se il retrieval non porta alla superficie i chunk giusti, il grafo estratto sarà parziale per definizione;
  • la qualità dipende ancora molto dai documenti sorgente e dal modello che estrae il grafo: un LLM rumoroso produce un micro-grafo rumoroso;
  • il sistema non è un reasoner formale OWL/SPARQL: conserva fatti e relazioni, ma non “ragiona” su di essi in senso logico.

La direzione, però, è quella giusta. Il lavoro che resta da fare è migliorare la selezione dei chunk e la fedeltà dell’estrazione, non tornare indietro al grafo costruito a tappeto.

Conclusioni

Il problema di partenza era semplice da enunciare: costruire preventivamente un grafo enorme non scala. La soluzione che ROE adotta è altrettanto semplice nella sua logica, anche se sofisticata nell’implementazione:

  1. Recupera i chunk-seme rilevanti per la domanda;
  2. Materializza un micro-grafo solo su una manciata di chunk selezionati, guidato dall’intento relazionale della domanda;
  3. Verifica che i diversi intenti siano coperti, prima di estrarre e prima di rispondere;
  4. Conserva il risultato, fuso per id con il grafo esistente, in modo che ogni domanda arricchisca la conoscenza senza rifare il lavoro.

Il risultato è un knowledge graph che cresce per domanda, non per documento: più economico da costruire, più pertinente, e invisibile per chi lo usa. Non è la soluzione definitiva al GraphRAG su larga scala, ma è un cambio di prospettiva che trasforma il costo del grafo da problema insormontabile a risorsa gestibile.

Ed è, a conti fatti, il modo più onesto di costruire un grafo: non tutto in anticipo, ma quello che serve, quando serve.


Riferimenti

Di R. Andrea Belvedere

Mi occupo di AI applicata, automazione, LLM, RAG e blockchain, con esperienza in contesti enterprise e industriali. Sono formatore tecnico, scrittore SEO e divulgatore scientifico: mi piace spiegare tecnologie complesse in modo semplice, pratico e orientato all’uso reale. Tutti gli Articoli sono elaborati dal sottoscritto sulla base di un progetto sviluppato direttamente dall’autore. Strumenti di intelligenza artificiale sono stati utilizzati come supporto all’analisi della documentazione e alla redazione. Il contenuto è stato verificato e revisionato dall’autore, che ne assume la responsabilità editoriale.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

error: Content is protected !!