Strona główna / Artykuły

Neo4j i LangChain4j: importerzy GraphRAG, filtrowanie i pamięć czatu

Neo4j z LangChain4j do GraphRAG: odkrywanie schematu, budowanie grafów za pomocą LLM, Cypher DSL, filtrowanie w trakcie indeksowania, wyszukiwanie elementów rodzicowsko-dziecięcych oraz pamięć do rozmów oparta na grafach.

Wydawca: REACTAPP.TOP18 min czytania4156 słów

Źródła: Mastering Neo4j & LangChain4j: GraphRAG, Persistent AI Memory and more

Poza podstawowym magazynem wektorowym

Zwykły magazyn wektorowy Neo4j działający wewnątrz aplikacji Java to jedynie punkt wejścia. Uczestnicy repozytorium społeczności LangChain4j dodali od tego czasu funkcje wykraczające daleko poza prosty wyszukiwacz podobieństw: automatyczną konstrukcję grafów, zoptymalizowane filtrowanie wektorów, strukturalne wzory GraphRAG oraz pamięć rozmów przechowywaną jako graf. Poniższe sekcje omawiają każdą z nich wraz z kodem używanym w testach przeprowadzanych przez społeczność.

Wszystko tutaj dotyczy wersji 1.0.0-beta19, a późniejsze wydania mogą zmienić niektóre zachowania lub interfejsy.

Zależności są takie same jak przy podstawowym ustawieniu, z modułem Neo4j retriever oraz jego powiązanymi elementami w pliku 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>

Odkrywanie schematu w funkcji Text-to-Cypher

Gdy model tworzy zapytania do bazy danych bezpośrednio z języka naturalnego (Text-to-Cypher), musi znać dokładną strukturę bazy danych: które węzły są połączone ze sobą oraz jakie właściwości posiada każdy węzeł. Bez tej wiedzy nie może tworzyć poprawnych zapytań i zaczyna wymyślać je na poczekaniu.

Klasa Neo4jGraph ukrywa ten problem topologii. Schemat nie jest zakodowany sztywno w aplikacji Java, ponieważ komponent pobiera go i formatuje dynamicznie. PR #119 znacznie przyspieszył ten proces. Wcześniej zbieranie węzłów, właściwości i relacji mogło wymagać kilku wyjść i powrotów do bazy danych. PR połączył wyodrębnianie schematu w jedno zoptymalizowane zapytanie Cypher oparte na wewnętrznej procedurze Neo4j apoc.meta.data. Dodano również konfigurowalną wielkość próby oraz ulepszony schemat zawierający wartości przykładowe, co zapewnia modelowi lepszy kontekst.

Weźmy jako przykład ten zestaw danych:

Ujawnienie jego schematu modelowi wygląda w ten sposób:

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

W ogromnym grafie przedsiębiorstwa z miliardami węzłów wydobywanie metadanych może być kosztowne, jeśli baza danych próbuje przeskanować wszystko. Ustawienia sample i maxRels, powiązane bezpośrednio z apoc.meta.data, pomagają kontrolować ten proces. Opcja enhanced-schema dodaje wartości właściwości przykładowych, dzięki czemu model może zobaczyć, jak faktycznie wyglądają dane.

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

Dzięki pobieraniu metadanych w jednej zapytaniu oraz ujawnianiu próbek, pipeline Text-to-Cypher sam dostosowuje się do zmian w bazie danych, a proces wydobywania informacji pozostaje szybki nawet przy ogromnych zbiorach danych produkcyjnych.

Budowanie grafu z tekstu

Wiele baz danych już zawiera duże pola tekstowe, a przekształcenie ich w graf wiedzy stanowi podstawę zaawansowanych podejść GraphRAG, takich jak podsumowywanie skupione na zapytaniach i klasterowanie tematyczne. W przeszłości oznaczało to konieczność utrzymywania kruchych, opartych na regułach pipeline do rozpoznawania nazwanych entytetów. LLMGraphTransformer przekazuje to zadanie modelowi językowemu.

Klasa ta jest niezależna od bazy danych, ponieważ przekształca tekst w węzły i krawędzie, które mogą być wykorzystywane również do innych celów. Aby zapobiec tworzeniu się fałszywych informacji lub fragmentacji ontologii, na przykład poprzez wymyślanie nieoczekiwanych etykiet, programiści kierują procesem wydobywania danych za pomocą konkretnych przykładów (prompting typu few-shot) oraz precyzyjnie zdefiniowanych schematów. Społeczność dodała również wbudowany sposób na powiązanie wydobytych entytetów z dokumentem źródłowym, z którego pochodzą, co pozwala zachować oryginalne metadane.

