Accueil / Articles

Neo4j et LangChain4j : importateurs GraphRAG, filtrage et mémoire de chat

Neo4j avec LangChain4j pour GraphRAG : découverte de schéma, création de graphes avec des LLM, DSL Cypher, filtrage dans l’index, récupération de relations parent-enfant et mémoire de conversation basée sur des graphes.

Éditeur : REACTAPP.TOP18 min de lecture4156 mots

Sources: Mastering Neo4j & LangChain4j: GraphRAG, Persistent AI Memory and more

Au-delà du stockage vectoriel de base

Un simple stockage vectoriel Neo4j fonctionnant à l’intérieur d’une application Java n’est que le point de départ. Les contributeurs du dépôt communautaire LangChain4j ont depuis ajouté des fonctionnalités allant bien au-delà d’une simple recherche de similarité : construction automatique de graphes, filtrage vectoriel optimisé, modèles GraphRAG structurés et mémoire de chat stockée sous forme de graphes. Les sections suivantes abordent chacun de ces aspects avec le code utilisé dans les tests de la communauté.

Tout ici concerne la version 1.0.0-beta19 ; les versions ultérieures pourraient modifier certains comportements ou interfaces.

Les dépendances sont identiques à celles d’une configuration de base, avec le module Neo4j retriever et ses composants associés dans le fichier Maven :

<dependencies>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-community-neo4j-retriever</artifactId>
    <version>${langchain.version}</version>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>${langchain.version}</version>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-community-neo4j</artifactId>
    <version>${langchain.version}</version>
  </dependency>
  <!-- other deps -->
</dependencies>

Découverte du schéma pour Text-to-Cypher

Lorsqu’un modèle rédige des requêtes de base de données directement à partir du langage naturel (Text-to-Cypher), il doit connaître la structure exacte de la base de données : quels nœuds sont connectés les uns aux autres et quelles propriétés chaque nœud possède. Sans cette connaissance, il ne peut pas générer des requêtes valides et se met à inventer des éléments.

La classe Neo4jGraph cache ce problème de topologie. Le schéma n’est pas codé en dur dans l’application Java, car le composant le récupère et le formate dynamiquement. Le PR #119 a considérablement accéléré ce processus. Auparavant, la collecte des nœuds, des propriétés et des relations pouvait nécessiter plusieurs allers-retours vers la base de données. Ce PR a intégré l’extraction du schéma dans une seule requête Cypher optimisée, basée sur la procédure interne apoc.meta.data de Neo4j. Il a également ajouté une taille d’échantillonnage configurable ainsi qu’un schéma amélioré incluant des valeurs d’échantillon, ce qui fournit au modèle un meilleur contexte.

Prenons cet ensemble de données comme exemple :

L’exposition de son schéma au modèle se présente comme suit :

import dev.langchain4j.store.graph.neo4j.Neo4jGraph;
// If I want to initialize the graph abstraction and load the schema:
Neo4jGraph graph = Neo4jGraph.builder()
    .driver(driver)
    .build();
// Under the hood, this executes a single, consolidated APOC query that yields
// labels, element types, and properties all at once, avoiding multiple DB calls.
graph.refreshSchema();
Neo4jGraph.StructuredSchema schema = graph.getStructuredSchema();
// We expect a well-formatted string logically divided into three sections,
// exactly as formatted by the new Neo4jGraphSchemaUtils class:
//
// `schema.nodesProperties()` is the following:
// :Person {name: STRING}, :Company {name: STRING}
//
// `schema.relationshipsProperties()` is the following:
// :WORKS_FOR {since: INTEGER}
//
// `schema.patterns()` is the following:
// (:Person)-[:WORKS_FOR]->(:Company)
System.out.println("Current Database Schema Context:\n" + schema);

Face à un graphique d’entreprise colossal comptant des milliards de nœuds, l’extraction de métadonnées peut devenir coûteuse si la base de données tente de scanner tout le contenu. Les paramètres sample et maxRels, directement liés à apoc.meta.data, permettent de contrôler cette situation. L’option enhanced-schema ajoute des valeurs d’attributs d’échantillon afin que le modèle puisse voir à quoi ressemblent réellement les données.

// If I want to scan a very large database efficiently by configuring the APOC sampling,
// and I want to enhance the LLM's understanding with sample property values:
Neo4jGraph optimizedGraph = Neo4jGraph.builder()
    .driver(driver)
    // We can configure the underlying apoc.meta.data parameters.
    // 'sample' limits the number of nodes inspected per label to speed up execution.
    // 'maxRels' limits the number of relationships inspected per node.
    // (Note: These are passed internally to the getSchemaFromMetadata utility)
    .build();

