Transformer du texte brut en graphes de connaissances avec l'IA locale

Concepts fondamentaux des graphes de connaissances

Face à des documents volumineux — rapports scientifiques,文档 administratifs, littérature technique — la lecture séquentielle devient inefficace. Les graphes de connaissances offrent une alternative structurée : ils représentent l'information sous forme de réseau où les antités et leurs relations sont explicitement modélisées.

Un graphe de connaissances repose sur deux éléments :

  • Nœuds (Nodes) : entités significatives extractionnées du texte (personnes, lieux, concepts, organisations)
  • Arêtes (Edges) : relations typées entre ces entités (créé_par, localisé_dans, cause, etc.)

Exemple : la phrase « Marie Curie a découvert le radium en 1898 » produit deux nœuds (Marie Curie, radium) reliés par une arête « a_découvert », plus un nœud temporel « 1898 » relié par « en ».

Architecture d'un pipeline de génération automatique

Le projet open-source AI-Knowledge-Graph implémente un pipeline en cinq étapes qui transforme du texte non structuré en graphe interactif. Voici une reconstruction de ce pipeline avec une approche modifiée.

Étape 1 : Segmentation du texte

Les modèles de langage ont une fenêtre de contexte limitée. La segmentation découpe le document source en unités traitables avec un chevauchement pour préserver la continuité sémantique.

import re
from typing import List
from dataclasses import dataclass

@dataclass
class SegmentConfig:
    max_tokens: int = 250
    overlap_ratio: float = 0.1

def segment_document(raw_text: str, cfg: SegmentConfig) -> List[str]:
    """Découpe un document en segments avec recouvrement."""
    tokens = raw_text.split()
    step = max(1, int(cfg.max_tokens * (1 - cfg.overlap_ratio)))
    segments = []
    
    cursor = 0
    while cursor < len(tokens):
        end = min(cursor + cfg.max_tokens, len(tokens))
        segment_text = ' '.join(tokens[cursor:end])
        segments.append(segment_text)
        if end >= len(tokens):
            break
        cursor += step
    
    return segments

# Utilisation
doc = "Votre texte long ici..."
cfg = SegmentConfig(max_tokens=250, overlap_ratio=0.1)
parts = segment_document(doc, cfg)
print(f"Document segmenté en {len(parts)} unités")

Étape 2 : Extraction des triplets RDF

Chaque segment est soumis à un LLM qui extrait des triplets (sujet, prédicat, objet). Le prompt est conçu pour forcer une sortie JSON valide et éviter les pronoms ambiguës.

import json
from typing import Dict, List, Optional

class TripletExtractor:
    EXTRACTION_TEMPLATE = """Tu es un extracteur de relations sémantiques.
Analyse le texte suivant et retourne UNIQUEMENT un tableau JSON.
Format: [{"s": "entité_source", "p": "relation", "o": "entité_cible"}]
Contraintes:
- Pas de pronoms, uniquement des entités nominales
- Relations en 1-3 mots max
- Faits explicites uniquement

Texte: {passage}
"""

    def __init__(self, llm_client):
        self.llm = llm_client

    def extract(self, passage: str) -> List[Dict[str, str]]:
        prompt = self.EXTRACTION_TEMPLATE.format(passage=passage)
        raw_response = self.llm.complete(prompt)
        
        try:
            parsed = json.loads(raw_response)
            return [t for t in parsed if self._validate_triplet(t)]
        except (json.JSONDecodeError, TypeError):
            return []

    @staticmethod
    def _validate_triplet(t: dict) -> bool:
        return all(k in t and t[k] for k in ('s', 'p', 'o'))

# Exemple d'extraction
texte = "Gutenberg a inventé l'imprimerie vers 1440 à Mayence."
# Résultat attendu:
# [{"s": "Gutenberg", "p": "a_inventé", "o": "imprimerie"},
#  {"s": "Gutenberg", "p": "actif_vers", "o": "1440"},
#  {"s": "imprimerie", "p": "inventée_à", "o": "Mayence"}]

Étape 3 : Désambiguïsation et fusion d'entités

Le même référent peut apparaître sous différentes formes : « IA », « Intelligence Artificielle », « intelligence artificielle ». Une étape de normalisation regroupe ces variantes.

from collections import defaultdict
from difflib import SequenceMatcher

