Neo4j y LangChain4j: Ingestores GraphRAG, filtrado y memoria de chat
Neo4j con LangChain4j para GraphRAG: descubrimiento de esquemas, construcción de grafos con LLM, DSL Cypher, filtrado en el índice, recuperación de relaciones padre-hijo y memoria de chat basada en grafos.
Fuentes: Mastering Neo4j & LangChain4j: GraphRAG, Persistent AI Memory and more
Más allá del almacén de vectores básico
Un simple almacén de vectores de Neo4j que funciona dentro de una aplicación en Java es solo el punto de entrada. Desde entonces, los colaboradores del repositorio comunitario LangChain4j han añadido funcionalidades que van mucho más allá de una simple búsqueda de similitud: construcción automática de grafos, filtrado de vectores optimizado, patrones estructurales GraphRAG y memoria de chat almacenada como un grafo. Las secciones siguientes tratan cada uno de estos aspectos con el código que utilizan las pruebas propias de la comunidad.
Todo aquí está orientado a la versión 1.0.0-beta19, y las versiones posteriores pueden cambiar algunos comportamientos o interfaces.
Las dependencias son las mismas que para una configuración básica, con el módulo Neo4j retriever y sus componentes asociados en el archivo 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>
Detección de esquema para Text-to-Cypher
Cuando un modelo escribe consultas a la base de datos directamente a partir del lenguaje natural (Text-to-Cypher), debe conocer la estructura exacta de la base de datos: qué nodos se conectan entre sí y qué propiedades tiene cada nodo. Sin ese conocimiento no puede generar consultas válidas y comienza a inventar cosas.
La clase Neo4jGraph oculta este problema de topología. El esquema no está codificado de forma fija en la aplicación Java, ya que el componente lo obtiene y lo formatea dinámicamente. La PR #119 aceleró significativamente este proceso. Anteriormente, recopilar nodos, propiedades y relaciones podía requerir varias consultas al banco de datos. La PR integró la extracción del esquema en una única consulta Cypher optimizada, basada en el procedimiento interno apoc.meta.data de Neo4j. También añadió un tamaño de muestreo configurable y un esquema mejorado que incluye valores de ejemplo, lo que proporciona al modelo un contexto más preciso.
Tomemos este conjunto de datos como ejemplo:
La exposición de su esquema al modelo se ve así:
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);
En un gráfico empresarial enorme con miles de millones de nodos, extraer metadatos puede resultar costoso si la base de datos intenta escanear todo. Las opciones sample y maxRels, vinculadas directamente a apoc.meta.data, ayudan a controlar esto. La opción enhanced-schema agrega valores de ejemplo de las propiedades para que el modelo pueda ver cómo son realmente los datos.
// 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.");
Al obtener los metadatos en una sola consulta y al exponer la muestra, un pipeline de Text-to-Cypher se ajusta automáticamente a los cambios en la base de datos, y la extracción sigue siendo rápida incluso con conjuntos de datos masivos en producción.
Construir un grafo a partir de texto
Muchas bases de datos ya contienen campos de texto extensos, y convertirlos en un grafo de conocimiento es la base de enfoques avanzados de GraphRAG como la resumen centrado en consultas y el clustering temático. Antes, esto implicaba mantener pipelines frágiles de reconocimiento de entidades nombradas basados en reglas. LLMGraphTransformer deja esa tarea en manos del LLM.
La clase es independiente de las bases de datos, ya que convierte el texto en nodos y aristas que también sirven para otros usos. Para evitar que el modelo fabrique información falsa o fragmente la ontología, por ejemplo inventando etiquetas inesperadas, los desarrolladores orientan la extracción mediante ejemplos concretos (prompting de pocos ejemplos) y esquemas definidos con precisión. La comunidad también añadió una forma nativa de vincular las entidades extraídas al documento de origen del cual provienen, lo que mantiene los metadatos originales.
La salida en JSON debe seguir una estructura fija, con claves head, head_type, relation, tail y tail_type, y los ejemplos de prompt enseñan al modelo esa estructura. Aquí hay un fragmento del JSON de ejemplo proporcionado al 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"
}
]
Con esos ejemplos en contexto, el transformador puede procesar documentos de entrada reales que contienen texto y metadatos, como "Keanu Reeves actuó en Matrix", que incluye el metadato 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
*/
Una vez que los objetos GraphDocument existen en memoria, es necesario guardarlos. En entornos de producción, el mismo documento puede ser procesado más de una vez, por lo que las operaciones de escritura deben evitar duplicados, como dos nodos separados de “Keanu Reeves”. KnowledgeGraphWriter se encarga de eso.
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 datos termina viéndose así:
Manteniendo el origen
En GraphRAG avanzado, es útil saber con exactitud qué fragmento de texto generó una relación determinada. Al establecer la marca includeSource se guardan las entidades y también se crea un nodo Document que contiene el texto original y los metadatos, vinculado a las entidades extraídas.
// 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.");
Los entornos empresariales no pueden depender de etiquetas genéricas como __Entity__ o HAS_ENTITY, y los nombres de las propiedades que provienen de entradas externas abren la puerta a inyecciones de Cypher. El autor sanitiza cada entrada mediante SchemaNames.sanitize, y los desarrolladores pueden reescribir toda la ontología, incluidos los nombres de las restricciones de unicidad creadas en la base de datos.
// 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'})
Esa configuración genera una topología personalizada, mostrada aquí con las entidades vinculadas a su fuente:
Al configurarse de esta manera, la extracción de grafos impulsada por LLMs se automatiza, es rastreable gracias a los enlaces a la fuente, es idempotente y segura para ejecutarse en pipelines de datos estructurados.
Consultas más seguras mediante Cypher DSL
Un cambio arquitectónico importante en el repositorio de la comunidad es la integración profunda de la biblioteca Cypher DSL. Los primeros sistemas GraphRAG solían crear consultas concatenando cadenas de texto sin procesar, lo cual se desaconseja debido al riesgo de inyección de código Cypher y a errores de sintaxis. Los componentes internos de LangChain4j ahora generan consultas de forma programática como un Árbol de Sintaxis Abstracta (AST). La mayor parte de este proceso ocurre de forma invisible, pero representa un gran avance para la seguridad de las aplicaciones.
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);
El motor DSL comprende la gramática de Cypher, por lo que su API no puede generar consultas sintácticamente inválidas. Eso proporciona una red de seguridad fiable para los componentes internos de la integración.
Prefiltrado en el índice
La búsqueda vectorial de Neo4j ha sido optimizada significativamente, y la integración LangChain4j ahora admite la sintaxis de búsqueda vectorial más reciente, incluidas las optimizaciones de 2026.01. La principal ventaja es el filtrado dentro del índice.
Realice una búsqueda de similitud junto con una restricción de metadatos, como documentos semánticamente cercanos a “machine learning” pero publicados únicamente en 2024. Los enfoques anteriores de bases de datos vectoriales obtienen primero los vectores más similares y luego descartan aquellos del año incorrecto. Ese post-filtrado desperdicia esfuerzos y, a menudo, devuelve menos documentos de los solicitados, lo que afecta la precisión de la recuperación. El filtrado en el índice aplica las restricciones de metadatos durante el recorrido del índice vectorial HNSW (Hierarchical Navigable Small World). La documentación de Neo4j ofrece más detalles sobre la sintaxis y las mejoras en rendimiento que ofrece.
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
Patrones de recuperación de GraphRAG
La búsqueda vectorial maneja bien la similitud semántica, pero falla en preguntas analíticas, como las agregaciones o los recorridos de múltiples pasos, como “¿Quiénes son los colegas de los ingenieros que trabajan en el Proyecto X?”. Los vectores no pueden contar, sumar ni recorrer de manera fiable jerarquías estructuradas. El concepto GraphRAG está dirigido a esa intención analítica, y graphrag.com ofrece una introducción al tema.
El particionamiento en fragmentos plantea un dilema común en RAG. Los fragmentos pequeños, de unos 200 tokens, proporcionan resultados vectoriales precisos, pero carecen de contexto circundante, lo que afecta negativamente la generación de respuestas. Los fragmentos grandes, de unos 2000 tokens, ofrecen al modelo grande de lenguaje mucho contexto, pero reducen significativamente la precisión de la búsqueda. Un conjunto de herramientas de ingestión y recuperación estructuradas resuelve este problema aprovechando la naturaleza bipartita del grafo, que separa la unidad buscable de la unidad recuperable.
Padre-hijo
Los documentos se dividen en pequeños fragmentos “hijos” que se utilizan únicamente para búsquedas vectoriales, y cada uno está vinculado mediante una relación al documento completo “padre”. Las representaciones vectoriales se calculan sobre unidades pequeñas y semanticamente coherentes, lo que mejora la calidad de la recuperación. Para dividir un documento grande, incorpore los fragmentos y átelos a un nodo padre en Neo4j, utilizando 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.");
Después de la ingestión, retrieval busca los pequeños fragmentos y entrega el contenido completo al LLM a través de 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
}
}
*/
Resúmenes como unidad devuelta
Devolver todo el elemento padre puede consumir demasiados tokens. Una opción más eficiente consiste en generar un resumen del padre durante la ingestión y devolver ese resumen cuando haya un fragmento hijo que coincida. SummaryGraphIngestor hace que el LLM escriba el resumen, lo almacene como un nodo independiente y lo vincule a los fragmentos buscables:
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 se encarga de la recuperación, de modo que un resultado vectorial en un bloque genere el resumen precalculado en lugar del texto en bruto:
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
}
}
*/
Preguntas hipotéticas
Los embeddings son sensibles a la redacción. Una pregunta breve como “¿Quién es John Doe?” se parece estructuralmente a un párrafo extenso sobre John Doe, por lo que las puntuaciones de similitud disminuyen y la capacidad de recuperación empeora. En el patrón de preguntas hipotéticas, el LLM escribe, en el momento de la ingestión, las preguntas a las que responde cada fragmento. El sistema incrusta esas preguntas y no el texto original, con enlaces hacia el documento principal. La consulta del usuario se compara entonces con las preguntas incrustadas, lo que aumenta considerablemente la precisión.
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
}
}
*/
Cifra personalizada
A veces, las topologías de tipo padre-hijo, resumen e hipotéticas no se ajustan a un modelo de datos empresarial específico, y es necesario especificar con exactitud qué Cypher se ejecutará durante la ingesta y la recuperación de datos.
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);
Aunque este material se centra en las fortalezas nativas de grafos de Neo4j, la comunidad LangChain4j ha generalizado estos patrones estructurales. Los ingestores mencionados anteriormente dependen de ParentChildEmbeddingStoreIngestor en su base, por lo que la recuperación genérica de relaciones padre-hijo funciona con cualquier almacén de embeddings de LangChain4j que maneje correctamente los metadatos. De este modo, algunas bases de datos vectoriales simples también adquieren capacidades básicas de tipo RAG basadas en grafos.
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();
Memoria de chat en el grafo
Los agentes de conversación necesitan mantener un estado a lo largo de las interacciones, y una IA sin memoria es una función sin estado. Los almacenes en memoria de LangChain4j son efímeros y no adecuados para entornos de producción donde las aplicaciones se reinician y se escalan horizontalmente. En su lugar, Neo4jChatMemoryStore persiste el historial de chat dentro de Neo4j. No se trata solo de un conjunto de texto, sino de una secuencia vinculada de nodos, esencialmente una lista enlazada en formato de grafo, lo que permite tener un registro persistente y consultable de la interacción del usuario.
Por defecto, el almacén crea un nodo raíz Memory identificado por una propiedad id. De él parten una cadena de nodos Message a través de las relaciones LAST_MESSAGE y NEXT. El generador necesita ya sea una configuración de sesión de Neo4j o una instancia existente de Driver, y ofrece estas opciones de configuración:
- la etiqueta del nodo de memoria (por defecto
Memory) y la etiqueta del nodo de mensaje (por defectoMessage); - la clave de la propiedad ID de la memoria (por defecto
id) que identifica la sesión de chat, y la clave de la propiedad texto del mensaje (por defectomessage); - el tipo de relación último-mensaje (por defecto
LAST_MESSAGE) y el tipo de relación siguiente-mensaje (por defectoNEXT); - el nombre de la base de datos Neo4j (por defecto
neo4j); - el tamaño de recuperación de mensajes (por defecto
10), que establece el límite de la ventana de contexto.
Para mantener separadas las sesiones de varios usuarios, el almacén las isola mediante un memoryId único. Los LLM modernos aceptan entrada multimodal, y el almacén serializa y guarda contenido complejo como imágenes, eliminándolas también posteriormente.
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);
El resultado almacenado se ve así:
Las bases de datos empresariales rara vez aceptan etiquetas genéricas como Memory o Message. En el caso de una base de datos que ya sigue reglas estrictas de nombramiento, el generador puede sobrescribir cada etiqueta, propiedad y tipo de relación que utiliza el motor.
// 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.");
Esa flexibilidad evita que la memoria parezca un complemento externo, y se convierte en una parte bien integrada de la arquitectura de grafos existente.
Tamaño de la ventana
Cargar un historial de miles de mensajes supera el límite de tokens, aumenta los costos de la API y empeora la calidad de las respuestas. El parámetro size en el constructor controla una ventana deslizante sobre la lista enlazada.
// 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.");
A veces se necesita ver la imagen completa, como en un trabajo de análisis de fondo que resume un ticket de soporte de un mes. Un tamaño de 0 o un número negativo indica a la tienda que recorra toda la lista hasta la primera interacción.
// 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.");
En aplicaciones pequeñas o funciones serverless, gestionar un bean global de Driver de Neo4j puede resultar innecesario. Un método abreviado de autenticación básica en el constructor acepta directamente las credenciales.
// 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.");
El único parámetro necesario es la conexión del controlador Java de Neo4j. Como alternativa, Neo4jChatMemoryStore.builder().withBasicAuth(, , ) crea una instancia de Driver internamente.
Dado que la memoria está integrada de forma nativa en Neo4j, los algoritmos de gráfico personalizados o las tareas en segundo plano pueden analizar estos nodos, extraer entidades mencionadas en las conversaciones y vincular el historial de chat de un usuario con el gráfico de conocimiento principal, lo que permite experiencias de IA profundamente personalizadas.
La integración entre LangChain4j y Neo4j ya ha madurado mucho. A medida que los modelos mejoran y las bases de datos de gráfico siguen optimizando las operaciones vectoriales, seguramente habrá más cambios y características.
Leer más
- Un pipeline GraphRAG completamente local con LangExtract, Neo4j, Qdrant y Ollama — Construir GraphRAG con modelos locales: extraer entidades con LangExtract, almacenarlas en Neo4j, indexarlas en Qdrant y luego expandir subgrafos para obtener respuestas basadas en grafos.
- Notas prácticas: LLMs locales para la extracción Graph RAG: El re-benchmark de mediados de 2026 — Guía paso a paso de las Notas prácticas: LLMs locales para la extracción Graph RAG: El re-benchmark de mediados de 2026: contratos, verificaciones y espacios de código listos para usar para los equipos que implementan este patrón.