Wyjście w formacie JSON musi spełniać określoną strukturę, zawierającą klucze head, head_type, relation, tail oraz tail_type, a przykładowe prompty uczą model takiemu kształtowi. Oto fragment przykładowego JSON przekazywanego modelowi 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"
   }
]

W kontekście tych przykładów model typu transformer może przetwarzać rzeczywiste dokumenty wejściowe zawierające tekst i metadane, takie jak "Keanu Reeves grał w Matrix", które niosą metadane 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
*/

Gdy obiekty GraphDocument znajdują się już w pamięci, należy je zapisać. W środowisku produkcyjnym ten sam dokument może być przetwarzany więcej niż raz, dlatego operacje zapisu muszą unikać duplikatów, takich jak dwa oddzielne węzły o nazwie „Keanu Reeves”. KnowledgeGraphWriter zajmuje się tym problemem.

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

Baza danych wygląda w końcu w ten sposób:

Zachowanie informacji o pochodzeniu

W zaawansowanym GraphRAG przydatne jest dokładne określenie, który fragment tekstu spowodował dany związek. Ustawienie flagi includeSource pozwala zachować entity oraz tworzy węzeł Document zawierający oryginalny tekst i metadane, powiązany z wyekstrahowanymi entity.

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

Środowiska korporacyjne nie mogą polegać na ogólnych etykietach takich jak __Entity__ lub HAS_ENTITY, a nazwy właściwości pochodzące z zewnętrznych danych otwierają drogę do iniekcji Cypher. Autor sanityzuje każdy wprowadzony danych za pomocą SchemaNames.sanitize, a programiści mogą przepisać całą ontologię, włączając nazwy ograniczeń unikalności utworzonych w bazie danych.

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

Taka konfiguracja tworzy spersonalizowaną topologię, pokazaną tutaj z elementami powiązanymi z ich źródłem:

W ten sposób wydobywanie grafów przy użyciu modeli LLM staje się zautomatyzowane, możliwe do śledzenia dzięki linkom do źródeł, jest idempotentne i bezpieczne do wykonywania w pipeline’ach danych strukturalnych.

Bardziej bezpieczne zapytania dzięki Cypher DSL

Jedną z ważnych zmian architektonicznych w repozytorium społecznościowym jest głęboka integracja biblioteki Cypher DSL. Wczesne systemy GraphRAG często budowały zapytania poprzez łączenie surowych ciągów znaków, co jest niezalecane ze względu na ryzyko iniekcji kodu w Cypherze oraz błędy składniowe. Wewnętrzne komponenty LangChain4j teraz budują zapytania programowo w postaci drzewa składni abstrakcyjnego (AST). Większość tych operacji odbywa się poza zasięgiem użytkownika, ale stanowi to duży krok w kierunku zwiększenia bezpieczeństwa aplikacji.

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

Motor DSL rozumie gramatykę Cyphera, więc jego API nie może wygenerować zapytania niespójnego pod względem składni. Dzięki temu wewnętrzne mechanizmy integracji mają niezawodną ochronę.

Przedfiltrowywanie w indeksie

Wyszukiwanie wektorowe w Neo4j zostało znacznie zoptymalizowane, a integracja LangChain4j teraz obsługuje nowszą składnię wyszukiwania wektorowego, włączając optymalizacje z wersji 2026.01. Główną zaletą jest filtrowanie w indeksie.

Wykorzystaj wyszukiwanie podobieństw w połączeniu z ograniczeniami metadanych, takimi jak dokumenty semantycznie bliskie tematowi „machine learning”, opublikowane jednak wyłącznie w 2024 roku. Starsze podejścia baz danych wektorowych najpierw pobierają najbardziej podobne wektory, a następnie odrzucają te z niewłaściwego roku. Takie filtrowanie po fakcie marnuje zasoby i często zwraca mniej dokumentów, niż zostało poproszono, co negatywnie wpływa na dokładność wyszukiwania. Filtrowanie w trakcie indeksowania stosuje ograniczenia metadanych w trakcie przeglądania indeksu wektorowego HNSW (Hierarchical Navigable Small World). Dokumentacja Neo4j zawiera dodatkowe informacje na temat składni oraz korzyści wynikających z jej zastosowania pod względem wydajności.

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

