Startseite / Artikel

Neo4j und LangChain4j: GraphRAG-Eingabemodule, Filterung und Chat-Gedächtnis

Neo4j mit LangChain4j für GraphRAG: Schema-Entdeckung, Erstellung von Graphen mit großen Sprachmodellen, Cypher DSL, Filtern im Index, Abruf von Eltern-Kind-Beziehungen sowie chatbasiertes Gedächtnis, das auf Graphen beruht.

Verlag: REACTAPP.TOP18 Min. Lesezeit4156 Wörter

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

Jenseits des grundlegenden Vektorlagers

Ein einfacher Neo4j-Vektorlager, der innerhalb einer Java-Anwendung läuft, ist lediglich der Einstiegspunkt. Die Mitwirkenden im Community-Repository von LangChain4j haben seitdem Funktionen hinzugefügt, die weit über eine einfache Ähnlichkeitssuche hinausgehen: automatische Graphenerstellung, angepasstes Vektorfiltern, strukturelle GraphRAG-Muster sowie Chat-Gedächtnis, das als Graph gespeichert wird. Die folgenden Abschnitte behandeln jedes dieser Aspekte zusammen mit dem Code, den die eigenen Tests der Community verwenden.

Alles hier bezieht sich auf Version 1.0.0-beta19 – spätere Versionen können einige Verhaltensweisen oder Schnittstellen ändern.

Die Abhängigkeiten sind dieselben wie bei einer grundlegenden Einrichtung, wobei das Neo4j-Retriever-Modul sowie seine zugehörigen Komponenten im Maven-File enthalten sind:

<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>

Schema-Entdeckung für Text-zu-Cypher

Wenn ein Modell Datenbankabfragen direkt aus natürlicher Sprache erstellt (Text-zu-Cypher), muss es die genaue Struktur der Datenbank kennen: welche Knoten miteinander verbunden sind und welche Eigenschaften jeder Knoten besitzt. Ohne dieses Wissen kann es keine gültigen Abfragen erstellen und beginnt stattdessen Dinge zu erfinden.

Die Klasse Neo4jGraph versteckt dieses Topologieproblem. Das Schema ist nicht in der Java-Anwendung fest codiert, da der Komponente es dynamisch abruft und formatiert. Der PR #119 beschleunigte dies erheblich. Früher konnten das Sammeln von Knoten, Eigenschaften und Beziehungen mehrere Hin- und Rückreisen zur Datenbank erfordern. Der PR führte die Extraktion des Schemas in eine optimierte Cypher-Anfrage ein, die auf Neo4js internem apoc.meta.data-Verfahren basiert. Zudem wurde eine konfigurierbare Stichprobengröße hinzugefügt sowie ein erweitertes Schema, das Stichprobenwerte enthält, wodurch dem Modell besseres Kontextwissen zur Verfügung steht.

Nehmen wir dieses Datensatz als Beispiel:

Die Offenlegung seines Schemas an das Modell sieht so aus:

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);

In einem riesigen Unternehmensgraphen mit Milliarden von Knoten kann das Extrahieren von Metadaten teuer werden, wenn die Datenbank versucht, alles zu scannen. Die Einstellungen sample und maxRels, die direkt mit apoc.meta.data verbunden sind, halten dies in Schach. Die Option „enhanced-schema“ fügt Beispieldatenwerte hinzu, damit das Modell sieht, wie die Daten tatsächlich aussehen.

// 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.");

Durch das Abrufen von Metadaten in einer einzigen Abfrage sowie die Offenlegung von Stichproben passt sich ein Text-zu-Chiffre-Pipeline automatisch an Änderungen in der Datenbank an, wodurch die Extraktion auch bei riesigen Produktionsdatensätzen schnell bleibt.

Erstellung eines Graphen aus Text

Viele Datenbanken enthalten bereits große Textfelder, und ihre Umwandlung in einen Wissensgraphen bildet die Grundlage für fortgeschrittene GraphRAG-Ansätze wie queryorientierte Zusammenfassung und Themenklustering. Früher bedeutete dies die Verwaltung fragiler, regelbasierten Pipelines für das Erkennen benannter Entitäten. LLMGraphTransformer überlässt diese Aufgabe dem LLM.

Die Klasse ist datenbankunabhängig, da sie Text in Knoten und Kanten umwandelt, die auch für andere Zwecke genutzt werden können. Um zu verhindern, dass das Modell Halluzinationen erzeugt oder die Ontologie fragmentiert – beispielsweise durch das Erfinden unerwarteter Labels – lenken Entwickler die Extraktion mit konkreten Beispielen (Few-Shot-Prompting) sowie präzise definierten Schemata. Die Community hat außerdem eine eingebaute Möglichkeit hinzugefügt, die extrahierten Entitäten wieder mit dem ursprünglichen Dokument zu verknüpfen, aus dem sie stammen, wodurch die ursprünglichen Metadaten erhalten bleiben.

