Главная / Статьи

Neo4j и LangChain4j: инструменты GraphRAG для ввода данных, фильтрации и хранения чат-истории

Neo4j с LangChain4j для GraphRAG: обнаружение схемы, построение графов с использованием LLM, язык Cypher DSL, фильтрация во время индексации, поиск связей родитель-дочерний и память для чатов, основанная на графах.

Издатель: REACTAPP.TOP18 мин чтения4156 слов

Источники: Mastering Neo4j & LangChain4j: GraphRAG, Persistent AI Memory and more

Что есть за пределами базового хранилища векторов

Простое хранилище векторов Neo4j, работающее внутри Java-приложения, является лишь точкой входа. Участники репозитория сообщества LangChain4j добавили функции, выходящие далеко за рамки простого поиска по сходству: автоматическое построение графов, настроенная фильтрация векторов, структурные шаблоны GraphRAG и память чата, хранящаяся в виде графа. В следующих разделах рассматриваются каждый из этих аспектов с примерами кода, используемыми в тестах сообщества.

Все приведенное здесь относится к версии 1.0.0-beta19; более поздние версии могут изменить некоторые функции или интерфейсы.

Зависимости такие же, как и для базовой настройки: модуль Neo4j retriever и связанные с ним компоненты указаны в файле 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>

Открытие схемы для функции «Текст в шифр»

Когда модель формирует запросы к базе данных непосредственно из естественного языка (функция «Текст в шифр»), ей необходимо знать точную структуру базы данных: какие узлы связаны друг с другом и какие свойства имеет каждый узел. Без этой информации она не может создавать корректные запросы и начинает выдумывать их.

Класс Neo4jGraph скрывает эту проблему топологии. Схема не закодирована жестко в Java-приложении, поскольку компонент загружает и форматирует её динамически. Предложение PR #119 значительно ускорило этот процесс. Ранее сбор узлов, свойств и связей мог занять несколько запросов к базе данных. В рамках этого предложения извлечение схемы было объединено в один оптимизированный запрос Cypher на основе внутренней процедуры Neo4j apoc.meta.data. Также был добавлен настраиваемый размер выборки и улучшенная схема, включающая примерные значения, что обеспечивает модели лучший контекст.

Возьмём в пример этот набор данных:

Отображение его схемы для модели выглядит следующим образом:

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

В огромной корпоративной сети с миллиардами узлов извлечение метаданных может стать дорогостоящим, если база данных пытается просканировать всё. Параметры sample и maxRels, напрямую связанные с apoc.meta.data, помогают контролировать этот процесс. Опция enhanced-schema добавляет примеры значений свойств, чтобы модель могла увидеть, как на самом деле выглядят данные.

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

Благодаря загрузке метаданных за один запрос и возможности выборки данных, пайплайн преобразования текста в шифр автоматически адаптируется к изменениям в базе данных, обеспечивая высокую скорость обработки даже при работе с огромными наборами данных в производственных условиях.

Создание графа из текста

Во многих базах данных уже существуют большие текстовые поля, и преобразование их в граф знаний лежит в основе современных подходов GraphRAG, таких как подведомственное к запросам краткое изложение и кластеризация по темам. Ранее это требовало использования хрупких пайплайнов распознавания именованных сущностей, основанных на правилах. LLMGraphTransformer передаёт эту задачу на обработку большой языковой модели.

Этот класс не зависит от конкретной базы данных, поскольку преобразует текст в узлы и рёбра, которые могут использоваться и в других целях. Чтобы предотвратить галлюцинации модели или фрагментацию онтологии, например из-за появления неожиданных меток, разработчики направляют процесс извлечения с помощью конкретных примеров (подсказок с небольшим количеством примеров) и чётко определённых схем. Комьюнити также добавил встроенный способ связывать извлечённые сущности обратно с исходным документом, откуда они взялись, что позволяет сохранить первоначальные метаданные.

Результат в формате JSON должен соответствовать фиксированной структуре с ключами head, head_type, relation, tail и tail_type; примеры подсказок помогают модели придерживаться этой структуры. Вот фрагмент примера JSON, предоставляемого ЯИИ:

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

С учётом этих примеров в контексте трансформер может обрабатывать реальные входные документы, содержащие текст и метаданные, такие как "Киану Ривз снимался в «Матрице», в которых присутствуют метаданные 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
*/

Как только объекты GraphDocument появляются в памяти, их необходимо сохранить. В реальных условиях один и тот же документ может обрабатываться несколько раз, поэтому при записи следует избегать дубликатов, таких как два отдельных узла с названием «Keanu Reeves». KnowledgeGraphWriter занимается этим.

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

База данных в итоге выглядит так:

Сохранение происхождения