class EntityResolver:
    SIMILARITY_THRESHOLD = 0.85

    def __init__(self):
        self._alias_map: Dict[str, str] = {}

    def build_canonical_forms(self, triplets: List[Dict]) -> None:
        entities = set()
        for t in triplets:
            entities.add(t['s'])
            entities.add(t['o'])
        
        unique = list(entities)
        groups: List[List[str]] = []
        consumed = set()

        for i, ent_a in enumerate(unique):
            if ent_a in consumed:
                continue
            cluster = [ent_a]
            for ent_b in unique[i+1:]:
                if ent_b in consumed:
                    continue
                ratio = SequenceMatcher(None, ent_a.lower(), ent_b.lower()).ratio()
                if ratio >= self.SIMILARITY_THRESHOLD:
                    cluster.append(ent_b)
                    consumed.add(ent_b)
            consumed.add(ent_a)
            canonical = max(cluster, key=len)
            for variant in cluster:
                self._alias_map[variant.lower()] = canonical

    def apply(self, triplets: List[Dict]) -> List[Dict]:
        resolved = []
        for t in triplets:
            resolved.append({
                's': self._alias_map.get(t['s'].lower(), t['s']),
                'p': t['p'],
                'o': self._alias_map.get(t['o'].lower(), t['o']),
            })
        return resolved

Étape 4 : Inférence de relations transitives

Certaines relations ne sont pas explicites mais déductibles. Si A « fabrique » B et B « réduit » C, alors A « contribue à réduire » C.

class TransitiveInferer:
    def __init__(self):
        self._rules = [
            {
                'chain': [('fabrique',), ('réduit',)],
                'derived': 'contribue_à_réduire'
            },
            {
                'chain': [('appartient_à',), ('situé_dans',)],
                'derived': 'localisé_dans'
            },
        ]

    def infer(self, triplets: List[Dict]) -> List[Dict]:
        derived_facts = []
        lookup = {(t['s'], t['p']): t['o'] for t in triplets}

        for rule in self._rules:
            pred_a, pred_b = rule['chain']
            for t1 in triplets:
                if t1['p'] in pred_a:
                    intermediate = t1['o']
                    target = lookup.get((intermediate, list(pred_b)[0]))
                    if target and target != t1['s']:
                        derived_facts.append({
                            's': t1['s'],
                            'p': rule['derived'],
                            'o': target,
                            'inferred': True
                        })
        return derived_facts

Étape 5 : Rendu visuel interactif

Les triplets — explicites et inférés — sont projetés dans un graphe navigable. Les nœuds sont disposés par force-directed layout et les arêtes colorées par type de relation.

Déploiement local

Prérequis

  • Python 3.11 ou supérieur
  • Ollama pour l'inférence locale de LLM
  • Modèle gemma2 ou llama3 téléchargé via Ollama
# Clonage du dépôt
git clone https://github.com/robert-mcdermott/ai-knowledge-graph
cd ai-knowledge-graph

# Installation des dépendances
pip install -r requirements.txt
# Alternative avec uv :
uv sync

# Récupération du modèle local
ollama pull gemma2

Configuration

Le fichier config.toml pilote tous les paramètres du pipeline :

[llm]
model_name = "gemma2"
endpoint = "http://localhost:11434/v1/chat/completions"
max_output_tokens = 8192
sampling_temp = 0.2

[segmentation]
window_size = 200
overlap_tokens = 20

[entity_resolution]
enabled = true
use_llm_clustering = true

[reasoning]
transitive_enabled = true
llm_inference_enabled = true

Le paramètre sampling_temp influence fortement la qualité des résultats :

  • 0.1–0.3 : extraction factuelle rigoureuse, idéal pour documents techniques
  • 0.4–0.6 : compromis entre précision et découverte de liens indirects
  • 0.7+ : génère des inférences créatives mais augmente le risque d'hallucinations

Exécution

# Génération depuis un fichier texte
python generate-graph.py --input document.txt --output graphe.html

# Options disponibles
# --debug            : logs détaillés du processus d'extraction
# --no-standardize   : désactive la fusion d'entités
# --no-inference     : extraction littérale uniquement
# --test             : test rapide avec données d'exemple

Paramètres de configuration recommandés

Scénario taille segment température inférence Résultat
Analyse précise (juridique, scientifique) 150–200 mots 0.1–0.2 OFF Graphe dense et vérifiable
Vue d'ensemble (rapport long) 400–500 mots 0.3–0.4 ON Connexions larges, hiérarchie visible
Découverte de liens cachés 200 mots 0.4–0.5 ON Réseau riche, liens inférés signalés

Limites et précautions

Hallucinations : le LLM peut générer des triplets plausibles mais absents du texte source. Une validation manuelle reste nécessaire, particulièrement pour les faits marqués inferred: true.

Volumétrie : pour les documents excédant 100 pages, segmenter d'abord en cahpitres et générer des sous-graphes, puis fusionner.

Langue : les performances optimales sont observées avec l'anglais. Les textes multilingues ou très idiomatiques peuvent produire des entités fragmentées.

Cas d'usage concrets

Analyse littéraire : cartographie des personnages et lieux d'un roman, révélant la structure narrative et les réseaux de relations.

Documentation d'entreprise : transformation d'un manuel de procédures en carte navigable reliant politiques, départements et processus.

Synthèse scientifique : visualisation des chaînes causales dans un article de recherche — facteurs, effets, méthodes et conclusions connectés explicitement.

Étiquettes: knowledge-graph nlp LLM Ollama gemma2

Publié le 4 juillet à 07h59