Die JSON-Ausgabe muss einer festen Struktur folgen, mit Schlüsseln head, head_type, relation, tail und tail_type; Beispielprompts lehren das Modell diese Struktur. Hier ist ein Auszug aus dem Beispiel-JSON, das dem LLM bereitgestellt wird:

[
   {
      "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"
   }
]

Mit diesen Beispielen im Kontext kann der Transformer echte Eingabedokumente verarbeiten, die Text und Metadaten enthalten, wie zum Beispiel "Keanu Reeves spielte in Matrix" mit dem Metadatenpaar 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
*/

Sobald die GraphDocument-Objekte im Speicher vorhanden sind, müssen sie gespeichert werden. In der Produktion kann derselbe Dokument mehrfach verarbeitet werden, weshalb Schreibvorgänge Duplikate vermeiden müssen – beispielsweise zwei separate „Keanu Reeves“-Node. KnowledgeGraphWriter kümmert sich darum.

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.");

Die Datenbank sieht am Ende so aus:

Beibehaltung der Herkunft

Bei fortgeschrittenem GraphRAG ist es hilfreich, genau zu wissen, welcher Textabschnitt eine bestimmte Beziehung erzeugt hat. Durch Setzen der includeSource-Flagge werden die Entitäten gespeichert und gleichzeitig ein Document-Node erstellt, der den ursprünglichen Text sowie Metadaten enthält und mit den extrahierten Entitäten verknüpft ist.

// 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.");

Unternehmensumgebungen können sich nicht auf allgemeine Bezeichnungen wie __Entity__ oder HAS_ENTITY verlassen, und Eigennamen, die aus externen Eingaben stammen, eröffnen die Tür für Cypher-Injektionen. Der Autor säubert jeden Eingang mithilfe von SchemaNames.sanitize, und Entwickler können die gesamte Ontologie umschreiben, einschließlich der Namen der Eindeutigkeitsbeschränkungen, die in der Datenbank erstellt wurden.

// 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'})

Diese Konfiguration erzeugt eine benutzerdefinierte Topologie, die hier mit zu ihren Quellen verlinkten Elementen dargestellt wird:

So konfiguriert ist die durch große Sprachmodelle gesteuerte Graphenextraktion automatisiert, dank der Quellverlinkungen nachvollziehbar, idempotent und sicher für den Einsatz in strukturierten Datensystemen.

Sichere Abfragen durch Cypher DSL

Eine wichtige architektonische Änderung im Community-Repository ist die tiefe Integration der Cypher DSL-Bibliothek. Frühe GraphRAG-Systeme erstellten Abfragen oft durch das Zusammenfügen von Rohstrings, was aufgrund von Risiken durch Cypher-Injectionen und Syntaxfehlern nicht empfohlen wird. Die internen Komponenten von LangChain4j erstellen Abfragen nun programmatisch als abstraktes Syntaxbaum-Struktur (AST). Der größte Teil dieser Prozesse findet hinter den Kulissen statt, doch es handelt sich dabei um einen bedeutenden Schritt für die Sicherheit der Anwendungen.

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);

Der DSL-Engine versteht die Grammatik von Cypher, sodass ihre API keine syntaktisch ungültige Abfrage ausgeben kann. Das bietet den internen Mechanismen der Integration ein zuverlässiges Sicherheitsnetz.

Vorfiltern im Index

Die Vektorabfrage von Neo4j wurde erheblich optimiert, und die LangChain4j-Integration unterstützt nun die neue Vektorabfrage-Syntax, einschließlich der Optimierungen von 2026.01. Der Hauptvorteil ist das Vorfiltern im Index.

Verwenden Sie eine Ähnlichkeitssuche in Kombination mit einer Metadatenbeschränkung – beispielsweise Dokumente, die semantisch dem Begriff „Machine Learning“ nahekommen, aber nur im Jahr 2024 veröffentlicht wurden. Herkömmliche Vektordatenbankansätze holen zunächst die am ähnlichstenen Vektoren ab und entfernen anschließend diejenigen aus dem falschen Jahr. Dieses Nachfiltern verschwendet Ressourcen und liefert oft weniger Dokumente, als angefordert, was die Genauigkeit der Suche beeinträchtigt. Das Index-Filtering wendet die Metadatenbeschränkungen während der Durchsuchung des HNSW (Hierarchical Navigable Small World)-Vektorindexes an. In der Neo4j-Dokumentation finden Sie weitere Details zur Syntax sowie zu den damit erzielten Leistungsverbesserungen.

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

