Knowledge Graph с локальными LLM: извлечение сущностей и отношений Neo4j | AiManual
AiManual Logo Ai / Manual.
19 Янв 2026 Гайд

Knowledge Graph без облаков: как локальные LLM вытаскивают сущности и связи из текста

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

RAG устарел? Графы знаний бьют по векторам

Представьте, что ваша RAG-система вместо тупого поиска по похожим векторам понимает, что "Илон Маск основал Tesla" и "Tesla производит электромобили" — это разные типы связей, а не просто слова рядом. Что "Python" в контексте программирования и "python" в контексте змей — разные сущности. Что можно спросить "Какие компании основали выходцы из PayPal?" и получить точный ответ, а не список похожих документов.

Это Knowledge Graph. И самое интересное — строить его можно полностью локально, без единого вызова к GPT-4. Зачем платить OpenAI, когда ваша видеокарта спит?

Внимание: если вы думаете, что Knowledge Graph — это просто модное слово для баз данных, вы ошибаетесь. Это способ заставить модели понимать мир, а не просто запоминать текст. Разница как между картой города и списком адресов.

Почему RAG с векторами иногда проваливается

Допустим, у вас 1000 документов про компании. В RAG с векторизацией запрос "компании, которые купил Microsoft" вернет документы, где есть слова "Microsoft", "купил", "компания". Но если в документе написано "Microsoft приобрела GitHub в 2018", а слово "купил" не используется — система может пропустить. Векторный поиск ищет похожие слова, а не смысл.

Knowledge Graph решает это просто: он хранит факт "Microsoft → приобрела → GitHub" как отдельную связь. Неважно, как сформулирован вопрос — "купила", "приобрела", "поглотила". Поиск идет по смыслу.

💡
Если вы только начинаете разбираться с графами знаний, посмотрите мой базовый гайд про Knowledge Graph. Там объясняю, зачем это нужно и какие задачи решает.

Выбор оружия: какие локальные модели реально работают

Вам не нужна Llama 3.1 405B. Серьезно. Для извлечения сущностей и отношений хватает моделей 7-13 миллиардов параметров. Большие модели медленнее, жрут память, а точность на этой задаче растет нелинейно.

Мои фавориты:

  • Mistral 7B Instruct — баланс скорости и качества. Отлично понимает инструкции.
  • Qwen2.5 7B — если нужна поддержка русского. Китайцы сделали неожиданно хорошую модель.
  • Phi-3 Mini 3.8B — для совсем слабого железа. Удивляет, на что способна модель размером с кота.
  • Llama 3.1 8B — стабильный вариант, если не хотите экспериментировать.

Забудьте про ChatGPT API. Один вызов стоит денег, а вам нужно обработать тысячи документов. Локальная модель стоит один раз (скачали) и работает бесплатно. Да, медленнее. Но когда обрабатываешь терабайты текста, разница в цене измеряется тысячами долларов.

МодельРазмерVRAM (FP16)Скорость (токенов/с)Качество извлечения
Phi-3 Mini3.8B~8 ГБ45-60Хорошее
Mistral 7B7B~14 ГБ35-50Отличное
Qwen2.5 7B7B~14 ГБ30-45Отличное (русский)
Llama 3.1 8B8B~16 ГБ25-40Отличное

У вас нет 24 ГБ VRAM? Есть выход. В статье "Архив знаний на случай апокалипсиса" я разбирал, как запускать модели на ограниченных ресурсах. GGUF, квантизация, CPU-offload — все это работает.

Архитектура системы: от текста до графа

Схема простая, но в деталях черт прячется:

Текст → Чанкинг → LLM (извлечение) → JSON → Валидация → Neo4j

Звучит элементарно. А теперь где все ломается:

Чанкинг — если режете текст по абзацам, теряете контекст. "Илон Маск основал SpaceX. Компания разрабатывает ракеты." Разрежьте между предложениями — во втором чанке "компания" без понятия, какая именно. Решение: перекрывающиеся чанки или семантическое разделение.

Извлечение — самая интересная часть. Нужно заставить модель возвращать структурированные данные. Не текст, а JSON с сущностями и отношениями.

1Промпт для извлечения — как не промахнуться

Вот как НЕ надо делать:

# Плохой промпт
prompt = "Извлеки сущности и отношения из текста"
# Модель вернет что угодно: список, текст, таблицу

Правильный подход — дать четкую схему и примеры:

