Всем привет! Меня зовут Василий Сизов и я работаю лидером кластера "ML CRM и ML клиентский опыт" в ВТБ. Мы с несколькими командами разрабатываем как классические модели, так и полноценные сервисы с LLM.
Это история одного пет-проекта на выходные, который вырос в небольшой фреймворк. Я расскажу, как поднять весь ML-стек для RAG одной командой, как разрезать книгу на правильные чанки, как вообще устроен Graph RAG (на примере Microsoft GraphRAG), почему я не смог его приручить — и что в итоге написал сам: Temporal Graph RAG с временными сообществами, составным ключом сущностей и двухуровневым реранкингом. И, конечно, честно сравню его с классическим RAG на живых вопросах по книге.
Будет много схем и таблиц, минимум кода. Поехали.

1. Зачем я вообще за это взялся
Если коротко — сошлись три обстоятельства.
Первое: у меня появился личный доступ к внешней H100. Когда у тебя в руках карта с 80 ГБ памяти, то очень хочется потрогать что-то тяжелее.
Второе: у меня запланирован рабочий проект с документами — и там почти наверняка пригодится графовый подход к поиску. Разбираться в новой технологии «в бою», на проде, под горящие сроки — плохая идея. Хотелось обкатать Graph RAG заранее.
Третье: нужен был датасет, который я знаю наизусть и где легко оценить качество ответа. «Гарри Поттер и философский камень» подошёл идеально: связный сюжет, куча персонажей и связей между ними, явная хронология событий — ровно то, на чём графовый поиск должен раскрыться.
Так и родился этот проект: поднять инфраструктуру, нарезать книгу, построить
классический RAG как baseline, разобраться, как работает Graph RAG, и сравнить
подходы на конкретных вопросах. Дальше — по порядку.
2. Инфраструктура: vLLM + Docker Compose
Что такое vLLM
vLLM (Virtual Large Language Model) — высокопроизводительный фреймворк для
инференса LLM. Для нашей задачи важны три его свойства:
PagedAttention — управление KV-кэшем на GPU по аналогии с виртуальной памятью
Continuous Batching — запросы динамически дособираются в батч на лету, а не ждут, пока наберётся фиксированный батч. GPU простаивает минимально.
OpenAI-совместимый API — модель сразу доступна по эндпоинтам
/v1/chat/completions,/v1/embeddings,/v1/rerank. Никакого своего протокола.
Альтернатива — SGLang, но я остался на vLLM
Три модели на одной GPU
Все три модели поднимаются из одного образа vllm/vllm-openai:latest:
Сервис | Модель | Порт | GPU-память | Назначение |
|---|---|---|---|---|
llm-qwen32 |
| 8001 | 80% | Генерация, извлечение графа, ответы |
embed-ru |
| 8006 | 10% | Эмбеддинги (1024-мерный вектор) |
reranker-ru |
| 8010 | 15% | Переранжирование результатов |
Ключевые параметры запуска:
--max-model-len 16384— контекстное окно LLM 16K токенов;--max-num-seqs 8для LLM — до 8 запросов параллельно;--max-num-seqs 1для эмбеддера и реранкера — последовательная обработка, чтобы не отъедать у LLM ни память, ни вычисления;--gpu-memory-utilization— жёсткий контроль доли VRAM под каждый сервис (80/10/15).
Qwen3-30B-A3B — это Mixture-of-Experts: из 30B параметров на каждый токен активны лишь ~3B. По качеству — крупная модель, по скорости инференса — почти как 3B. На FP8 в 80 ГБ помещается с запасом.
3. От документа к чанкам
Качество любого RAG упирается в качество чанков. Мусор на входе — мусор и в графе, и в ответах. Поэтому на парсинг и нарезку я потратил неприлично много времени.

