惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
S
Securelist
V
Vulnerabilities – Threatpost
C
Cyber Attacks, Cyber Crime and Cyber Security
Schneier on Security
Schneier on Security
Cyberwarzone
Cyberwarzone
Simon Willison's Weblog
Simon Willison's Weblog
Hacker News - Newest:
Hacker News - Newest: "LLM"
P
Palo Alto Networks Blog
T
Troy Hunt's Blog
SecWiki News
SecWiki News
Security Archives - TechRepublic
Security Archives - TechRepublic
T
The Blog of Author Tim Ferriss
Project Zero
Project Zero
Microsoft Security Blog
Microsoft Security Blog
The Register - Security
The Register - Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
J
Java Code Geeks
F
Full Disclosure
阮一峰的网络日志
阮一峰的网络日志
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Attack and Defense Labs
Attack and Defense Labs
Know Your Adversary
Know Your Adversary
WordPress大学
WordPress大学
PCI Perspectives
PCI Perspectives
N
News | PayPal Newsroom
The Last Watchdog
The Last Watchdog
酷 壳 – CoolShell
酷 壳 – CoolShell
P
Privacy & Cybersecurity Law Blog
P
Proofpoint News Feed
V
Visual Studio Blog
C
CERT Recently Published Vulnerability Notes
H
Help Net Security
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
云风的 BLOG
云风的 BLOG
月光博客
月光博客
T
The Exploit Database - CXSecurity.com
I
InfoQ
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
U
Unit 42
腾讯CDC
小众软件
小众软件
V2EX - 技术
V2EX - 技术
罗磊的独立博客
Cloudbric
Cloudbric
Recorded Future
Recorded Future
IT之家
IT之家
Google DeepMind News
Google DeepMind News
C
CXSECURITY Database RSS Feed - CXSecurity.com

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Graph Rag и «Гарри Поттер»
Василий Сизов · 2026-06-22 · via Все публикации подряд на Хабре

Всем привет! Меня зовут Василий Сизов и я работаю лидером кластера "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

Qwen/Qwen3-30B-A3B-Instruct-2507-FP8

8001

80%

Генерация, извлечение графа, ответы

embed-ru

deepvk/USER-bge-m3

8006

10%

Эмбеддинги (1024-мерный вектор)

reranker-ru

BAAI/bge-reranker-v2-m3

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 этапа:

  1. Извлечение элементов с метаданными. По каждому текстовому элементу собираем текст, номера страниц и текущую иерархию заголовков.

  2. Склейка незавершённых фрагментов. Docling иногда рвёт абзац на куски. Если элемент не кончается на ., !, ?, — клеим со следующим. Тонкость: номера страниц при склейке не объединяем, иначе один чанк накопил бы page_numbers=[5,6,7,8,9,10…].

  3. Разбиение на предложения — regex под русский: точка/!/?/, пробел, затем заглавная (рус/лат), «, дефис или цифра.

  4. Сборка чанков с учётом границ глав. Ключевое: если у предложения сменились заголовки — текущий чанк принудительно закрывается, новый начинается с новой главы. Размер считаем настоящим токенайзером эмбеддера, лимит — 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

Двухстадийный процесс:

  1. Извлечение сущностей и связей. LLM проходит по чанкам и достаёт сущности, классифицирует их по типам (PERSON, LOCATION, OBJECT, …), пишет описание каждой сущности и каждой связи, а у связи ещё и strength score — числовую силу отношения.

  2. Консолидация. Одна и та же сущность встречается в десятках чанков. Её описания из разных мест 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

Нет временной структуры (порядка событий)

relative_position, хронологическая сортировка везде

Одна сущность «Гарри» на всю книгу, без контекста главы

Составной ключ (name, book, chapter)

Один уровень реранкинга

Двухуровневый реранкинг: 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

Для каждого окна:

  1. собираем все entity_keys и relationship_ids из его чанков;

  2. сортируем по хронологии (book_number, chapter) — критично, чтобы LLM видел события в правильном порядке;

  3. формируем промпт (до 50 сущностей + до 50 связей) с временным контекстом;

  4. 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

"{name}: {description}"

«Кто такой Дамблдор?» → находим сущность

Community

community.report целиком

«Что было после прибытия в Хогвартс?» → находим окно

Text Unit

