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

Создайте простую пайплайн GraphRAG с использованием Neo4j, Gemini и LangChain.

Небольшая пайплайн GraphRAG с Neo4j, Gemini и LangChain: извлечение с соблюдением схемы, запросы к соседям и многократные переходы, односторонний преобразование текста в Cypher.

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

Источники: Building a Simple GraphRAG Pipeline with Neo4j, Gemini, and LangChain

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

Настройка

Установите драйвер Neo4j, а также пакеты LangChain Neo4j и Google GenAI:

pip install neo4j langchain-neo4j langchain-google-genai

Подключение к Neo4j

Загрузите URI, имя пользователя, пароль и необязательное название базы данных из локального файла с учетными данными, чтобы ничего конфиденциального не попадало в репозиторий:

neo4j_creds = {}
with open("Neo4j-cred.txt") as f:
    for line in f:
        line = line.strip()
        if line and "=" in line and not line.startswith("#"):
            key, value = line.split("=", 1)
            neo4j_creds[key.strip()] = value.strip()
NEO4J_URI = neo4j_creds["NEO4J_URI"]
NEO4J_USERNAME = neo4j_creds["NEO4J_USERNAME"]
NEO4J_PASSWORD = neo4j_creds["NEO4J_PASSWORD"]
NEO4J_DATABASE = neo4j_creds.get("NEO4J_DATABASE")

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

from neo4j import GraphDatabase
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))
driver.verify_connectivity()with driver.session(database=NEO4J_DATABASE) as session:
    assert session.run("RETURN 1 AS ok").single()["ok"] == 1driver.close()
print("Neo4j connectivity OK.")

Как только драйвер начнет отвечать, используйте те же учетные данные в помощнике LangChain Neo4jGraph, который будет применяться на последующих этапах:

from langchain_neo4j import Neo4jGraph
graph = Neo4jGraph(
    url=NEO4J_URI,
    username=NEO4J_USERNAME,
    password=NEO4J_PASSWORD,
    database=NEO4J_DATABASE,
)

Инициализация Gemini

Создайте клиент ChatGoogleGenerativeAI с использованием API-ключа среды:

import os
from langchain_google_genai import ChatGoogleGenerativeAI
GEMINI_MODEL = "gemini-2.5-flash"
api_key = os.environ.get("GOOGLE_API_KEY")llm = ChatGoogleGenerativeAI(model=GEMINI_MODEL, google_api_key=api_key)

Один экземпляр модели выполняет две функции: преобразует необработанные отчеты в графовые документы и превращает английские вопросы в формат Cypher.

Создание графа из нескольких документов

Четыре коротких файла с описанием инцидентов заменяют собой журнал ошибок. Загрузите их как объекты типа Document в LangChain:

from langchain_core.documents import Document
reports = {
    "meera_souffle.txt": "Chef Meera's cheese soufflé collapsed minutes after leaving "
        "the oven at her home kitchen. The stand mixer was used to whip the egg "
        "whites; they were overwhipped and had already begun to separate before "
        "folding, so the soufflé lost its structure and fell flat.",
    "arjun_fire.txt": "A grease fire broke out in the oven at the community center "
        "kitchen while Chef Arjun was roasting trays of vegetables. Built-up grease "
        "ignited after the oven was left on high heat for an extended period. The "
        "smoke set off alarms across the entire community center building.",
    "lina_curry.txt": "Chef Lina's curry burned on the induction stove at the "
        "catering truck kitchen. No timer was set, and the dish was left unattended "
        "well past its cooking time.",
    "devi_cake.txt": "Chef Devi's layer cake collapsed at the bakery kitchen. "
        "All-purpose flour was substituted for cake flour in the stand mixer "
        "without adjusting the liquid ratio, and the batter never set properly.",
}documents = [
    Document(page_content=text, metadata={"source": name})
    for name, text in reports.items()
]

Контролируйте процесс извлечения данных. Определите переменные ALLOWED_NODES и ALLOWED_RELATIONSHIPS в виде троек с указанием исходного метки, связи и целевой метки, чтобы отчеты использовали единый словарь вместо того, чтобы придумывать новые термины для одинаковых фактов.