Wzorce wyszukiwania w GraphRAG

Wyszukiwanie wektorowe dobrze radzi sobie z podobieństwem semantycznym, ale zawodzi przy pytaniach analitycznych, takich jak agregacje lub wieloetapowe przeszukiwania, np. „Kto są kolegami inżynierów pracujących nad Projektem X?”. Wektory nie potrafią liczyć, sumować ani wiarygodnie przemieszczać się po zstrukturyzowanych hierarchiach. Koncepcja GraphRAG jest skierowana właśnie na takie potrzeby analityczne, a na stronie graphrag.com znajduje się wprowadzenie do niej.

Podział tekstu na fragmenty stwarza typowe dylematy w technologii RAG. Małe fragmenty liczące około 200 tokenów zapewniają dokładne wyniki wyszukiwania wektorowego, ale brakuje im otoczenia kontekstowego, co negatywnie wpływa na generowanie odpowiedzi. Duże fragmenty liczące około 2000 tokenów dostarczają modelowi dużo kontekstu, ale znacznie obniżają precyzję wyszukiwania. Zestaw narzędzi do przetwarzania i wyszukiwania danych rozwiązuje ten problem, wykorzystując dwudzielną naturę grafu, która oddziela jednostkę do wyszukiwania od jednostki do odzyskiwania.

Relacja rodzic-dziecko

Dokumenty są dzielone na małe „potomne” fragmenty używane wyłącznie do wyszukiwania wektorowego, a każdy z nich jest powiązany relacją z pełnym „rodzicielskim” dokumentem. Wkładki są obliczane na małych, semantycznie spójnych jednostkach, co poprawia jakość wyszukiwania. Aby podzielić duży dokument, należy wstawić te fragmenty i przymocować je do węzła rodzicielskiego w Neo4j, używając 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.");

Po przyjęciu danych, mechanizm odzyskiwania przeszukuje małe fragmenty i przekazuje pełny tekst rodzicielski do modelu LLM za pośrednictwem 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
  }
}
*/

Streszczenia jako zwracana jednostka

Zwracanie całego elementu nadrzędnego może wymagać zbyt wielu tokenów. Lepszą opcją jest wygenerowanie streszczenia elementu nadrzędnego podczas przetwarzania i zwrócenie tego streszczenia, gdy zostanie znaleziony odpowiadający mu fragment potomny. SummaryGraphIngestor sprawia, że model LLM pisze streszczenie, przechowuje je jako osobny węzeł i łączy je z fragmentami dostępnymi do wyszukiwania:

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 odpowiada za wyszukiwanie, tak że trafienie wektorowe w danym fragmencie zwraca z góry obliczony streszczenie zamiast surowego tekstu:

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

Pytania hipotetyczne

Embeddingi są wrażliwe na sformułowanie. Krótkie pytanie takie jak „Kto to jest John Doe?” strukturalnie różni się od rozbudowanego akapitu o John Doe, więc wyniki podobieństwa spadają, a precyzja odpowiadania jest gorsza. W przypadku pytań hipotetycznych model LLM przy przetwarzaniu zapisuje pytania, na które odpowiada każdy fragment tekstu. System tworzy embeddingi z tych pytań, a nie z surowego tekstu, dodając jednocześnie linki do dokumentu źródłowego. Zapytanie użytkownika jest następnie porównywane z tymi embeddingowanymi pytaniami, co znacznie poprawia dokładność wyników.

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

*/

Spersonalizowany Cypher

Czasami topologie typu rodzic-dziecko, podsumowujące oraz hipotetyczne nie pasują do konkretnego modelu danych przedsiębiorstwa, więc trzeba dokładnie określić, kterzy Cypher ma być uruchamiany podczas pobierania i wyszukiwania danych.

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

Chociaż ten materiał koncentruje się na naturalnych zaletach grafowych Neo4j, społeczność LangChain4j uogólniła te wzorce strukturalne. Powyższe narzędzia do pobierania danych opierają się pod spodem na ParentChildEmbeddingStoreIngestor, dzięki czemu ogólne funkcje wyszukiwania elementów rodzicowsko-dziecięcych działają z każdym magazynem embeddingów LangChain4j, który prawidłowo obsługuje metadane. Dzięki temu niektóre proste bazy danych wektorowych uzyskują również podstawowe funkcje typu RAG w stylu grafowym.

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