contextualized_text

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, для сущностей; здесь — второй, для чанков).

  1. Per-entity reranking. Для каждой сущности реранкер оценивает её text_units в контексте этой сущности: запрос обогащается → "{query}. Контекст: {entity. name}: {entity.description[:200]}". Один и тот же чанк может быть релевантен в контексте «Гарри» и нерелевантен в контексте «Хагрид» — реранкер видит этот нюанс.

  2. Глобальная сортировка всех text_units со всех сущностей по rerank-score.

  3. Token budget. Идём сверху вниз, считаем токены настоящим токенайзером Qwen3, останавливаемся на 55% контекстного окна. Тексты обрезаем до 2000 символов.

  4. Финальная хронологическая сортировка отобранных по 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. Прямой ответ: Платформа номер девять и три четверти.

  2. Подтверждающие цитаты: «…надо сесть на поезд, который отходит в одиннадцать часов утра от платформы номер девять и три четверти» (Документ 1); «…арка с табличкой: „Платформа номер девять и три четверти"» (Документ 3).

  3. Ссылки: Документ 1, 3, 4.

Temporal RAG:

  1. Платформа имеет номер (девять и три четверти).

  2. Подтверждающие детали: «Гарри Поттер должен прибыть на платформу в первый день сентября»; «…отправляющийся в одиннадцать утра с платформы девять и три четверти»; «Платформа 9¾ — скрытая платформа, ведущая в Хогвартс, доступная только магам».

  3. Источники: 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 идёт дальше — рассуждает по связям:

  1. Квиррелл не знал, как пройти мимо Пушка.

  2. Хагрид, владевший Пушком, единственный знал способ и никому не рассказывал.

  3. В контексте: «Да ни одна живая душа не знает, вот как! Кроме меня… и Дамблдора» — гордо заявил Хагрид.

  4. Следовательно, Квиррелл не мог узнать путь от Хагрида.

Это уже вывод по графу связей, а не пересказ ближайших чанков. Ровно тот класс вопросов, ради которого всё и затевалось.


Вопрос 5: «Какие препятствия последовательно проходят Гарри, Рон и Гермиона на пути к философскому камню?» (главный кейс — цепочка событий)

Classic RAG находит 2–3 чанка про отдельные препятствия, путается и ошибочно приписывает Гермионе «лабиринт», сам же себя поправляет — и не выстраивает последовательность: чанки приходят по rerank-score, а не по сюжету.

Temporal RAG восстанавливает цепочку по порядку, с механикой каждого препятствия:

  1. Запретный коридор с Пушком — Гарри играет на флейте от Хагрида, усыпляет трёхголового пса, проходят под мантией-невидимкой.

  2. Дьявольские силки — Гермиона вспоминает травологию: растение боится света и тепла.

  3. Зал с летающими ключами — нужно поймать нужный ключ.

  4. Шахматная комната — Рон становится конём, жертвует собой ради мата.

  5. Тролль — уже без сознания, проходят мимо.

  6. Зеркало Еиналеж — Гарри получает камень, потому что хочет его защитить, а не использовать.

Это прямое следствие хронологической сортировки 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. Выводы

Что я вынес из этого проекта:

  1. Правильный контекст — залог успеха. Не модель и не алгоритм поиска решают, а то, что в итоге оказалось в окне LLM. Весь Temporal Graph RAG — это машина по сборке правильного контекста.

  2. Чтобы был хороший контекст, нужно хорошо нарезать. Скучный этап чанкинга (Docling-фиксы, склейка фрагментов, границы глав) дал больше прироста качества, чем любой «умный» поиск поверх плохих чанков.

  3. Потенциал у Graph RAG есть — но на небольших объёмах он раскрывается слабо (ИМХО). На одной книге Temporal выигрывает на сложных вопросах, но незначительно. Сложность построения индекса (часы LLM-извлечения) пока не окупается на маленьком корпусе.

  4. Собственная реализация вместо MS GraphRAG дала гибкость, которой мне не хватало: составной ключ сущности, sliding-window сообщества вместо Leiden, двухуровневый реранкинг с контекстом, 4-этапный отбор чанков, token budget, checkpoint-система.

  5. Мне кажется, настоящий потенциал Graph RAG — на формальных документах (тех же банковских), где много жёстких сущностей, ролей и связей, а не на художественном тексте. Это и буду проверять в рабочем проекте.

Весь код, промпты и инструкция по воспроизведению:
https://github.com/Vasily-Sizov/temporal_graph_rag

Вопросы и критика — велкам. До скорых встреч!