// When the schema is refreshed, the underlying query runs:
// CALL apoc.meta.data({maxRels: $maxRels, sample: $sample})
optimizedGraph.refreshSchema();
// The LLM now receives a fast, accurately sampled schema representation,
// protecting database performance during application startup or schema refreshes.
System.out.println("Optimized Schema loaded successfully.");

Avec les métadonnées récupérées en une seule requête et l’échantillonnage mis en évidence, un pipeline Text-to-Cypher s’adapte automatiquement aux changements de la base de données, permettant une extraction rapide même sur d’immenses ensembles de données de production.

Création d’un graphe à partir de texte

De nombreuses bases de données contiennent déjà de grands champs de texte, et leur transformation en un graphe de connaissances constitue la base des approches avancées GraphRAG telles que la synthèse axée sur les requêtes et le regroupement par thème. Auparavant, cela impliquait de gérer des pipelines de reconnaissance d’entités nommées fragiles et basés sur des règles. LLMGraphTransformer confie cette tâche au LLM.

La classe est indépendante des bases de données, car elle convertit le texte en nœuds et en arêtes qui servent également à d’autres fins. Afin d’empêcher le modèle de produire des hallucinations ou de fragmenter l’ontologie, par exemple en inventant des étiquettes inattendues, les développeurs orientent l’extraction à l’aide d’exemples concrets (prompting à faible quantité d’exemples) et de schémas précisément définis. La communauté a également ajouté un moyen natif pour lier les entités extraites au document source d’où elles proviennent, ce qui permet de conserver les métadonnées originales.

La sortie JSON doit respecter une structure fixe, avec des clés head, head_type, relation, tail et tail_type ; des exemples de prompts enseignent au modèle cette structure. Voici un extrait du JSON d’exemple fourni au LLM :

[
   {
      "tail": "Microsoft",
      "head": "Adam",
      "head_type": "Person",
      "text": "Adam is a software engineer in Microsoft since 2009...",
      "relation": "WORKS_FOR",
      "tail_type": "Company"
   },
   {
      "tail": "Microsoft Word",
      "head": "Microsoft",
      "head_type": "Company",
      "text": "Microsoft is a tech company that provides several products...",
      "relation": "PRODUCED_BY",
      "tail_type": "Product"
   }
]

Avec ces exemples dans leur contexte, le transformateur peut traiter des documents d’entrée réels contenant du texte et des métadonnées, comme "Keanu Reeves a joué dans Matrix" qui inclut la métadonnée key33: value3.

import dev.langchain4j.community.data.document.transformer.graph.LLMGraphTransformer;
import dev.langchain4j.community.data.document.graph.GraphDocument;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DefaultDocument;
import dev.langchain4j.data.document.Metadata;
import java.util.List;

ChatModel chatModel = /* dev.langchain4j.model.chat instance */
Driver driver = /* org.neo4j.driver.Driver instance */

// If I want to guide the extraction by providing a structured set of examples:
LLMGraphTransformer transformer = LLMGraphTransformer.builder()
    .model(chatModel)
    .examples(EXAMPLES_PROMPT) // Injects the above JSON dataset into the system prompt
    .build();
Document docKeanu = new DefaultDocument(
    "Keanu Reeves acted in Matrix",
    Metadata.from("key33", "value3")
);
// The LLM will transform the text, structuring nodes and relationships based on the examples
List<GraphDocument> graphDocs = transformer.transformAll(List.of(docKeanu));

/*
The above `graphDocs` returns this result:

GraphDocument
├─ Nodes
│  ├─ GraphNode
│  │  ├─ id: Matrix
│  │  ├─ type: Movie
│  │  └─ properties: {}
│  │
│  └─ GraphNode
│     ├─ id: Keanu Reeves
│     ├─ type: Person
│     └─ properties: {}
│
├─ Relationships
│  └─ GraphEdge
│     ├─ type: ACTED_IN
│     ├─ sourceNode
│     │  ├─ id: Keanu Reeves
│     │  └─ type: Person
│     ├─ targetNode
│     │  ├─ id: Matrix
│     │  └─ type: Movie
│     └─ properties: {}
│
└─ Source
   ├─ text: "Keanu Reeves acted in Matrix"
   └─ metadata
      └─ key33: value3
*/

Lorsque les objets GraphDocument existent en mémoire, ils doivent être enregistrés. En environnement de production, le même document peut être traité plus d’une fois, il est donc nécessaire d’éviter les doublons, comme deux nœuds distincts intitulés « Keanu Reeves ». KnowledgeGraphWriter s’occupe de cela.