prompt = """
Текст: {текст}

Извлеки все сущности и отношения в формате JSON.

Схема JSON:
{
  "entities": [
    {
      "name": "название сущности",
      "type": "PERSON|ORGANIZATION|LOCATION|PRODUCT|OTHER",
      "description": "краткое описание из контекста"
    }
  ],
  "relations": [
    {
      "from_entity": "имя сущности 1",
      "to_entity": "имя сущности 2",
      "relation": "основал|работает_в|приобрел|расположен_в"
    }
  ]
}

Пример:
Текст: "Илон Маск основал компанию SpaceX в 2002 году."
Ответ:
{
  "entities": [
    {"name": "Илон Маск", "type": "PERSON", "description": "основатель SpaceX"},
    {"name": "SpaceX", "type": "ORGANIZATION", "description": "компания, основанная в 2002"}
  ],
  "relations": [
    {"from_entity": "Илон Маск", "to_entity": "SpaceX", "relation": "основал"}
  ]
}
"""

Почему это работает? Модель любит структуру. Чем четче формат, тем меньше она "творит". Типы сущностей ограничены — так модель не придумает "SUPER_HERO" как тип.

Совет: добавьте в промпт "Не добавляй сущности или отношения, которых нет в тексте". Модели любят дополнять информацию, что ломает фактологическую точность.

2Обработка через локальную LLM — технические детали

Не используйте LangChain для этого. Серьезно. Он добавляет накладные расходы, а вам нужна максимальная скорость. Простой вызов через Ollama или llama.cpp:

import requests
import json

# Ollama запущен локально
def extract_entities(text, model="mistral:7b"):
    prompt = create_prompt(text)  # Функция из предыдущего шага
    
    response = requests.post(
        "http://localhost:11434/api/generate",
        json={
            "model": model,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0.1}  # Низкая температура для консистентности
        }
    )
    
    result = response.json()["response"]
    
    # Извлекаем JSON из ответа (модель может добавить текст)
    try:
        # Ищем JSON между { и }
        start = result.find('{')
        end = result.rfind('}') + 1
        json_str = result[start:end]
        return json.loads(json_str)
    except json.JSONDecodeError:
        # Fallback: пытаемся починить
        return fix_json(result)

Температура 0.1 — ключевой параметр. Вы хотите, чтобы модель на одном и том же тексте возвращала один и тот же JSON. Высокая температура даст творчество, а вам нужна точность.

Что делать, если модель упорно возвращает невалидный JSON? Есть трюк — системный промпт в 152KB, который заставляет модель следовать формату. Но обычно хватает четкой схемы.

3Валидация и дедупликация — где теряется 30% качества

Модель извлечет "Илон Маск", "Маск, Илон", "И. Маск" как разные сущности. Нужно нормализовать.

def normalize_entity_name(name):
    # Приводим к нижнему регистру, убираем лишние пробелы
    name = name.lower().strip()
    
    # Убираем титулы и обращения
    removals = ["господин", "мистер", "доктор", "профессор"]
    for rem in removals:
        name = name.replace(rem, "").strip()
    
    # Стандартизируем порядок слов (для имен)
    if "," in name:
        # "Маск, Илон" → "илон маск"
        parts = name.split(",")
        if len(parts) == 2:
            name = f"{parts[1].strip()} {parts[0].strip()}"
    
    return name

# Пример использования
print(normalize_entity_name("Маск, Илон"))  # "илон маск"
print(normalize_entity_name("доктор Илон Маск"))  # "илон маск"

Дедупликация отношений сложнее. "Основал" и "создал" — это одно и то же? Зависит от домена. Создайте словарь синонимов:

relation_synonyms = {
    "основал": ["создал", "учредил", "основал"],
    "работает_в": ["работает в", "трудоустроен в", "является сотрудником"],
    "приобрел": ["купил", "поглотил", "приобрел"]
}

def normalize_relation(rel):
    rel_lower = rel.lower()
    for canonical, synonyms in relation_synonyms.items():
        if rel_lower in synonyms:
            return canonical
    return rel_lower

4Загрузка в Neo4j — не только CREATE

Все туториалы показывают простой CREATE. На практике нужна upsert-логика (обновить если существует, иначе создать):

from neo4j import GraphDatabase

class KnowledgeGraph:
    def __init__(self, uri, user, password):
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
    
    def upsert_entity(self, entity):
        with self.driver.session() as session:
            # MERGE создает если нет, иначе находит
            query = """
            MERGE (e:Entity {name: $name})
            SET e.type = $type,
                e.description = $description,
                e.updated_at = timestamp()
            RETURN id(e) as entity_id
            """
            result = session.run(query, 
                name=entity["normalized_name"],
                type=entity["type"],
                description=entity.get("description", "")
            )
            return result.single()["entity_id"]
    
    def upsert_relation(self, from_name, to_name, relation_type):
        with self.driver.session() as session:
            query = """
            MATCH (from:Entity {name: $from_name})
            MATCH (to:Entity {name: $to_name})
            MERGE (from)-[r:RELATION {type: $relation_type}]->(to)
            SET r.weight = coalesce(r.weight, 0) + 1,
                r.updated_at = timestamp()
            RETURN id(r) as relation_id
            """
            result = session.run(query,
                from_name=from_name,
                to_name=to_name,
                relation_type=relation_type
            )
            return result.single()["relation_id"]