Для продвинутого использования GraphRAG полезно знать точно, какой фрагмент текста породил определённую связь. Установка флага includeSource позволяет сохранить сущности, а также создаёт узел Document, содержащий исходный текст и метаданные, связанные с извлечёнными сущностями.

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

В корпоративных средах нельзя полагаться на общие метки вроде __Entity__ или HAS_ENTITY, а имена свойств, получаемые из внешних источников, создают риск внедрения кода типа Cypher. Автор обрабатывает каждый входящий элемент с помощью метода SchemaNames.sanitize, а разработчики могут переписать всю онтологию, включая имена ограничений уникальности, созданных в базе данных.

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

Такая конфигурация создаёт пользовательскую топологию, показанную здесь с элементами, связанными с их исходными источниками:

При такой настройке извлечение графа с использованием LLM автоматизируется, становится отслеживаемым благодаря ссылкам на источники, идемпотентным и безопасным для использования в пайплайнах структурированных данных.

Более безопасные запросы с помощью Cypher DSL

Одним из важных архитектурных изменений в репозитории сообщества является глубокая интеграция библиотеки Cypher DSL. Ранние системы GraphRAG часто формировали запросы путём объединения необработанных строк, что не рекомендуется из-за риска внедрения кода через Cypher и синтаксических ошибок. Внутренние компоненты LangChain4j теперь формируют запросы программно в виде абстрактной синтаксической структуры (AST). Большая часть этих процессов происходит незаметно для пользователя, но это значительный шаг в плане безопасности приложений.

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

Движок DSL понимает грамматику Cypher, поэтому его API не может генерировать синтаксически неверные запросы. Это обеспечивает надежную защиту внутренних механизмов интеграции.

Предфильтрация внутри индекса

Векторный поиск в Neo4j был значительно оптимизирован, и интеграция LangChain4j теперь поддерживает новую синтаксис векторного поиска, включая оптимизации версии 2026.01. Основное преимущество заключается в фильтрации внутри индекса.

Используйте поиск по сходству в сочетании с ограничениями по метаданным, например, документы, семантически близкие к термину «машинное обучение», но опубликованные только в 2024 году. Традиционные подходы к векторным базам данных сначала извлекают наиболее похожие векторы, а затем удаляют те, которые относятся к неверному году. Такая постфильтрация тратит ресурсы и часто возвращает меньше документов, чем требуется, что снижает точность поиска. Фильтрация в процессе индексации применяет ограничения по метаданным во время просмотра векторного индекса HNSW (Иерархический навигируемый малый мир). В документации Neo4j приведена дополнительная информация о синтаксисе и преимуществах такого подхода с точки зрения производительности.

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

Векторный поиск хорошо справляется с определением семантической похожести, но терпит неудачу при решении аналитических задач, таких как агрегация данных или многократные переходы по структурам, например, вопрос «Кто является коллегами инженеров, работающих над проектом X?». Векторы не могут считать, суммировать данные или надёжно перемещаться по структурированным иерархиям. Концепция GraphRAG направлена именно на решение таких аналитических задач, и на сайте graphrag.com представлено введение в неё.

Разбиение текста на фрагменты создаёт характерную дилемму в подходе RAG. Небольшие фрагменты объёмом около 200 токенов обеспечивают точные результаты поиска по векторам, но лишены контекста вокруг, из-за чего качество генерируемого текста снижается. Большие фрагменты объёмом около 2000 токенов дают большой объём контекста для ИИ, но существенно снижают точность поиска. Набор инструментов для обработки и поиска данных решает эту проблему, используя двудольную структуру графа, которая разделяет единицы, подлежащие поиску, и единицы, которые можно извлечь.

Родитель-дочерние связи

Документы разделяются на небольшие «дочерние» фрагменты, используемые исключительно для векторного поиска, причем каждый из них связан с полным «родительским» документом с помощью соответствующей связи. Вкладки вычисляются для небольших, семантически целостных единиц, что повышает качество поиска. Чтобы разделить большой документ, необходимо вкладить его фрагменты в Neo4j и привязать их к родительскому узлу с использованием 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.");

После загрузки модуль поиска проходит по небольшим фрагментам и передает полный исходный текст в LLM через 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
  }
}
*/

Резюме в качестве возвращаемой единицы

Возвращение всего родительского элемента может потребовать слишком большого количества токенов. Более эффективный вариант — генерация резюме родительского элемента во время обработки и его возвращение при совпадении дочернего фрагмента. Класс SummaryGraphIngestor заставляет большую языковую модель написать резюме, сохраняет его в виде отдельного узла и связывает с поисковыми фрагментами:

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 выполняет поиск, так что при нахождении вектора в определённом фрагменте вместо сырого текста возвращается заранее вычисленное резюме:

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

Гипотетические вопросы