import dev.langchain4j.community.rag.content.retriever.neo4j.KnowledgeGraphWriter;

Neo4jGraph neo4jGraph = /* dev.langchain4j.store.graph.neo4j.Neo4jGraph instance */;
LLMGraphTransformer graphTransformer = /* dev.langchain4j.community.data.document.transformer.graph.LLMGraphTransformer instance */;

Document docKeanu = new DefaultDocument(
        "Keanu Reeves acted in Matrix",
        Metadata.from("key33", "value3")
);
List<GraphDocument> graphDocs = graphTransformer.transformAll(List.of(docKeanu));


// If I want to persist the extracted entities safely:
KnowledgeGraphWriter writer = KnowledgeGraphWriter.builder()
    .graph(neo4jGraph)
    .build();
// The first write populates the database
writer.addGraphDocuments(graphDocs, false);
// Executing the exact same command again is safe.
// The internal UNWIND and MERGE logic generated by the writer guarantees
// that no duplicate entities or relationships are created.
writer.addGraphDocuments(graphDocs, false);
// Expected resulting topology in the database:
// (:__Entity__ {id: 'keanu'})-[:ACTED]->(:__Entity__ {id: 'matrix'})
System.out.println("Entities persisted successfully. No duplicates created.");

La base de données ressemble finalement à ceci :

Préservation de l’origine

Pour GraphRAG avancé, il est utile de savoir précisément quel fragment de texte a généré une relation donnée. En activant le paramètre includeSource, on enregistre les entités et on crée également un nœud Document contenant le texte original ainsi que les métadonnées, lié aux entités extraites.

// If I want to maintain data provenance and link entities back to their source:
writer.addGraphDocuments(graphDocs, true); // true = includeSource

// Behind the scenes, the writer executes three crucial operations:
// 1. It creates the Document node, copying the original metadata (e.g., key33: value3) and the text.
// 2. If the original document lacks an ID, the writer automatically generates
//    an MD5 hash of the text to use as a unique identifier.
// 3. It links the document to the extracted entities using a relationship (default: HAS_ENTITY).
// Expected resulting topology in the database:
// (:Document {id: '<MD5_hash>', text: 'Keanu Reeves...', key33: 'value3'})-[:HAS_ENTITY]->(:__Entity__ {id: 'keanu'})
System.out.println("Source document successfully linked to the extracted entities.");

Les environnements d’entreprise ne peuvent pas compter sur des étiquettes génériques telles que __Entity__ ou HAS_ENTITY, et les noms de propriétés provenant d’entrées externes ouvrent la porte à l’injection Cypher. L’auteur nettoie chaque entrée à l’aide de SchemaNames.sanitize, et les développeurs peuvent réécrire toute l’ontologie, y compris les noms des contraintes d’unicité créées dans la base de données.

// If I want to adapt the ingestion to a pre-existing enterprise schema,
// and customize the relationship that links the document to the entities:
KnowledgeGraphWriter customWriter = KnowledgeGraphWriter.builder()
    .graph(neo4jGraph)
    .label("ActorOrMovie")             // Replaces "__Entity__"
    .idProperty("customId")            // Replaces "id"
    .textProperty("customText")        // Replaces "text" for the Document node
    .relType("MENTIONED_IN_SOURCE")    // Replaces "HAS_ENTITY"
    .constraintName("unique_custom")   // Sets a specific name for the Neo4j CONSTRAINT
    .build();

// Inserting the data with includeSource set to true will now use the new nomenclature:
customWriter.addGraphDocuments(graphDocs, true);

// Example of the resulting Cypher pattern generated by the writer:
// (:Document {customText: '...'})-[:MENTIONED_IN_SOURCE]->(:ActorOrMovie {customId: 'keanu'})

Cette configuration génère une topologie personnalisée, présentée ici avec des entités reliées à leur source :

Configuré de cette manière, l’extraction de graphes pilotée par des LLM est automatisée, traçable grâce aux liens vers la source, idempotente et sûre à utiliser dans des pipelines de données structurées.

Des requêtes plus sûres grâce au Cypher DSL

Un changement architectural important dans le repository communautaire est l’intégration approfondie de la bibliothèque Cypher DSL. Les premiers systèmes GraphRAG construisaient souvent des requêtes en concaténant des chaînes de caractères brutes, ce qui est déconseillé en raison du risque d’injection Cypher et des erreurs de syntaxe. Les composants internes de LangChain4j construisent désormais des requêtes de manière programmatisée sous forme d’arbre de syntaxe abstraite (AST). La plupart de ces opérations se font de manière invisible, mais cela représente une avancée majeure pour la sécurité des applications.

