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" как отдельную связь. Неважно, как сформулирован вопрос — "купила", "приобрела", "поглотила". Поиск идет по смыслу.
Выбор оружия: какие локальные модели реально работают
Вам не нужна 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 Mini | 3.8B | ~8 ГБ | 45-60 | Хорошее |
| Mistral 7B | 7B | ~14 ГБ | 35-50 | Отличное |
| Qwen2.5 7B | 7B | ~14 ГБ | 30-45 | Отличное (русский) |
| Llama 3.1 8B | 8B | ~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_lower4Загрузка в 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, а вы запускаете несколько инстансов. Они будут делить ресурсы и бороться за память. Решение:
- Используйте маленькие модели (Phi-3 Mini) для параллельной обработки
- Или кэшируйте результаты — один раз обработали документ, сохранили в БД, больше не трогаем
- Или батчинг — подавайте несколько текстов в один промпт (с осторожностью, есть ограничение контекста)
Базовая 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Это не поиск похожих документов. Это поиск смысловых связей. Векторный поиск скажет "вот документы, где упоминаются Илон Маск и Джефф Безос". Графовый покажет, что они оба основали космические компании, конкурируют, и возможно, есть общие инвесторы.
Где все падает: типичные ошибки новичков
Ошибка 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-вселенных. Там графы знаний — основа для генерации согласованных миров.