Эмбеддинги чувствительны к формулировке. Короткий вопрос вроде «Кто такой Джон Доу?» по структуре сильно отличается от длинного абзаца о Джоне Доу, поэтому показатели сходства снижаются, а точность воспроизведения ухудшается. Для гипотетических вопросов ИИ при обработке данных пишет вопросы, на которые должен ответить каждый фрагмент текста. Система генерирует эмбеддинги для этих вопросов, а не для самих текстов, добавляя при этом ссылки на исходный документ. Затем запрос пользователя сопоставляется с эмбеддингами вопросов, что значительно повышает точность результата.

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

Иногда структуры типа «родитель-дочерний», сводные и гипотетические топологии не соответствуют конкретной корпоративной модели данных, и необходимо четко определить, какой именно Cypher будет использоваться во время ввода и извлечения данных.

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

Хотя данный материал посвящён преимуществам нативных графовых функций Neo4j, сообщество LangChain4j обобщило эти структурные паттерны. Упомянутые выше механизмы ввода данных основаны на ParentChildEmbeddingStoreIngestor, поэтому универсальный механизм поиска родительских и дочерних элементов работает с любым хранилищем эмбеддингов LangChain4j, которое правильно обрабатывает метаданные. Таким образом, некоторые простые векторные базы данных также получают базовые возможности поиска в стиле графов RAG.

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

Память чата в графе

Агентам для ведения диалогов необходимо сохранять состояние между взаимодействиями, а ИИ без памяти представляет собой функцию без состояния. Хранилища в памяти LangChain4j являются временными и не подходят для производственной среды, где приложения перезагружаются и масштабируются горизонтально. Вместо этого Neo4jChatMemoryStore сохраняет историю чата внутри Neo4j. Это не просто набор текстовых данных — это связанная последовательность узлов, фактически списк с ссылками в формате графа, что обеспечивает постоянное хранение и возможность поиска информации об взаимодействии пользователя.

По умолчанию хранилище создаёт корневой узел Memory, идентифицируемый свойством id. От него через связи LAST_MESSAGE и NEXT отходит цепочка узлов Message. Для работы с ним требуется либо конфигурация сессии Neo4j, либо существующий экземпляр драйвера, причём доступны следующие опциональные настройки:

  • метка узла памяти (по умолчанию Memory) и метка узла сообщения (по умолчанию Message);
  • ключ свойства идентификатора памяти (по умолчанию id), который идентифицирует сессию чата, и ключ свойства текста сообщения (по умолчанию message);
  • тип связи последнего сообщения (по умолчанию LAST_MESSAGE) и тип связи следующего сообщения (по умолчанию NEXT);
  • имя базы данных Neo4j (по умолчанию neo4j);
  • размер получаемых сообщений (по умолчанию 10), который определяет предел окна контекста.

Чтобы разделить сессии нескольких пользователей, хранилище изолирует их с помощью уникального значения memoryId. Современные нейронные сети большого размера принимают мультимодальные данные, и хранилище сериализует и сохраняет сложный контент, такой как изображения, а затем удаляет их.

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

Результат хранения выглядит следующим образом:

Корпоративные базы данных редко принимают общие метки вроде Memory или Message. В случае базы данных, уже соблюдающей строгие правила именования, инструмент создания позволяет заменить любые метки, свойства и типы связей, используемые движком.

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

Такая гибкость позволяет избежать ощущения, что память представляет собой чужеродный плагин, и превращает её в хорошо интегрированную часть существующей архитектуры графа.

Размер окна

Загрузка истории тысяч сообщений превышает лимит токенов, увеличивает стоимость API и снижает качество ответов. Параметр size в конструкторе контролирует «скользящее окно» в списке с ссылками.

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

Иногда требуется полная картина, например, аналитическая задача на фоне, обобщающая информацию о тикетах поддержки за месяц. Значение 0 или отрицательное число указывают системе просмотреть весь список начиная с первого взаимодействия.

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

В небольших приложениях или функциях без сервера управление глобальным объектом Driver Neo4j может оказаться нежелательным. Вместо этого метод базовой аутентификации, предусмотренный в конструкторе, принимает прямые учетные данные.

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

Единственным обязательным параметром является подключение к драйверу Neo4j для Java. В качестве альтернативы Neo4jChatMemoryStore.builder().withBasicAuth(, , ) создает внутренний экземпляр драйвера.

Поскольку память встроена нативно в Neo4j, пользовательские алгоритмы обработки графов или фоновые задачи могут анализировать эти узлы, извлекать сущности, упомянутые в разговорах, и связывать историю чатов пользователя с основной графовой базой знаний, что позволяет создавать высоко персонализированные ИИ-решения.

Интеграция LangChain4j и Neo4j уже сильно созрела. По мере улучшения моделей и дальнейшей оптимизации векторных операций в графовых базах данных неизбежно появятся новые изменения и функции.

Связанные материалы