import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Statement;
import org.neo4j.cypherdsl.core.Node;

Driver driver = /* org.neo4j.driver.Driver instance */

// Demonstrating how internal queries are constructed safely via the DSL
// We define a Node representation first
Node documentNode = Cypher.node("Document").named("d");
// We build the query programmatically using the fluent API.
// Notice how literal values are wrapped safely, preventing injection.
Statement statement = Cypher.match(documentNode)
    .where(Cypher.property("d", "id").isEqualTo(Cypher.literalOf("doc-123")))
    .returning(Cypher.property("d", "text"))
    .build();
// The DSL engine traverses the AST and compiles it into a syntactically safe string.
String safeCypherQuery = statement.getCypher();
// Expected output: MATCH (d:`Document`) WHERE d.id = 'doc-123' RETURN d.text
System.out.println("Generated safe Cypher via DSL: " + safeCypherQuery);

Le moteur DSL comprend la grammaire de Cypher, de sorte que son API ne peut pas générer de requête syntaxiquement invalide. Cela offre une sécurité fiable aux composants internes de l’intégration.

Pré-filtrage dans l’index

La recherche vectorielle de Neo4j a été considérablement optimisée, et l’intégration LangChain4j prend désormais en charge la nouvelle syntaxe de recherche vectorielle, y compris les optimisations de 2026.01. L’avantage principal est le filtrage direct dans l’index.

Il s’agit d’une recherche de similarité associée à une contrainte de métadonnées, par exemple des documents sémantiquement proches de « machine learning » mais publiés uniquement en 2024. Les approches anciennes de bases de données vectorielles récupèrent d’abord les vecteurs les plus similaires avant d’éliminer ceux correspondant à une année incorrecte. Ce post-traitement gaspille des ressources et renvoie souvent moins de documents que demandé, ce qui nuit à la précision de la récupération. Le filtrage en phase d’indexation applique les contraintes de métadonnées pendant la navigation dans l’index vectoriel HNSW (Hierarchical Navigable Small World). La documentation Neo4j fournit davantage de détails sur la syntaxe ainsi que sur les améliorations de performance qu’elle apporte.

import dev.langchain4j.store.embedding.neo4j.Neo4jEmbeddingStore;
import dev.langchain4j.store.embedding.filter.Filter;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;

Driver driver = /* org.neo4j.driver.Driver instance */
Embedding embedding = /* dev.langchain4j.data.embedding.Embedding instance */

MatchSearchClauseStrategy matchSearchClauseStrategy = new MatchSearchClauseStrategy();

// Configure the store with the new syntax enabled
Neo4jEmbeddingStore store = Neo4jEmbeddingStore.builder()
    .driver(driver)
    .dimension(1536)
    .searchStrategy(matchSearchClauseStrategy) // Crucial flag: Enables the 2026.01 optimized native vector search syntax
    .filterMetadata(Arrays.asList("year", "department")) // Enable filtering for 'year' and 'department', which translates to the `WITH [indexName.year, indexName.department]` clause during index creation
    .build();

// Build a metadata filter combining multiple boolean conditions
Filter filter = metadataKey("year").isEqualTo(2024)
    .and(metadataKey("department").isEqualTo("Engineering"));
// Execute the search request
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
    .queryEmbedding(embedding)
    .maxResults(5)
    .filter(filter) // Filter is executed natively inside the Neo4j Vector Index block
    .build();
SearchResult<TextSegment> results = store.search(request);
// The results will natively exclude any documents not matching the criteria specified in the `filter` instance
// returning the final Top-K, ensuring you always get 5 highly relevant segments if they exist.
System.out.println("Search executed with in-index filtering.");
// Execute results.matches() to verify in-index filtering

Modèles de récupération GraphRAG

La recherche vectorielle gère bien la similarité sémantique, mais elle échoue face aux questions analytiques, telles que les agrégations ou les parcours à plusieurs étapes comme « Qui sont les collègues des ingénieurs travaillant sur le Projet X ? ». Les vecteurs ne peuvent pas compter, additionner ou parcourir de manière fiable des hiérarchies structurées. Le concept GraphRAG vise précisément cette intention analytique, et graphrag.com propose une introduction à ce sujet.

Le découpage en fragments crée un dilemme courant dans le RAG. De petits fragments d’environ 200 tokens permettent des résultats vectoriels précis, mais manquent de contexte environnant, ce qui nuit à la génération de texte. De grands fragments d’environ 2000 tokens fournissent au LLM beaucoup de contexte, mais réduisent fortement la précision de la recherche. Un ensemble d’outils de traitement structurel et de récupération résout ce problème en exploitant la nature bipartite du graphe, qui sépare l’unité recherchable de l’unité retrouvable.