GraphRAG-Auswahmuster

Die Vektorsuche bewältigt semantische Ähnlichkeiten gut, stößt aber bei analytischen Fragen wie Aggregationen oder mehrstufigen Abfragen wie „Wer sind die Kollegen der Ingenieure, die an Projekt X arbeiten?“ an ihre Grenzen. Vektoren können nicht zählen, summieren oder zuverlässig durch strukturierte Hierarchien navigieren. Das Konzept GraphRAG zielt auf solche analytischen Anforderungen ab, und auf graphrag.com gibt es eine Einführung dazu.

Das Chunking stellt im RAG-Verfahren ein bekanntes Dilemma dar. Kleine Chunks mit etwa 200 Tokens liefern präzise Vektorergebnisse, fehlen aber am umgebenden Kontext, wodurch die Generierung beeinträchtigt wird. Große Chunks mit etwa 2000 Tokens liefern dem LLM zwar reichlich Kontext, verringern aber stark die Suchgenauigkeit. Eine Kombination aus strukturellen Einpflegemitteln und Auswahlmitteln löst dieses Problem, indem sie die bipartite Natur des Graphen nutzt, die die suchbaren Einheiten von den auswählbaren Einheiten trennt.

Eltern-Kind-Beziehungen

Dokumente werden in kleine „Kind“-Teile aufgeteilt, die ausschließlich für die Vektorsuche verwendet werden, wobei jeder Teil durch eine Beziehung mit dem vollständigen „Eltern“-Dokument verknüpft ist. Embeddings werden anhand kleiner, semantisch kohärenter Einheiten berechnet, was die Qualität der Suche verbessert. Um ein großes Dokument aufzuteilen, embedden Sie die Teile und fügen Sie sie in Neo4j zu einem Elternknoten hinzu – dazu verwenden Sie 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.");

Nach der Aufnahme durchsucht retrieval die kleinen Fragmente und übermittelt den vollständigen Elternteil an das LLM über 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
  }
}
*/

Zusammenfassungen als zurückgegebene Einheit

Die Rückgabe des gesamten Elternelements kann zu vielen Tokens führen. Eine effizientere Lösung besteht darin, während des Einlesens eine Zusammenfassung des Elternelements zu erstellen und diese zusammen mit einem passenden Kind-Element zurückzugeben. SummaryGraphIngestor lässt das LLM die Zusammenfassung schreiben, speichert sie als eigenen Knoten und verknüpft sie mit den suchbaren Elementen:

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 übernimmt die Abfrage, sodass ein Vektor-Ergebnis für einen Datensatz die im Voraus berechnete Zusammenfassung anstelle von Rohtext liefert:

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
  }
}
*/

Hypothetische Fragen

Embeddings sind empfindlich gegenüber der Formulierung. Eine kurze Frage wie „Wer ist John Doe?“ unterscheidet sich strukturell stark von einem ausführlichen Absatz über John Doe, wodurch die Ähnlichkeitswerte sinken und die Trefferquote leidet. Beim Erstellen der Fragen durch das LLM wird festgelegt, welche Fragen jede Teilmenge beantwortet. Das System embeddet anschließend diese Fragen und nicht den Rohtext, wobei Verweise auf das übergeordnete Dokument hinzugefügt werden. Die Abfrage eines Benutzers wird dann mit den embeddeden Fragen abgeglichen, wodurch die Genauigkeit erheblich steigt.

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
  }
}

*/

Custom Cypher

Manchmal passen die Eltern-Kind-, Zusammenfassungs- und hypothetischen Topologien nicht zu einem bestimmten Unternehmensdatenmodell, und man muss genau festlegen, welcher Cypher bei der Eingabe und Abfrage ausgeführt wird.

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);

Auch wenn sich dieses Material auf die nativen Graphenstärken von Neo4j konzentriert, haben die Entwickler der LangChain4j-Community diese Strukturmustern verallgemeinert. Die oben genannten Ingestoren stützen sich im Grunde auf ParentChildEmbeddingStoreIngestor, wodurch eine allgemeine Eltern-Kind-Aufruffunktion mit jedem LangChain4j-Embedding-Store funktioniert, der Metadaten ordnungsgemäß verarbeitet. Dadurch erhalten einige einfache Vektordatenbanken ebenfalls eine grundlegende RAG-Funktion im Graphenstil.

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();

Chat-Gedächtnis im Graphen