Docling и три его сюрприза
Обрабатывал PDF библиотекой Docling от IBM. Она хороша: понимает кучу форматов, использует layout-модели для определения структуры, умеет подключать OCR и vLLM, отлично работает с таблицами. На конкретном PDF «Гарри Поттера» пришлось чинить три вещи руками.
Проблема 1. Оглавление попадает в текст. Docling парсит PDF «как есть», включая содержание. Метод-фильтр отличает пункт оглавления от настоящего заголовка
Проблема 2. Главы распознаются как таблицы. Иногда Docling видит заголовок главы как таблицу с ячейками ["Глава N", "Название"].
Проблема 3. "Мусорные заголовки". Docling помечает как section_header любой жирный текст на отдельной строке — подписи в письмах, названия магазинов в списке покупок, бланки. Чанкер пропускает только настоящие структурные заголовки: Глава …, Часть …, Пролог, Эпилог, Annotation. Всё остальное игнорируется как шум.
Sentence-Based Chunker
Стандартный RecursiveCharacterTextSplitter режет по символам и легко рвёт предложение пополам. Docling'овский HybridChunker лучше, но не знает про границы глав. Поэтому — свой чанкер в 4 этапа:
Извлечение элементов с метаданными. По каждому текстовому элементу собираем текст, номера страниц и текущую иерархию заголовков.
Склейка незавершённых фрагментов. Docling иногда рвёт абзац на куски. Если элемент не кончается на
.,!,?,…— клеим со следующим. Тонкость: номера страниц при склейке не объединяем, иначе один чанк накопил быpage_numbers=[5,6,7,8,9,10…].Разбиение на предложения — regex под русский: точка/
!/?/…, пробел, затем заглавная (рус/лат),«, дефис или цифра.Сборка чанков с учётом границ глав. Ключевое: если у предложения сменились заголовки — текущий чанк принудительно закрывается, новый начинается с новой главы. Размер считаем настоящим токенайзером эмбеддера, лимит — 510 токенов.
Слишком длинное одиночное предложение режем по словам.
Документ (PDF)
└─ Docling → ~1200 текстовых элементов
└─ склейка фрагментов → ~800 элементов
└─ разбиение на предложения → ~5000 предложений
└─ сборка с учётом глав → 334 чанка
каждый: text, headings=["Глава 1 …"], tokens≈480, pages=[5,6]
Почему граница по главам так важна? Для графа критично, чтобы чанк не смешивал события двух глав — иначе LLM при извлечении сущностей перепутает, к какой главе что относится. А дальше составной ключ сущности будет включать главу: (name, book, chapter).
4. Classic RAG как baseline
Прежде чем строить граф, нужен честный baseline. Классический RAG:

Поиск. Вектор запроса нормализуем (faiss.normalize_L2) — L2-расстояние превращается в косинусную близость. FAISS отдаёт top-50, кросс-энкодер BAAI/bge-reranker-v2-m3 переранжирует их и оставляет top-5.
Почему 50 → 5? Bi-encoder (эмбеддер) быстрый, но грубый — хорош для recall.
Cross-encoder (реранкер) точный, но дорогой — хорош для precision. Связка «широкий recall + точный precision» даёт лучшее из двух миров: 50 кандидатов, из них 5 действительно релевантных.
Генерация. 5 чанков уходят в LLM с метаданными ([Документ 1] (Книга 1, Глава 5, ID: book1_chunk_42)) и нарочито строгим system-промптом:
СТРОГИЕ ПРАВИЛА (НАРУШЕНИЕ = ОШИБКА):
1) Используй ТОЛЬКО факты, ПРЯМО написанные в контексте
2) НЕ делай выводов, НЕ интерпретируй, НЕ додумывай
3) Если контекст говорит "или A, или B" — укажи оба варианта
4) КАЖДОЕ утверждение подкрепляй ТОЧНОЙ цитатой
5) НЕ объединяй информацию из разных документов без явной связи
Ограничения, ради которых всё и затевалось:
нет понимания связей — модель видит текст, но не «кто с кем связан»;
нет временного контекста — не понимает последовательность событий;
узкий контекст — 5 чанков (~2500 токенов) из 12000 доступных;
плохо на цепочках событий — «какая последовательность привела к X?»;
нет хронологии — чанки приходят в порядке rerank-score, а не по времени сюжета.
Ровно эти дыры и должен закрыть Graph RAG. Разберёмся, как он устроен.
5. Что такое Graph RAG и как он устроен (Microsoft GraphRAG)
Прежде чем рассказывать про свою реализацию, честно объясню канонический подход — Microsoft GraphRAG. Дальше я буду от него отталкиваться, поэтому важно понимать его целиком, а не как «ту штуку, что мне не подошла». Этот раздел — нейтральная теория.
Идея: граф знаний поверх текста
Классический RAG ищет похожие куски текста. Graph RAG добавляет уровень абстракции — граф знаний. Пайплайн индекса концептуально один и тот же:

entities — сущности: персонажи, места, объекты. Узлы графа.
relationships — связи между сущностями. Рёбра графа.
communities — кластеры плотно связанных сущностей.
Поверх всего — связный граф, по которому можно «ходить», а не только искать ближайший вектор.
Зачем это нужно? Чтобы отвечать на вопросы, где важны связи и обобщения:
«О чём вообще книга?», «Кто как связан с Х?». Векторный поиск по чанкам тут пасует.
Индекс MS GraphRAG
Двухстадийный процесс:
Извлечение сущностей и связей. LLM проходит по чанкам и достаёт сущности, классифицирует их по типам (
PERSON,LOCATION,OBJECT, …), пишет описание каждой сущности и каждой связи, а у связи ещё и strength score — числовую силу отношения.Консолидация. Одна и та же сущность встречается в десятках чанков. Её описания из разных мест LLM-суммаризацией сводят в одно непротиворечивое, без дублей.
Community Detection и иерархия саммари
Дальше из сущностей и связей строится граф: узлы = сущности, рёбра = связи, вес ребра = strength score. К графу применяется алгоритм поиска сообществ — Leiden (в оригинальной статье) или Louvain. Алгоритм находит плотно связанные кластеры сущностей и строит иерархию сообществ: маленькие плотные группы складываются в группы покрупнее.
Для каждого сообщества на каждом уровне иерархии LLM генерирует summary, который:
описывает ключевые сущности кластера,
фиксирует основные взаимодействия,
выделяет доминирующие темы.
Получается иерархия саммари: нижние уровни — детальные отчёты, верхние — абстрактные, агрегированные. Это и есть «сжатое знание о датасете», по которому потом идёт поиск.
Важная оговорка: Leiden/Louvain недетерминированы — на одном и том же графе разбиение от запуска к запуску может слегка плавать.
Global Search — map-reduce по сообществам
Первый режим поиска. Граф напрямую не обходится — работаем только с заранее сгенерированными community reports выбранного уровня иерархии.
Запрос
│
├─ MAP: каждый community report (нужного уровня) бьётся на куски;
│ LLM из каждого куска достаёт тезисы + оценку важности 0–100
│
└─ REDUCE: самые важные тезисы со всех сообществ собираются вместе;
LLM синтезирует из них финальный связный ответ
Чуть подробнее про промпты этих двух шагов.
Map-промпт инструктирует LLM достать из куска отчёта ключевые тезисы, каждому выставить оценку важности 0–100 и оформить ответ как JSON со ссылками на ID отчётов-источников; если данных недостаточно — прямо сказать об этом, не домысливая.
Reduce-промпт получает все тезисы, уже отсортированные по важности, и синтезирует из них финальный ответ в Markdown, выбрасывая нерелевантное и сохраняя ссылки на источники. То есть Global Search — это, по сути, иерархическая суммаризация под конкретный вопрос.
Когда применять: широкие тематические вопросы — «О чём эта книга?», «Какие основные темы?». Здесь не нужен конкретный факт, нужно обобщение по всему датасету.
Компромисс уровня иерархии: нижние уровни → детальные отчёты → больше LLM-вызовов → точнее, но дольше; верхние уровни → абстрактные отчёты → быстрее, но теряется гранулярность. Балансировка детальности и скорости — ключ к настройке Global Search.
Local Search — vector search + обход графа
Второй режим. Здесь мы как раз ходим по графу:
Запрос
│
├─ Vector search → семантически близкие entities (точки входа)
│
├─ Graph traversal — от найденных entities расширяемся по рёбрам:
│ • соседние сущности
│ • связанные relationships с описаниями
│ • community reports, куда входят эти сущности
│ • text chunks, привязанные к этим сущностям
│
├─ Ранжирование и обрезка под контекстное окно:
│ • text chunks — по частоте связи с найденными entities (top-K)
│ • communities — по rank и weight (top-K)
│ • relationships — по importance (top-K)
│
└─ LLM генерирует ответ по собранному контексту
Когда применять: entity-focused вопросы — «Кто такой Одиссей?», «Какие свойства у ромашки?». Нужно глубоко раскрыть конкретную сущность и её окружение.
Global vs Local — когда что
Global Search | Local Search | |
|---|---|---|
Что использует | только community reports | граф + чанки + сообщества |
Механизм | map-reduce по сообществам | vector search + обход графа |
Тип вопросов | широкие, тематические | про конкретную сущность |
Пример | «О чём эта книга?» | «Кто такой Дамблдор?» |
Что важно запомнить про MS GraphRAG, прежде чем идти дальше:
это двухстадийный процесс: сначала извлечение и консолидация сущностей/связей, потом community detection и суммаризация;
качество сильно зависит от размера чанка, набора типов сущностей и параметров community detection — мелкие чанки дают более полное извлечение сущностей;
масштабирование держится на ранжировании: при тысячах сущностей, связей и сообществ в контекст попадает только top-K самого релевантного.
Вот это всё — Microsoft GraphRAG. Концептуально стройно. Теперь — почему я не смог им воспользоваться.
6. Я честно пытался Microsoft GraphRAG. Не взлетело
Я не собирался писать свой фреймворк. Идея была скучная и правильная: взять готовый пакет graphrag, прикрутить к своим моделям, получить результат.
Реальность: в какой-то момент мои фиксы библиотеки стали больше, чем основной код проекта. Две главные боли:
Все промпты заточены под большие модели. GraphRAG проектировался под GPT-класс. На моей локальной LLM «из коробки» извлечение разваливалось — а переписать промпты, не воюя с внутренностями пакета, не выходило.
Тяжело «подлезть» в нужные классы. Поменять хранилище, алгоритм поиска, логику сообществ — всё это требовало или форка, или патчей. Pipeline жёсткий.
Был соблазн сдаться и написать в выводах «GraphRAG на 32B (Qwen2.5-32B-Instruct-AWQ) не работает, расходимся». Но это был бы вывод не про технологию, а про мою лень. Так что не сдался. Решил, что проблема не в идее графа, а в конкретной реализации, и собрал свою.
Что именно меня не устраивало в MS GraphRAG и что я сделал вместо:
Негибкость MS GraphRAG | Моё решение в Temporal Graph RAG |
|---|---|
Жёсткий Leiden clustering перемешивает хронологию событий | Temporal communities — sliding window вместо Leiden |
Нет временной структуры (порядка событий) |
|
Одна сущность «Гарри» на всю книгу, без контекста главы | Составной ключ |
Один уровень реранкинга | Двухуровневый реранкинг: entities → text_units |
Падение на чанке 300 = потеря всей работы | Checkpoint каждые 20 чанков |
Дальше — что под капотом. Я намеренно показываю каждый шаг в параллели с MS GraphRAG: так понятнее, что именно изменилось и зачем. Назову свой вариант T-GraphRAG (Temporal), канонический — M-GraphRAG (Microsoft).
7. Temporal Graph RAG — индекс (5 шагов)
PDF → Чанки (Step 1: Text Units)
↓
LLM Extraction (Step 2: Entities + Relationships)
↓
Sliding Window Communities (Step 3)
↓
Embeddings (Step 4)
↓
Parquet + FAISS (Step 5)
Step 1 — Text Units: добавляем время
В M-GraphRAG чанк — это просто текст. В T-GraphRAG каждый чанк обогащается временными метаданными. ChunkLoader достаёт номер книги из имени файла, считает позицию и строит:
@dataclass
class TemporalPosition:
book_number: int # 1
book_title: str # "Harry Potter"
chunk_index: int # 42 (внутри книги)
global_chunk_index: int # 42 (сквозной через все книги)
relative_position: float # 0.125 — 12.5% книги
relative_position = idx / (total_chunks - 1) — число от 0.0 до 1.0. Это позволяет проследить развитие персонажей и расставлять события в правильном порядке — дальше время будет всплывать буквально на каждом шаге.
Step 2 — Entities & Relationships: ключевое отличие
Самый тяжёлый шаг — до 8 параллельных запросов к LLM (asyncio.Semaphore(8)). Извлечение сущностей и связей в целом как в M-GraphRAG, но с двумя принципиальными изменениями.
Отличие 1. Составной ключ (name, book, chapter). В M-GraphRAG одна сущность «Гарри Поттер» на всю книгу: её описания консолидируются в одно. В T-GraphRAG один персонаж в разных главах = разные записи:
"Гарри Поттер|1|Глава 1 Мальчик, который выжил" → сирота, живёт у Дурслей
"Гарри Поттер|1|Глава 6 Путь с платформы девять и три четверти" → ученик Гриффиндора, подружился с Роном
"Гарри Поттер|1|Глава 17 Человек с двумя лицами" → противостоит Волан-де-Морту, нашёл философский камень
Зачем: мы видим эволюцию персонажа по главам. На вопрос «Как изменился Гарри?» можно собрать все его «версии» и показать развитие, а не усреднённое описание. Вот как это выглядит в реальном индексе — две записи одной сущности «Хагрид»:
{ "name": "Хагрид", "type": "PERSON", "book_number": 1,
"chapter": "Глава 1 Мальчик, который выжил",
"description": "старший охранник Хогвартса, хранитель леса, эмоционально привязан
к Гарри | гигантский человек, который привёз мальчика из дома на Тисовой улице |
доверенное лицо Дамблдора, должен доставить Гарри | владелец мопеда" }
{ "name": "Хагрид", "type": "PERSON", "book_number": 1,
"chapter": "Глава 14 Дракон по имени Норберт",
"description": "хранитель дракона, живёт в деревянном доме, незаконно укрывает
дракона | обладает знаниями о философском камне | воспитывает норвежского
горбатого дракона | знает, как пройти мимо Пушка" }
Это буквально разные стадии одного героя — и на вопросе «как изменился Хагрид?» граф отдаст обе, а не усреднит их в безликое «лесник Хогвартса».
Отличие 2. Типизация связей прямо при извлечении. Вместо абстрактного strength score связь сразу получает тип. Фрагмент промпта:
ПРИОРИТЕТ 1 — OPERATIONAL (правила, ограничения, препятствия):
"Пушок охраняет люк" → OPERATIONAL
ПРИОРИТЕТ 2 — FACTUAL (роли, владения, устойчивые факты):
"Дамблдор — директор Хогвартса" → FACTUAL
ПРИОРИТЕТ 3 — NARRATIVE (только значимые события, меняющие ситуацию):
"Гарри нашёл философский камень" → NARRATIVE
НЕ ИЗВЛЕКАЙ:
- "Гарри пошёл в комнату" (бытовое перемещение)
- "Рон был удивлён" (эмоция без последствий)
Блок «НЕ ИЗВЛЕКАЙ» с конкретными антипримерами оказался важнее всего остального: без него LLM забивает граф бытовым шумом. strength (1–10) от модели нормализуется в weight (0.0–1.0): weight = strength / 10.
Заметка на полях: соблазн «а давайте просто попросим LLM построить граф и будем ждать чуда» очень велик. Чуда не будет. Будет ровно то, что вы описали в промпте, — поэтому половина работы здесь это не код, а формулировка «что считать связью».
Агрегация описаний. Если сущность встречается в нескольких чанках одной главы, описания копятся в descriptions_raw[], а в конце дедуплицируются через set() и склеиваются через | — LLM любит повторять одно и то же из соседних чанков.
Checkpoint каждые 20 чанков. Обработка 334 чанков занимает относительно много времени. Падение на чанке 300 без чекпоинтов = потеря. Поэтому каждые 20 чанков сохраняется промежуточный результат (entities/relationships + processed_count), и при перезапуске индексация продолжается с места обрыва. Это та самая отказоустойчивость, которой нет в M-GraphRAG.
Статистика на «философском камне»: 1759 сущностей, 2389 связей.
Step 3 — Communities: sliding window вместо Leiden
Вот здесь главное расхождение с M-GraphRAG. Напомню: там сообщества строит Leiden / Louvain по связности графа. Проблема для художественного текста — кластеризация по связности перемешивает хронологию: сообщество «Гарри и Хагрид» соберёт их взаимодействия из начала и из конца книги в одну кучу.
В T-GraphRAG сообщество — это скользящее окно по сюжету:
Чанки: [1][2]...[15][16]...[20][21][22]...[35]...
←——— Окно 1 (чанки 0–19) ———→
←——— Окно 2 (чанки 15–34) ———→
window_size = 20, overlap = 5, step = 15
Для каждого окна:
собираем все
entity_keysиrelationship_idsиз его чанков;сортируем по хронологии
(book_number, chapter)— критично, чтобы LLM видел события в правильном порядке;формируем промпт (до 50 сущностей + до 50 связей) с временным контекстом;
LLM генерирует community report.
Вот как выглядит вход и выход для одного окна:
КНИГА: Гарри Поттер и философский камень (Книга 1)
ВРЕМЕННОЙ ДИАПАЗОН: Чанки 15–34 (4.5%–10.2% книги)
СУЩНОСТИ:
- Гарри Поттер (PERSON): ученик, получивший письмо из Хогвартса
- Хагрид (PERSON): лесник Хогвартса, привёз письмо
- Косой переулок (LOCATION): торговая улица волшебного мира
ОТНОШЕНИЯ:
- Хагрид → Гарри Поттер: привёз письмо из Хогвартса
- Гарри Поттер → Косой переулок: впервые посетил для покупок
→ LLM на это отвечает структурированным JSON:
{
"title": "Прибытие писем и открытие магического мира",
"summary": "Гарри получает письмо из Хогвартса и впервые попадает в Косой переулок с Хагридом.",
"report": "В этом временном окне Гарри Поттер впервые сталкивается с магическим миром..."
}
Почему sliding window лучше Leiden — для этой задачи:
сохраняет хронологию (Leiden её ломает);
overlap=5 даёт плавные переходы — персонаж на стыке окон попадает в оба;
community report = сжатое описание отрезка сюжета, идеально под «что произошло в этой части?»;
предсказуемый размер вместо непредсказуемых кластеров Leiden.
Результат: 23 temporal community на книгу.
Step 4 — Эмбеддинги
Эмбеддятся (1024-dim) три типа объектов, каждый своим текстом под свою задачу поиска:
Тип | Текст для эмбеддинга | Зачем |
|---|---|---|
Entity |
| «Кто такой Дамблдор?» → находим сущность |
Community |
| «Что было после прибытия в Хогвартс?» → находим окно |
Text Unit |
| Classic RAG / гибрид / сравнение |
Из-за Semaphore(1) эмбеддер работает строго последовательно — это правильно для --max-num-seqs 1. Длинные reports клиент режет и усредняет (см. раздел 2).
Step 5 — Хранение: Parquet + FAISS
Два уровня: Parquet для табличных данных, FAISS для векторов.
temporal_index/
├── data/ # Parquet, Snappy
│ ├── text_units.parquet # 334
│ ├── entities.parquet # 1759
│ ├── relationships.parquet # 2389
│ └── communities.parquet # 23
├── vectors/ # FAISS IndexFlatL2 + mapping-файлы
│ ├── entities.faiss / communities.faiss / text_units.faiss
├── intermediate/ # чекпоинты для восстановления
└── metadata.json
Почему Parquet, а не JSON: Snappy-сжатие в 3–5 раз компактнее, чтение быстрее json.load, типы сохраняются (числа остаются числами). Сложные поля (списки) сериализуются в JSON-строки внутри колонок.
Почему FAISS IndexFlatL2 (brute-force): 1759 сущностей — слишком мало для ANN-индексов (IVF/HNSW). Точный поиск за O(n) при n<10K — мгновенный, зато без потерь точности. L2-расстояние конвертируется в близость на лету.
8. Temporal Graph RAG — поиск (entity-centric, 6 шагов)
Здесь я ушёл от M-GraphRAG сильнее всего. В Microsoft два разных режима — Global (map-reduce) и Local (vector + traversal). У меня — единый entity-centric pipeline: сущности как точки входа, дальше всё стягивается к ним. Это самый сложный компонент системы.
Запрос
↓ 1. Извлечение временных подсказок (regex)
↓ 2–3. FAISS (top-K×3) → Reranker → top-15 entities
↓ 4. Relationships от найденных entities (in/out-network)
↓ 5. Communities через пересечение entity_keys
↓ 6. Text Units через 4-этапный reranking
↓ Сборка контекста (token budget) → LLM → Ответ
Шаг 1 — временные подсказки из запроса
Regex-анализ запроса автоматически ставит фильтр по книгам:
"в первой книге" / "философский камень" → [1]
"во второй книге" / "тайная комната" → [2]
"как изменился" / "на протяжении серии" → [1..7] (все книги)
«Как изменился Гарри на протяжении серии?» ищется по всем книгам; «Что было в Тайной комнате?» — только по книге 2. Подсказок нет — ищем везде.
Шаги 2–3 — поиск и реранкинг сущностей
Oversampling. В FAISS берём top_k × 3 = 45 кандидатов — заведомо больше нужного.
Bi-encoder ошибается чаще cross-encoder'а, поэтому даём реранкеру запас.
Reranking. Сущности подаём реранкеру как "{name}: {description}", он оценивает семантическую близость к запросу и оставляет top-15. Это и есть отличие от M-Local
Search: там сущности — лишь точки входа в traversal, у меня они проходят отдельный точный отбор кросс-энкодером. Fallback: упал реранкер — используем FAISS-ранжирование.
Шаг 4 — relationships: in-network vs out-network
Для каждой сущности из top-15 по составному ключу (name, book, chapter) находим все её связи и делим на две группы:
In-network (приоритет): оба конца — среди найденных сущностей.
Гарри → Рон: "лучшие друзья"— оба в контексте, связь максимально релевантна.Out-network: один конец вне top-15.
Гарри → Дурсли: "живёт у них"— дополнительный контекст.
In-network идут первыми. Внутри групп — сортировка по relative_position первого text_unit, то есть в хронологическом порядке. Лимит — 10 связей на сущность.
Шаг 5 — communities через пересечение, а не через поиск
В M-Local Search сообщества подтягиваются обходом графа. У меня — через пересечение ключей:
entity_keys = { "{name}|{book}|{chapter}" для каждой найденной entity }
для каждого community: overlap = |entity_keys ∩ community.entities|
берём top-5 communities с наибольшим overlap
Почему так точнее семантического поиска по сообществам: community с максимальным пересечением гарантированно описывает тот самый участок сюжета, где встречаются найденные сущности. «Семантически похожее» сообщество такой гарантии не даёт.
Шаг 6 — text_units: 4-этапный reranking
Самая нетривиальная часть — двухуровневый реранкинг (первый уровень был на шаге 3, для сущностей; здесь — второй, для чанков).
Per-entity reranking. Для каждой сущности реранкер оценивает её text_units в контексте этой сущности: запрос обогащается →
"{query}. Контекст: {entity. name}: {entity.description[:200]}". Один и тот же чанк может быть релевантен в контексте «Гарри» и нерелевантен в контексте «Хагрид» — реранкер видит этот нюанс.Глобальная сортировка всех text_units со всех сущностей по rerank-score.
Token budget. Идём сверху вниз, считаем токены настоящим токенайзером Qwen3, останавливаемся на 55% контекстного окна. Тексты обрезаем до 2000 символов.
Финальная хронологическая сортировка отобранных по
relative_position— LLM получает события в правильной последовательности.
Запрос: "Как Гарри попал в Хогвартс?"
После реранкинга (по score): После хронологической сортировки (в LLM):
1. chunk_92 (0.95, pos 27.5%) 1. chunk_85 (pos 25.4%) письмо из Хогвартса
2. chunk_85 (0.88, pos 25.4%) → 2. chunk_92 (pos 27.5%) платформа 9¾
3. chunk_98 (0.82, pos 29.3%) 3. chunk_98 (pos 29.3%) Распределяющая шляпа
Token budget
┌──────────────────────────────────────────────────┐
│ Communities (title+summary) 10% ≈ 1200 токенов │
├──────────────────────────────────────────────────┤
│ Entities + Relationships 35% ≈ 4200 токенов │
│ (in-network первыми, хронология) │
├──────────────────────────────────────────────────┤
│ Sources / Text Units 55% ≈ 6600 токенов │
│ (реранк, потом хронология) │
└──────────────────────────────────────────────────┘
≈ 12000 из 16384 (остальное — на ответ и system prompt)
55% на оригинальный текст — осознанный выбор: LLM пишет ответ заметно лучше, когда видит исходные фразы, а не только сухие описания сущностей.
Правильный контекст — залог успеха. Весь этот пайплайн существует ради одного: положить в окно LLM не «похожие чанки», а структурированный, хронологически верный срез знания. Дальше — проверим, окупается ли сложность.
9. Classic RAG vs Temporal Graph RAG: на живых вопросах
Архитектурное сравнение
Аспект | Classic RAG | Temporal Graph RAG |
|---|---|---|
Единица поиска | чанк | сущность (entity) |
Индекс | 1 FAISS | 3 FAISS + 4 Parquet |
Контекст в LLM | 5 чанков (~2500 ток.) | сущности+связи+сообщества+чанки (~12000 ток.) |
Понимание связей | нет | да (типы OPERATIONAL/FACTUAL/NARRATIVE) |
Время | нет | да (relative_position, sliding window) |
Reranker | 1 уровень | 2 уровня (entities + text_units) |
Хронология ответа | нет (порядок по score) | да |
System prompt | строгий (цитаты) | мягче (допускает связывание) |
Индексация | минуты | часы |
Отказоустойчивость | не нужна | checkpoint каждые 20 чанков |
Где кто сильнее
Тип вопроса | Classic | Temporal | Почему |
|---|---|---|---|
Простой факт («кто директор?») | ✅ | ✅ | оба находят чанк |
Перечисление («все учителя») | 🟡 | ✅ | граф видит все PERSON-сущности |
Цепочка событий («что привело к X?») | ❌ | ✅ | связи в хронологии |
Пересечение («кто связан и с X, и с Y?») | ❌ | ✅ | сущности на пересечении |
Эволюция («как изменился X?») | ❌ | ✅ | составной ключ → версии по главам |
Точная цитата из места | ✅ | 🟡 | у графа бывает «шум» из связей |
Механизмы («как работает X?») | 🟡 | ✅ | OPERATIONAL хранит правила |
Разбор реальных ответов
Это самое интересное — что обе системы реально ответили на одни и те же вопросы.
Ниже — выдержки из настоящих ответов (сокращённо, но дословно).
Вопрос 1: «Какой номер у платформы, с которой отправляется поезд в Хогвартс?»
(прямой фактовый вопрос)
Classic RAG:
Прямой ответ: Платформа номер девять и три четверти.
Подтверждающие цитаты: «…надо сесть на поезд, который отходит в одиннадцать часов утра от платформы номер девять и три четверти» (Документ 1); «…арка с табличкой: „Платформа номер девять и три четверти"» (Документ 3).
Ссылки: Документ 1, 3, 4.
Temporal RAG:
Платформа имеет номер 9¾ (девять и три четверти).
Подтверждающие детали: «Гарри Поттер должен прибыть на платформу в первый день сентября»; «…отправляющийся в одиннадцать утра с платформы девять и три четверти»; «Платформа 9¾ — скрытая платформа, ведущая в Хогвартс, доступная только магам».
Источники:
book1_chunk_94,book1_chunk_98,book1_chunk_99.
Оба ответа верные. Classic строже цитирует исходный текст, Temporal добавляет структурный контекст из Entities («скрытая платформа, доступная только магам»). На прямом факте — паритет.
Вопрос 2: «Какую фирму возглавлял Дурсль?» (прямой факт)
Classic RAG: «фирму „Граннингс"» + точная цитата про дрели (Документ 1) + слегка натянутая «подтверждающая» цитата про телефонные разговоры (Документ 5) — попытка обосновать должность там, где обоснование не требовалось.
Temporal RAG: «„Граннингс"» + одна точная цитата book1_chunk_2 + подтверждение из секции Entities: «„Граннингс" — фирма, возглавляемая мистером Дурслем, специализируется на производстве дрелей». Лаконичнее и чище: graph-контекст уже содержал готовый факт.
Вопрос 3: «Перечисли всех учителей Хогвартса и их предметы». (перечисление)
Classic RAG нашёл пятерых с дословными цитатами: МакГонагалл — трансфигурация, Квиррелл — защита от Тёмных искусств, Стебль — травология, Флитвик — заклинания, Бинс — история магии. Полно ровно по тем чанкам, что попали в top-5.
Temporal RAG выдал структурнее, но иначе: МакГонагалл (трансфигурация, декан Гриффиндора), Снегг (зелья, декан Слизерина), Флитвик (заклинания, староста Когтеврана), Квиррелл (защита от Тёмных искусств) — с ролями и факультетами, но без Стебль и Бинса.
Любопытный момент: на «перечислениях» побеждает не всегда тот, кого ждёшь. Classic полнее по предметам, Temporal богаче по структуре (деканы, факультеты), но оба неполны — каждый упирается в свой механизм отбора (top-5 чанков vs top-15 сущностей).
Скорее ничья с разными провалами.
Вопрос 4: «Как Квиррелл узнал, как пройти мимо Пушка?» (вопрос-ловушка: прямого ответа в тексте нет, нужно рассуждение)
Classic RAG честно отвечает «в контексте не указано», приводит цитаты про карточную игру Хагрида с незнакомцем, про Снегга-под-капюшоном — и делает аккуратный вывод: «не указано, что Квиррелл получил эту информацию».
Temporal RAG идёт дальше — рассуждает по связям:
Квиррелл не знал, как пройти мимо Пушка.
Хагрид, владевший Пушком, единственный знал способ и никому не рассказывал.
В контексте: «Да ни одна живая душа не знает, вот как! Кроме меня… и Дамблдора» — гордо заявил Хагрид.
Следовательно, Квиррелл не мог узнать путь от Хагрида.
Это уже вывод по графу связей, а не пересказ ближайших чанков. Ровно тот класс вопросов, ради которого всё и затевалось.
Вопрос 5: «Какие препятствия последовательно проходят Гарри, Рон и Гермиона на пути к философскому камню?» (главный кейс — цепочка событий)
Classic RAG находит 2–3 чанка про отдельные препятствия, путается и ошибочно приписывает Гермионе «лабиринт», сам же себя поправляет — и не выстраивает последовательность: чанки приходят по rerank-score, а не по сюжету.
Temporal RAG восстанавливает цепочку по порядку, с механикой каждого препятствия:
Запретный коридор с Пушком — Гарри играет на флейте от Хагрида, усыпляет трёхголового пса, проходят под мантией-невидимкой.
Дьявольские силки — Гермиона вспоминает травологию: растение боится света и тепла.
Зал с летающими ключами — нужно поймать нужный ключ.
Шахматная комната — Рон становится конём, жертвует собой ради мата.
Тролль — уже без сознания, проходят мимо.
Зеркало Еиналеж — Гарри получает камень, потому что хочет его защитить, а не использовать.
Это прямое следствие хронологической сортировки text_units и OPERATIONAL-связей («музыка усыпляет Пушка», «силки боятся света»). Здесь graph выигрывает однозначно — и это та задача, которую классический RAG в принципе не умеет.
Вывод по примерам: на прямых фактах — паритет (Classic иногда чище цитирует); на перечислениях — ничья с разными дырами; на рассуждении по связям и цепочках событий — Temporal заметно сильнее.
10. LLM as Judge: честные метрики
Чтобы не оценивать «на глаз», я прогнал ответы через судей — ChatGPT и Claude Opus.
Картина получилась следующая:
на простых и средних вопросах оба RAG работают одинаково — либо оба хорошо, либо оба плохо;
на сложных вопросах Temporal работает чуть лучше — но разница незначительная.
То есть да, temporal подход выигрывает там, где должен (связи, цепочки), но на этом объёме — одна книга, 334 чанка — выигрыш не такой драматичный, как хотелось бы.
11. Стек и цифры
Индекс «философского камня»:
Метрика | Значение |
|---|---|
Text Units (чанков) | 334 |
Entities | 1759 |
Relationships | 2389 |
Communities (окон) | 23 |
Embedding dim | 1024 |
Размер чанка | ≤510 токенов |
FAISS | IndexFlatL2 |
Модели:
Модель | Роль | Особенность |
|---|---|---|
Qwen3-30B-A3B-FP8 | LLM | MoE: ~3B активных из 30B, контекст 16K |
deepvk/USER-bge-m3 | Embeddings | 1024-dim, мультиязычная, хороша на русском |
BAAI/bge-reranker-v2-m3 | Reranker | cross-encoder, точнее bi-encoder |
Эксперимент с промптом извлечения графа. Тестировал 3 варианта:
Вариант | Entities/чанк | Rel/чанк | Качество | Скорость |
|---|---|---|---|---|
1. Строгий одноэтапный | 11.2 | 12.6 | 100/100 | 1.7 мин/чанк |
2. Двухэтапный (извлечение+фильтр) | 21.2 | 25.2 | 97/100 | 4.5 мин/чанк |
3. С типизацией + «НЕ ИЗВЛЕКАЙ» | 9.4 | 9.4 | 100/100 | 1.5 мин/чанк |
Выбрал вариант 3: меньше сущностей → меньше шума в графе; типизация OPERATIONAL/FACTUAL/NARRATIVE из коробки → не нужен отдельный шаг классификации; конкретные антипримеры → LLM чётко понимает задачу; и он же самый быстрый — 1.5 мин/чанк × 334 ≈ 8.3 часа на книгу.
12. Выводы
Что я вынес из этого проекта:
Правильный контекст — залог успеха. Не модель и не алгоритм поиска решают, а то, что в итоге оказалось в окне LLM. Весь Temporal Graph RAG — это машина по сборке правильного контекста.
Чтобы был хороший контекст, нужно хорошо нарезать. Скучный этап чанкинга (Docling-фиксы, склейка фрагментов, границы глав) дал больше прироста качества, чем любой «умный» поиск поверх плохих чанков.
Потенциал у Graph RAG есть — но на небольших объёмах он раскрывается слабо (ИМХО). На одной книге Temporal выигрывает на сложных вопросах, но незначительно. Сложность построения индекса (часы LLM-извлечения) пока не окупается на маленьком корпусе.
Собственная реализация вместо MS GraphRAG дала гибкость, которой мне не хватало: составной ключ сущности, sliding-window сообщества вместо Leiden, двухуровневый реранкинг с контекстом, 4-этапный отбор чанков, token budget, checkpoint-система.
Мне кажется, настоящий потенциал Graph RAG — на формальных документах (тех же банковских), где много жёстких сущностей, ролей и связей, а не на художественном тексте. Это и буду проверять в рабочем проекте.
Весь код, промпты и инструкция по воспроизведению:
https://github.com/Vasily-Sizov/temporal_graph_rag
Вопросы и критика — велкам. До скорых встреч!



