Parent-enfant

Les documents sont divisés en de petits fragments « enfants » utilisés uniquement pour la recherche vectorielle, et chacun est relié par une relation au document « parent » complet. Les embeddings sont calculés sur de petites unités sémantiquement cohérentes, ce qui améliore la qualité de la récupération. Pour diviser un grand document, embeddez les fragments et attachez-les à un nœud parent dans Neo4j en utilisant ParentChildGraphIngestor.

import dev.langchain4j.store.graph.neo4j.Neo4jParentChildIngestor;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.splitter.DocumentSplitters;

// embeddingModel, embeddingStore, childSplitter instances...

// If I want to automatically ingest a document into a Parent-Child graph topology:
Neo4jEmbeddingStoreIngestor ingestor = ParentChildGraphIngestor.builder()
          .driver(driver)
          .embeddingModel(embeddingModel)
          // We define how the document should be chunked before ingestion
          .documentSplitter(DocumentSplitters.recursive(200, 20))
          .documentChildSplitter(childSplitter)
          .build();

Document document = Document.from( """Artificial Intelligence (AI) is a field of computer science. It focuses on creating intelligent agents capable of performing tasks that require human intelligence.
                        Machine Learning (ML) is a subset of AI. It uses data to learn patterns and make predictions. Deep Learning is a specialized form of ML based on neural networks.
                        """);

ingestor.ingest(List.of(document));
// The graph now contains one 'Document' node connected via 'HAS_CHILD'
// to multiple embedded 'DocumentChunk' nodes.
System.out.println("Parent and child nodes successfully ingested and linked.");

Après ingestion, retrieval parcourt les petits fragments et transmet le texte parent complet à l’LLM via EmbeddingStoreContentRetriever :

import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;


// If I want to search against chunks but retrieve the rich parent document:
final EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore)
        .maxResults(1)
        .minScore(0.4)
        .build();

List<Content> contents = retriever.retrieve(Query.from("specific configuration detail"));
// The retriever hits the small 'DocumentChunk' index, traverses the 'HAS_CHILD'
// relationship, and returns the entire 'Document' node.
System.out.println("Retrieved full parent document context.");
/*
The result of `contents` is :

DefaultContent {
  textSegment = TextSegment {
    text = """
      Machine Learning (ML) is a subset of AI. It uses data to learn patterns and make predictions.
      Deep Learning is a specialized form of ML based on neural networks.

      Machine Learning (ML) is a subset of AI. It uses data to learn patterns and make predictions.
      Deep Learning is a specialized form of ML based on neural networks.

      Artificial Intelligence (AI) is a field of computer science. It focuses on creating intelligent agents
      capable of performing tasks that require human intelligence.
    """,
    metadata = {
      index = 1,
      source = Wikipedia link,
      title = AI Basics,
      url = https://example.com/ai,
      parentId = parent_1af3e080-5029-40ab-b3d7-0829064d800d
    }
  },
  metadata = {
    EMBEDDING_ID = null,
    SCORE = 0.8560410737991333
  }
}
*/

Résumés en tant qu’unité retournée

Retourner un élément parent entier peut consommer trop de tokens. Une solution plus efficace consiste à générer un résumé de l’élément parent lors de l’ingestion et à retourner ce résumé lorsque l’un des fragments enfants correspond. SummaryGraphIngestor fait en sorte que le LLM écrive ce résumé, le stocke en tant que nœud distinct et le lie aux fragments recherchables :

import dev.langchain4j.community.store.embedding.neo4j.Neo4jEmbeddingStoreIngestor;
import dev.langchain4j.community.store.embedding.neo4j.SummaryGraphIngestor;

// If I want the LLM to summarize my document during ingestion and store the summary:
/* Neo4jEmbeddingStore, ChatModel and DocumentSplitter instances... */

final Neo4jEmbeddingStoreIngestor ingestor = SummaryGraphIngestor.builder()
        .driver(driver)
        .embeddingModel(embeddingModel)
        .questionModel(chatModel)
        .documentSplitter(parentSplitter)
        .build();

ingestor.ingest(List.of(document));
// The graph now has a 'Summary' node (containing the LLM-generated summary)
// linked to the specific 'DocumentChunk' nodes.
System.out.println("Document ingested and summarized successfully.");

Neo4jSummaryRetriever s’occupe de la récupération, de sorte qu’un résultat vectoriel correspondant à un morceau de données fournit le résumé précalculé à la place du texte brut :