from langchain_neo4j import LLMGraphTransformer
ALLOWED_NODES = [
    "Chef", "Kitchen", "Mishap", "Appliance", "FailureCause", "CauseCategory", "AffectedArea",
]ALLOWED_RELATIONSHIPS = [
    ("Chef", "WORKS_AT", "Kitchen"),
    ("Mishap", "OCCURRED_AT", "Kitchen"),
    ("Mishap", "INVOLVED_APPLIANCE", "Appliance"),
    ("Appliance", "HAD_ISSUE", "FailureCause"),
    ("Mishap", "CATEGORIZED_AS", "CauseCategory"),
    ("Chef", "RESPONSIBLE_FOR", "Mishap"),
    ("Mishap", "AFFECTED_AREA", "AffectedArea"),
]CAUSE_CATEGORY_INSTRUCTIONS = (
    "A Mishap is the specific event described in the report (e.g. 'Meera's Soufflé "
    "Collapse'), distinct from the Kitchen where it happened. A FailureCause should "
    "use the report's own specific wording for what went wrong (e.g. 'egg whites "
    "overwhipped', 'grease buildup ignited'). A CauseCategory is a normalized, shared "
    "classification: always choose exactly one of 'Temperature Control Failure', "
    "'Timing or Attention Lapse', or 'Technique or Substitution Error', even if the "
    "report never uses this wording. Only create an AffectedArea node, connected via "
    "AFFECTED_AREA, when the report says the mishap's effects spread beyond the "
    "kitchen itself — not for the kitchen's own name or address."
)transformer = LLMGraphTransformer(
    llm=llm,
    allowed_nodes=ALLOWED_NODES,
    allowed_relationships=ALLOWED_RELATIONSHIPS,
    strict_mode=True,
    additional_instructions=CAUSE_CATEGORY_INSTRUCTIONS,
)graph_documents = await transformer.aconvert_to_graph_documents(documents)for gd in graph_documents:
    print(f"{gd.source.metadata['source']}: {len(gd.nodes)} nodes, {len(gd.relationships)} relationships")

В результате типичной обработки могут быть получены такие показатели:

meera_souffle.txt: 6 nodes, 5 relationships
arjun_fire.txt:    7 nodes, 6 relationships
lina_curry.txt:    6 nodes, 5 relationships
devi_cake.txt:     6 nodes, 5 relationships

Запишите документы графа в Neo4j и обновите метаданные схемы:

graph.add_graph_documents(graph_documents, baseEntityLabel=True, include_source=True)
graph.refresh_schema()

Прямые запросы, показывающие структурную ценность

Для этих проверок не требуется повторного вызова модели — рёбра уже существуют.

Определите, что было получено в результате извлечения данных:

node_counts = graph.query(
    "MATCH (n:__Entity__) RETURN labels(n) AS labels, count(*) AS count ORDER BY count DESC"
)
rel_counts = graph.query(
    "MATCH ()-[r]->() WHERE type(r) <> 'MENTIONS' "
    "RETURN type(r) AS relationship_type, count(*) AS count ORDER BY count DESC"
)

Соседи одного объекта в радиусе двух шагов. Поиск по сходству не может заменить запрос «всё, что связано с Мирой»:

SEARCH_TERM = "meera"
neighborhood = graph.query(
    """
    MATCH (a:__Entity__)-[r*1..2]-(b:__Entity__)
    WHERE toLower(a.id) CONTAINS $term
    RETURN DISTINCT a.id AS start, [rel IN r | type(rel)] AS via, b.id AS neighbor
    LIMIT 25
    """,
    params={"term": SEARCH_TERM},
)

Коротчайший путь между людьми, которые никогда не упоминаются вместе в одном отчёте. Мира и Деви никогда не упоминают друг друга, однако оба инцидента имеют одну и ту же нормализованную категорию причины:

TERM_A, TERM_B = "meera", "devi"
paths = graph.query(
    """
    MATCH (a:__Entity__), (b:__Entity__)
    WHERE toLower(a.id) CONTAINS $term_a AND toLower(b.id) CONTAINS $term_b
    MATCH p = shortestPath((a)-[*..6]-(b))
    RETURN [n IN nodes(p) | n.id] AS path_nodes, [r IN relationships(p) | type(r)] AS path_rels
    LIMIT 5
    """,
    params={"term_a": TERM_A, "term_b": TERM_B},
)
Meera --[RESPONSIBLE_FOR]--> Meera's Soufflé Collapse --[CATEGORIZED_AS]--> Technique or Substitution Error <--[CATEGORIZED_AS]-- Devi's Cake Collapse <--[RESPONSIBLE_FOR]-- Devi

Этот путь является проверяемым ответом на вопрос о связи между объектами — сначала структура, затем описание.

Задавайте вопросы простым английским языком

GraphCypherQAChain — это классический цикл «текст → Cypher»: модель формирует запросы на языке Cypher на основе схемы, Neo4j их выполняет, а во втором этапе обрабатываются строки данных. Поскольку генерируемые запросы влияют на действующую базу данных, необходимо добавить в промпт явное указание на режим только для чтения — помните, что обеспечение безопасности в производственной среде достигается с помощью роли базы данных с ограничениями на запись, а не только промпта.