Обратите внимание на weight = coalesce(r.weight, 0) + 1. Это счетчик, сколько раз встретилось это отношение. Полезно для определения силы связи.

Оптимизация: как ускорить обработку в 10 раз

Обработка 1000 документов по одному займет вечность. Параллелизация — ваш друг:

from concurrent.futures import ThreadPoolExecutor
import asyncio

# Неправильно: последовательная обработка
def process_documents_sequential(docs):
    results = []
    for doc in docs:
        result = extract_entities(doc)  # Медленно!
        results.append(result)
    return results

# Правильно: параллельная обработка
def process_documents_parallel(docs, max_workers=4):
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # Запускаем несколько экземпляров Ollama на разных портах
        futures = []
        for i, doc in enumerate(docs):
            # Распределяем по портам 11434, 11435, 11436...
            port = 11434 + (i % max_workers)
            future = executor.submit(extract_entities_with_port, doc, port)
            futures.append(future)
        
        results = [f.result() for f in futures]
        return results

def extract_entities_with_port(text, port):
    # Аналогично extract_entities, но с другим портом
    response = requests.post(f"http://localhost:{port}/api/generate", ...)
    return process_response(response)

Но есть нюанс: одна модель на GPU, а вы запускаете несколько инстансов. Они будут делить ресурсы и бороться за память. Решение:

  1. Используйте маленькие модели (Phi-3 Mini) для параллельной обработки
  2. Или кэшируйте результаты — один раз обработали документ, сохранили в БД, больше не трогаем
  3. Или батчинг — подавайте несколько текстов в один промпт (с осторожностью, есть ограничение контекста)

Базовая RAG-система, о которой я писал в гайде по RAG за 15 минут, хороша для начала. Но Knowledge Graph — следующий уровень.

Семантический поиск по графу: Cypher вместо векторов

Вот где графы показывают силу. Запросы, которые в векторном поиске требуют магии, в Cypher выглядят естественно:

-- Какие компании основали бывшие сотрудники Google?
MATCH (p:Person)-[:РАБОТАЛ_В]->(:Organization {name: "Google"})
MATCH (p)-[:ОСНОВАЛ]->(company:Organization)
RETURN p.name, company.name

-- Найти цепочку инвестиций глубиной 3
MATCH path = (investor:Organization)-[:ИНВЕСТИРОВАЛ_В*1..3]->(startup:Organization)
WHERE investor.name = "Sequoia Capital"
RETURN path

-- Кто связан и через кого?
MATCH path = shortestPath(
  (p1:Person {name: "Илон Маск"})-[*]-(p2:Person {name: "Джефф Безос"})
)
RETURN path

Это не поиск похожих документов. Это поиск смысловых связей. Векторный поиск скажет "вот документы, где упоминаются Илон Маск и Джефф Безос". Графовый покажет, что они оба основали космические компании, конкурируют, и возможно, есть общие инвесторы.

💡
Для сложных агентных систем, где нужна память и планирование, смотрите полное руководство по Agentic RAG. Там комбинируем графы, векторы и локальные модели.

Где все падает: типичные ошибки новичков

Ошибка 1: Слишком много типов сущностей. Начинают с 50 типов: PERSON, COMPANY, CITY, COUNTRY, PRODUCT, FEATURE, TECHNOLOGY... Модель путается. Начните с 3-5 основных типов, расширяйте постепенно.

Ошибка 2: Отношения без контекста. "Илон Маск связан с Tesla". Как связан? Основал? Купил? Руководит? Всегда указывайте тип отношения. Иначе граф превращается в кашу.

Ошибка 3: Игнорирование конфиденциальности. Локально не значит безопасно. Если обрабатываете персональные данные, добавьте PII-фильтр. В гайде по Middleware в LangChain есть примеры, как это делать.

Что дальше? Knowledge Graph 2.0

Граф знаний — не статичная структура. Он должен обновляться, когда появляется новая информация. Представьте систему, которая:

  • Мониторит RSS-ленты и автоматически добавляет новые связи
  • Определяет противоречия ("Илон Маск основал SpaceX" vs "Илон Маск не основал SpaceX") и помечает их для проверки
  • Объединяется с векторным поиском — гибридная система, где граф дает точность, а векторы — recall

Локальные LLM делают это доступным. Не нужно платить за каждый вызов API. Обработали миллион статей — заплатили только за электричество. (Хотя с нынешними тарифами это тоже аргумент).

Попробуйте начать с малого: возьмите 100 документов из вашей области, запустите Mistral 7B на своей машине, постройте граф. Увидите связи, о которых не догадывались. И забудете о векторах как о единственном способе поиска.

А если хотите увидеть, как графы знаний используют для генерации контента, посмотрите туториал по созданию RPG-вселенных. Там графы знаний — основа для генерации согласованных миров.