import dev.langchain4j.community.store.embedding.neo4j.Neo4jEmbeddingStoreIngestor;
import dev.langchain4j.community.store.embedding.neo4j.SummaryGraphIngestor;

/* required instances */

Document document = Document.from("""
        Artificial Intelligence (AI) is a field of computer science. It focuses on creating intelligent agents capable of performing tasks that require human intelligence.
        Machine Learning (ML) is a subset of AI. It uses data to learn patterns and make predictions. Deep Learning is a specialized form of ML based on neural networks.
        """);

ingestor.ingest(document);

final EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingModel(embeddingModel)
        .maxResults(5)
        .minScore(0.6)
        .embeddingStore(ingestor.getEmbeddingStore())
        .build();

/*
The result is something like this:

DefaultContent {
  textSegment = TextSegment {
    text = "Machine Learning (ML) is a subset of AI",
    metadata = {
      index  = 0,
      source = Wikipedia link,
      title  = Quantum Mechanics,
      url    = https://example.com/ai
    }
  },
  metadata = {
    EMBEDDING_ID = null,
    SCORE        = 0.8425111770629883
  }
}
*/

Questions hypothétiques

Les embeddings sont sensibles à la formulation. Une question courte comme « Qui est John Doe ? » diffère structurellement d’un paragraphe long sur John Doe, ce qui fait baisser les scores de similarité et nuire à la capacité de rappel. Le modèle LLM rédige, au moment de l’ingestion, les questions auxquelles chaque fragment répond. Le système incorpore ces questions et non le texte brut, en y ajoutant des liens vers le document parent. La requête de l’utilisateur est alors comparée aux questions intégrées, ce qui améliore considérablement la précision.

import dev.langchain4j.community.store.embedding.neo4j.Neo4jEmbeddingStoreIngestor;
import dev.langchain4j.community.store.embedding.neo4j.HypotheticalQuestionGraphIngestor;

/* ... Neo4jEmbeddingStore, ChatModel and DocumentSplitter instances.. */

Neo4jEmbeddingStoreIngestor ingestor = HypotheticalQuestionGraphIngestor.builder()
        .embeddingModel(embeddingModel)
        .driver(driver)
        .documentSplitter(splitter)
        .questionModel(chatModel)
        .embeddingStore(embeddingStore)
        .build();

Document document = Document.from("""
                        Quantum mechanics studies how particles behave. It is a fundamental theory in physics.
                        Gradient descent and backpropagation algorithms.
                        Spaghetti carbonara and Italian dishes.
                        John Doe is a Super Saiyan.
                        """);
ingestor.ingest(document);

EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
                .embeddingModel(embeddingModel)
                .maxResults(2)
                .minScore(0.5)
                .embeddingStore(ingestor.getEmbeddingStore())
                .build();

List<Content> results = retriever.retrieve(Query.from("Who is John Doe?"));

System.out.println("Retrieved Hypothetical context: " + results);

/*
The result is something like this:

DefaultContent {
  textSegment = TextSegment {
    text = "John Doe is a Super Saiyan.",
    metadata = {
      index  = 2,
      source = Wikipedia link,
      title  = Quantum Mechanics,
      url    = https://example.com/ai
    }
  },
  metadata = {
    SCORE        = 0.8234479427337646,
    EMBEDDING_ID = 2002cabe-2a3e-4c6e-96ec-a0292e26e817
  }
}

*/

Cypher personnalisé

Parfois, les topologies de type parent-enfant, de résumé ou hypothétiques ne correspondent pas à un modèle de données d’entreprise spécifique, et il est nécessaire de définir exactement quel Cypher doit s’exécuter lors de l’ingestion et de la récupération des données.

final Neo4jEmbeddingStore neo4jEmbeddingStore = /* Neo4jEmbeddingStore instance */

final EmbeddingStoreContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingModel(embeddingModel)
        .maxResults(5)
        .minScore(0.4)
        .embeddingStore(neo4jEmbeddingStore)
        .build();

// other required instances ...

Document doc = Document.from("""
                        Quantum mechanics studies how particles behave. It is a fundamental theory in physics.
                        Gradient descent and backpropagation algorithms.
                        Spaghetti carbonara and Italian dishes.
                        John Doe is a Super Saiyan.
                        """);

// Ingest the document into Neo4j as parent-child nodes
final Neo4jEmbeddingStoreIngestor ingestor = Neo4jEmbeddingStoreIngestor.builder()
        .documentSplitter(parentSplitter)
        .documentChildSplitter(childSplitter)
        .driver(driver)
        .query("CREATE (:MainDoc $metadata)") // a Cypher query template used for storing the processed segment data in Neo4j
        .embeddingStore(neo4jEmbeddingStore)
        .embeddingModel(embeddingModel)
        .build();