Unterhaltungsagenten benötigen einen Zustand über mehrere Interaktionen hinweg, und eine KI ohne Gedächtnis ist eine zustandslose Funktion. Die im Speicher liegenden Datenstrukturen von LangChain4j sind vorübergehend und eignen sich nicht für die Produktion, in der Anwendungen neu gestartet und horizontal skaliert werden. Neo4jChatMemoryStore speichert hingegen die Chat-Geschichte innerhalb von Neo4j. Es handelt sich dabei nicht nur um einen Textausdruck, sondern um eine vernetzte Abfolge von Knoten – im Grunde eine verknüpfte Liste in Graphform –, die eine dauerhafte und abfragbare Aufzeichnung der Interaktion des Benutzers bereitstellt.

Standardmäßig erstellt der Speicher einen Wurzelknoten Memory, der durch die Eigenschaft id identifiziert wird. An diesen hängt eine Kette von Message-Knoten über die Beziehungen LAST_MESSAGE und NEXT. Der Konfigurator benötigt entweder eine Neo4j-Sitzungskonfiguration oder eine bereits vorhandene Driver-Instanz und bietet folgende optionale Einstellungen an:

  • der Etikett des Speicherknotens (Standardwert Memory) und der Etikett des Nachrichtenknotens (Standardwert Message);
  • die Eigenschaftsschlüssel für die Speicher-ID (Standardwert id), die die Chat-Sitzung identifiziert, sowie der Eigenschaftsschlüssel für den Nachrichtentext (Standardwert message);
  • der Beziehungsart „Letzte Nachricht“ (Standardwert LAST_MESSAGE) und der Beziehungsart „Nächste Nachricht“ (Standardwert NEXT);
  • der Name der Neo4j-Datenbank (Standardwert neo4j);
  • die Anzahl der abgerufenen Nachrichten (Standardwert 10), die die Größe des Kontextfensters begrenzt.

Um die Sitzungen mehrerer Benutzer voneinander zu trennen, isoliert der Speicher sie mithilfe einer eindeutigen memoryId. Moderne LLMs akzeptieren multimodale Eingaben, und der Speicher serialisiert sowie speichert komplexe Inhalte wie Bilder und löscht diese anschließend ebenfalls.

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);

Das gespeicherte Ergebnis sieht so aus:

Unternehmensdatenbanken begrüßen selten allgemeine Bezeichnungen wie Memory oder Message. Bei einer Datenbank, die bereits strenge Benennungsvorschriften befolgt, kann der Ersteller jede von dem Motor verwendete Bezeichnung, Eigenschaft und Beziehungsart überschreiben.

// 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.");

Diese Flexibilität verhindert, dass das Speichersystem wie ein fremder Plugin wirkt, und es wird zu einem gut strukturierten Bestandteil der bestehenden Graphenarchitektur.

Fenstergröße

Das Laden einer Historie mit Tausenden von Nachrichten überschreitet die Token-Limite, erhöht die API-Kosten und verschlechtert die Ergebnisse. Der Parameter size im Konstruktor steuert ein schräg verlaufendes Fenster in der verknüpften Liste.

// 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.");

Manchmal ist ein vollständiges Bild erforderlich, wie beispielsweise eine Hintergrundanalyse, die eine monatelange Supportanfrage zusammenfasst. Eine Größe von 0 oder eine negative Zahl weist den Store an, die gesamte Liste bis zur ersten Interaktion durchzugehen.

// 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.");

In kleinen Anwendungen oder serverlosen Funktionen kann das Verwalten eines globalen Neo4j Driver-Beans unerwünscht sein. Eine abkürzende Methode für die Basisauthentifizierung im Builder verwendet stattdessen direkte Anmeldeinformationen.

// 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.");

Die einzige erforderliche Einstellung ist die Verbindung zum Neo4j Java Driver. Als Alternative erstellt Neo4jChatMemoryStore.builder().withBasicAuth(, , ) intern eine Driver-Instanz.

Da das Speichern im Speicher in Neo4j nativ möglich ist, können benutzerdefinierte Graphenalgorithmen oder Hintergrundaufgaben diese Knoten analysieren, in Gesprächen erwähnte Entitäten extrahieren und den Chatverlauf eines Benutzers mit dem Kern-Wissensgraphen verknüpfen, wodurch tief personalisierte KI-Erlebnisse ermöglicht werden.

Die Integration von LangChain4j und Neo4j hat sich bereits stark weiterentwickelt. Da die Modelle verbessert werden und Graph-Datenbanken die Vektoroperationen weiter optimieren, werden sicherlich weitere Änderungen und Funktionen folgen.