Pamięć czatu w grafie

Agenty konwersacyjne potrzebują stanu między interakcjami, a sztuczna inteligencja bez pamięci jest funkcją bezstanową. Magazyny pamięci w LangChain4j są tymczasowe i nie nadają się do użycia w środowiskach produkcyjnych, gdzie aplikacje są restartowane i skalowane horyzontalnie. Zamiast tego Neo4jChatMemoryStore przechowuje historię czatu wewnątrz Neo4j. Nie jest to zwykły zapis tekstu – to powiązana sekwencja węzłów, w zasadzie lista powiązana w formie grafu, która zapewnia trwały i możliwy do zapytania zapis interakcji użytkownika.

Domyślnie magazyn tworzy węzeł korzeniowy Memory oznaczony właściwością id. Od niego odchodzi łańcuch węzłów Message poprzez relacje LAST_MESSAGE i NEXT. Do jego utworzenia potrzebna jest albo konfiguracja sesji Neo4j, albo istniejąca instancja Driver, a dostępne są następujące opcjonalne ustawienia:

  • etykieta węzła pamięci (domyślnie Memory) oraz etykieta węzła wiadomości (domyślnie Message);
  • klucz właściwości ID pamięci (domyślnie id), który identyfikuje sesję czatową, oraz klucz właściwości tekstu wiadomości (domyślnie message);
  • typ relacji ostatniej wiadomości (domyślnie LAST_MESSAGE) oraz typ relacji następnej wiadomości (domyślnie NEXT);
  • nazwa bazy danych Neo4j (domyślnie neo4j);
  • liczba wiadomości do pobrania (domyślnie 10), która określa granicę okna kontekstowego.

Aby oddzielić sesje różnych użytkowników, system izoluje je za pomocą unikalnego memoryId. Współczesne modele LLM obsługują dane multimodalne, a system serializuje i przechowuje złożone treści, takie jak obrazy, a następnie je usuwa.

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

Przechowywany wynik wygląda w ten sposób:

Bazy danych korporacyjne rzadko akceptują ogólne etykiety takie jak Memory lub Message. W przypadku bazy danych, która już przestrzega ścisłych zasad nazewnictwa, narzędzie do tworzenia może przejąć kontrolę nad każdą etykietą, właściwością oraz typem relacji używanymi przez silnik.

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

Ta elastyczność sprawia, że pamięć nie wydaje się obcym dodatkiem, lecz staje się dobrze zaprojektowaną częścią istniejącej architektury grafu.

Wielkość okna

Ładowanie historii tysięcy wiadomości przekracza limit tokenów, zwiększa koszty API i pogarsza jakość odpowiedzi. Parametr size w konstruktorze kontroluje okno przesuwanie się wzdłuż listy powiązań.

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

Czasami potrzebny jest pełny obraz sytuacji, na przykład raport analityczny podsumowujący zgłoszenia klienta z całego miesiąca. Wartość 0 lub liczba ujemna informują sklep o konieczności przejścia przez całą listę aż do pierwszej interakcji.

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

W małych aplikacjach lub funkcjach serverless zarządzanie globalnym obiektem Driver Neo4j może być niepożądane. Metoda szybkiej autoryzacji typu basic-authentication w narzędziu budującym przyjmuje bezpośrednie dane uwierzytelniające.

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

Jedyną wymaganą ustawieniem jest połączenie z driverem Neo4j dla Javy. Jako alternatywę Neo4jChatMemoryStore.builder().withBasicAuth(, , ) tworzy wewnętrznie instancję drivera.

Ponieważ pamięć jest natywnie obsługiwana w Neo4j, dostosowane algorytmy grafowe lub zadania w tle mogą analizować te węzły, wyodrębniać entity wspomniane w rozmowach oraz łączyć historię czatów użytkownika z głównym grafem wiedzy, umożliwiając głęboko spersonalizowane doświadczenia AI.

Integracja LangChain4j z Neo4j już znacznie się rozwinęła. W miarę udoskonalania się modeli oraz ciągłej optymalizacji operacji wektorowych w bazach danych grafowych, z pewnością pojawią się kolejne zmiany i funkcje.

Literatura pokrewna