ingestor.ingest(doc);

final String retrieveQuery = "Machine Learning";
List<Content> results = retriever.retrieve(Query.from(retrieveQuery));

System.out.println("Retrieved Generic context: " + results);

Bien que ce matériel mette l’accent sur les forces intrinsèques des graphes de Neo4j, la communauté LangChain4j a généralisé ces modèles structuraux. Les outils d’ingestion mentionnés ci-dessus reposent en sous-main sur ParentChildEmbeddingStoreIngestor, ce qui permet une récupération parent-enfant générique avec tout stock d’encodages LangChain4j capable de gérer correctement les métadonnées. Ainsi, certaines bases de données vectorielles simples disposent également d’une fonctionnalité RAG de type graphique de base.

import dev.langchain4j.community.store.embedding.ParentChildEmbeddingStoreIngestor;

/* Required instances */

ParentChildEmbeddingStoreIngestor ingestor = ParentChildEmbeddingStoreIngestor.builder()
                .documentTransformer(documentTransformer)
                .documentSplitter(documentSplitter)
                .textSegmentTransformer(textSegmentTransformer)
                .embeddingModel(embeddingModel)
                .embeddingStore(embeddingStore)
                .documentChildSplitter(documentChildSplitter)
                .childTextSegmentTransformer(childTextSegmentTransformer)
                .build();

Mémoire de chat dans le graphe

Les agents de conversation ont besoin de conserver leur état au fil des interactions, et une IA sans mémoire n’est qu’une fonction sans état. Les stockages en mémoire de LangChain4j sont éphémères et inadaptés à un environnement de production où les applications redémarrent et s’adaptent horizontalement. Neo4jChatMemoryStore conserve quant à lui l’historique des conversations à l’intérieur de Neo4j. Il ne s’agit pas simplement d’un enregistrement de texte : c’est une séquence de nœuds liés, en réalité une liste chaînée sous forme de graphe, qui permet de conserver de manière persistante et consultable les interactions de l’utilisateur.

Déjà prêt à l’emploi, ce stockage crée un nœud racine Memory identifié par une propriété id. Une chaîne de nœuds Message y est attachée grâce aux relations LAST_MESSAGE et NEXT. L’outil de configuration nécessite soit une configuration de session Neo4j, soit une instance de Driver existante, et il propose les paramètres optionnels suivants :

  • l’étiquette du nœud mémoire (par défaut Memory) et l’étiquette du nœud de message (par défaut Message) ;
  • la clé de la propriété d’ID mémoire (par défaut id) qui identifie la session de chat, ainsi que la clé de la propriété du texte du message (par défaut message) ;
  • le type de relation dernier-message (par défaut LAST_MESSAGE) et le type de relation prochain-message (par défaut NEXT) ;
  • le nom de la base de données Neo4j (par défaut neo4j) ;
  • la taille de récupération des messages (par défaut 10), qui limite la fenêtre de contexte.

Afin de séparer les sessions de plusieurs utilisateurs, le stockage les isole grâce à un memoryId unique. Les modèles LLM modernes acceptent des entrées multimodales, et le stockage serialize et persiste des contenus complexes tels que des images, avant de les supprimer également.

import dev.langchain4j.store.memory.chat.neo4j.Neo4jChatMemoryStore;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ImageContent;
import java.util.List;

// 1. Initialize the store using an existing driver
Neo4jChatMemoryStore memoryStore = Neo4jChatMemoryStore.builder()
    .driver(driver)
    .build();
// 2. Identify the specific user sessions
String sessionId1 = "user-alice-123";
String sessionId2 = "user-bob-456";
// 3. Append standard text messages for Alice
List<ChatMessage> aliceMessages = List.of(
    new UserMessage("Hi, I'm Alice."),
    new AiMessage("Hello Alice!")
);
memoryStore.updateMessages(sessionId1, aliceMessages);
// 4. Append multimodal messages (text + images) for Bob
List<ChatMessage> bobMessages = List.of(
    new UserMessage("What do you see in this image?", List.of(new ImageContent("https://...")))
);
memoryStore.updateMessages(sessionId2, bobMessages);
// When we retrieve or delete messages using sessionId1,
// the graph guarantees that Bob's linked list of messages remains completely untouched.
System.out.println("Isolated memory chains created for both Alice and Bob.");


// 5. Optionally delete messages
// memoryStore.deleteMessages(sessionId1);
// memoryStore.deleteMessages(sessionId2);