from langchain_core.prompts import PromptTemplate
from langchain_neo4j import GraphCypherQAChain
from langchain_neo4j.chains.graph_qa.cypher import CYPHER_GENERATION_PROMPT
READ_ONLY_INSTRUCTION = (
    "\nThis is a read-only exploration tool: only ever generate MATCH / OPTIONAL MATCH / "
    "WITH / RETURN / ORDER BY / LIMIT statements. Never generate CREATE, MERGE, DELETE, "
    "SET, REMOVE, or DROP, even if the question seems to ask for a change."
)cypher_prompt = PromptTemplate(
    template=CYPHER_GENERATION_PROMPT.template.replace(
        "Instructions:", f"Instructions:{READ_ONLY_INSTRUCTION}"
    ),
    input_variables=CYPHER_GENERATION_PROMPT.input_variables,
)graphrag_chain = GraphCypherQAChain.from_llm(
    llm,
    graph=graph,
    cypher_prompt=cypher_prompt,
    validate_cypher=True,
    return_intermediate_steps=True,
    allow_dangerous_requests=True,
    verbose=True,
)

Небольшой вспомогательный скрипт выводит вместе вопрос, сгенерированный запрос на языке Cypher, примеры строк данных и окончательный ответ:

def ask_graph(question: str):
    result = graphrag_chain.invoke({"query": question})
    cypher = result["intermediate_steps"][0]["query"]
    context = result["intermediate_steps"][1]["context"]
    print(f"Q: {question}\n")
    print(f"Generated Cypher:\n{cypher}\n")
    print(f"Graph context ({len(context)} rows): {context[:5]}\n")
    print(f"Answer:\n{result['result']}")
    return result

Вопрос о расписании сотрудников кухни:

_ = ask_graph("Which kitchens are in the graph, and which chef works at each one?")
Generated Cypher:
MATCH (c:Chef)-[:WORKS_AT]->(k:Kitchen)
RETURN k.id AS kitchen, c.id AS chef
Answer:
The kitchens and the chefs who work there are: the Home Kitchen (Meera), the
Community Center Kitchen (Arjun), the Catering Truck Kitchen (Lina), and the
Bakery Kitchen (Devi).

Более подробный разбор инцидента:

_ = ask_graph(
    "For each mishap, which chef was responsible, which appliance was involved, "
    "what category was it classified under, and did it affect any area beyond the kitchen?"
)
Generated Cypher:
MATCH (m:Mishap)
OPTIONAL MATCH (c:Chef)-[:RESPONSIBLE_FOR]->(m)
OPTIONAL MATCH (m)-[:INVOLVED_APPLIANCE]->(a:Appliance)
OPTIONAL MATCH (m)-[:CATEGORIZED_AS]->(cat:CauseCategory)
OPTIONAL MATCH (m)-[:AFFECTED_AREA]->(area:AffectedArea)
RETURN m.id AS mishap, c.id AS chef, a.id AS appliance, cat.id AS category, area.id AS affected_area
Answer:
- Meera's Soufflé Collapse: Meera, Stand Mixer, Technique or Substitution Error,
  no area affected beyond the kitchen.
- Community Center Kitchen Fire: Arjun, Oven, Temperature Control Failure,
  affected the entire Community Center building.
- Lina's Burnt Curry: Lina, Induction Stove, Timing or Attention Lapse,
  no area affected beyond the kitchen.
- Devi's Cake Collapse: Devi, Stand Mixer, Technique or Substitution Error,
  no area affected beyond the kitchen.

Что попробовать дальше

Выполните обнаружение сообществ (например, метод Лейдена), чтобы сгруппировать связанные инциденты, и заранее подготовьте краткие отчеты для вопросов, касающихся всего корпуса. Свяжите граф с векторным индексом исходного текста отчетов, чтобы при поиске нечетких вопросов сначала находилась соответствующая входная вершина перед продолжением поиска. Аккуратно расширяйте списки разрешенных элементов по мере появления новых типов сущностей, а также тестируйте генерируемый Cypher на отдельном наборе вопросов перед расширением прав доступа к базе данных.

Почему демо-версия остается небольшой

Четырех документов достаточно для отработки ограниченной выделения информации, запросов к соседним узлам, многоэтапных путей и преобразования текста из естественного языка в формат Cypher при наличии видимых ограничений на запись. История из кухни остается понятной, при этом демонстрируются те же принципы GraphRAG, которые можно применять к отчетам об инцидентах или графам политик: хранить связи один раз, запрашивать их различными способами и не позволять модели создавать запросы на запись в производственные данные.