Le résultat stocké ressemble à ceci :

Les bases de données d’entreprise accueillent rarement des étiquettes génériques telles que Memory ou Message. Pour une base de données qui suit déjà des règles de nommage strictes, le générateur peut remplacer chaque étiquette, propriété et type de relation utilisés par le moteur.

// If I want to align the memory storage with my specific domain ontology:
Neo4jChatMemoryStore customMemoryStore = Neo4jChatMemoryStore.builder()
    .driver(driver)
    .memoryLabel("UserSession")         // Overrides the default "Memory" label
    .messageLabel("ChatTurn")           // Overrides the default "Message" label
    .lastMessageRelType("LATEST_CHAT")  // Overrides the default "LAST_MESSAGE" rel
    .nextMessageRelType("FOLLOWED_BY")  // Overrides the default "NEXT" rel
    .idProperty("sessionKey")           // Overrides the default "id" property
    .messageProperty("textContent")     // Overrides the default "message" property
    .build();

// Now, when the system persists a chat, it will execute domain-specific Cypher queries like:
// MERGE (m:UserSession {sessionKey: 'user-alice-123'})
// CREATE (msg:ChatTurn {textContent: 'Hi...'})
// MERGE (m)-[:LATEST_CHAT]->(msg)
System.out.println("Custom memory store initialized with domain-specific schema.");

Ce degré de flexibilité empêche la mémoire de ressembler à un plugin externe, ce qui la transforme en une composante bien intégrée à l’architecture graphique existante.

Taille de la fenêtre

Charger un historique de milliers de messages dépasse la limite de tokens, augmente les coûts liés à l’API et détériore la qualité des réponses. Le paramètre size dans le constructeur permet de gérer une fenêtre glissante sur la liste chaînée.

// If I want to heavily restrict the context window to only the most recent interactions:
Neo4jChatMemoryStore slidingWindowStore = Neo4jChatMemoryStore.builder()
    .driver(driver)
    .size(3) // The default is 10. We limit it to the 3 most recent messages.
    .build();

// Assuming the user has sent 20 messages in this session over the past month.
List<ChatMessage> recentHistory = slidingWindowStore.getMessages("user-alice-123");
// The system efficiently traverses the graph starting from the LATEST_CHAT relationship
// and walks backwards via the FOLLOWED_BY relationships, stopping after it collects the
// limited batch of recent messages. The older historical messages remain safely in the
// database, but are not loaded into memory, saving precious tokens.
System.out.println("Loaded only the " + recentHistory.size() + " most recent messages.");

Parfois, il est nécessaire de disposer de l’ensemble des informations, comme dans le cas d’une tâche d’analyse en arrière-plan qui résume les tickets de support sur une période d’un mois. Une valeur size égale à 0 ou négative indique à la boutique de parcourir toute la liste jusqu’à la première interaction.

// If I want to extract the complete history for analytics or summarization:
Neo4jChatMemoryStore completeHistoryStore = Neo4jChatMemoryStore.builder()
    .driver(driver)
    .size(0) // 0 disables the sliding window limit
    .build();

List<ChatMessage> fullHistory = completeHistoryStore.getMessages("user-alice-123");
System.out.println("Extracted the complete session history containing " + fullHistory.size() + " messages.");

Dans les petites applications ou les fonctions serverless, gérer un bean global Neo4j Driver peut s’avérer inutile. Une méthode d’authentification basique abrégée disponible dans le constructeur permet d’utiliser directement les identifiants requis.

// If I want to instantiate the store directly without managing an external Driver instance:
Neo4jChatMemoryStore standaloneStore = Neo4jChatMemoryStore.builder()
    .withBasicAuth("bolt://localhost:7687", "neo4j", "password")
    .build();
System.out.println("Memory store connected directly via Basic Auth.");

La seule configuration requise est celle relative au connecteur Java de Neo4j. En alternative, Neo4jChatMemoryStore.builder().withBasicAuth(, , ) crée une instance de Driver en interne.

Puisque la mémoire est nativement intégrée à Neo4j, des algorithmes graphiques personnalisés ou des tâches en arrière-plan peuvent analyser ces nœuds, extraire les entités mentionnées dans les conversations et relier l’historique de chat d’un utilisateur au graphique de connaissances principal, permettant ainsi des expériences d’IA profondément personnalisées.

L’intégration entre LangChain4j et Neo4j a déjà beaucoup évolué. À mesure que les modèles s’améliorent et que les bases de données graphiques continuent d’optimiser les opérations vectorielles, de nouveaux changements et fonctionnalités verront certainement le jour.