Data Ingestion 4: Addendum про GraphRAG

Data Ingestion 4: Addendum про GraphRAG

May 10, 2026

Будувати GraphRAG у моєму поточному контексті — з мілітарною термінологією, суржиком, відсутністю нормальних NER-моделей для української мови (принаймні за нашим доменом), та жорсткими обмеженнями по залізу — це задача не зовсім тривіальна.

image

Але оскільки деякі метрики (Context Recall на multi-hop запитах) вже показують межу можливостей гібридного векторного пошуку — почасти через слабку заповненість бази знань, почасти через фундаментальні обмеження dense retrieval на запитах, що вимагають об'єднання інформації з кількох документів — перехід до графів стає логічним наступним кроком для оцінки.

Нижче — розбір того, як GraphRAG працює, які варіанти існують взагалі і мої думки, як інтегрувати його в існуючу архітектуру без переписування з нуля.


Частина 1: GraphRAG у теорії (Як це працює взагалі)

image

Ми вже кілька разів повторювали цю концепцію, але гадаю, що слід повторити ще раз: у класичному RAG ми ріжемо текст на шматки (чанки) і шукаємо математично найближчі вектори у dense або hybrid просторі.

GraphRAG підходить до задачі інакше: на етапі інгестії з тексту витягуються сутності (вузли, nodes) і зв'язки між ними (ребра, edges), які формують Knowledge Graph. На етапі retrieval ми не шукаємо схожі тексти — ми ходимо по графу від релевантного вузла до сусідів.

Існує два основних підходи:

Triplet-based GraphRAG (іноді називають "naive").

З тексту витягуються триплети у формі [Суб'єкт] → (ПРЕДИКАТ) → [Об'єкт]. Наприклад: [Користувач] → (ПОДАЄ) → [Рапорт]. Під час запиту система знаходить вхідний вузол і обходить його сусідів на задану глибину. Простий, передбачуваний, добре працює для локального пошуку — "розкажи мені все, що пов'язано з конкретною сутністю X".

Microsoft GraphRAG (hierarchical).
Поверх базового графа будується додатковий шар: вузли кластеризуються в "ком'юніті" через алгоритм Leiden, для кожного ком'юніті LLM генерує summary, потім ці summary самі кластеризуються на наступному рівні.

Це дає змогу відповідати на global queries типу "які основні теми у цьому корпусі" або "узагальни мені сутність цієї бази знань" — питання, на які класичний RAG відповідати не вміє в принципі. Але ціна цього — значно дорожча інгестія (LLM генерує summary для кожного ком'юніті на кожному рівні) і повільніший retrieval. Для саппорт-тікетів, де питання конкретні і локальні ("у мене не працює X"), ця надбудова не надто корисна — користувач ніколи не питає "узагальни мені всю базу знань про помилки авторизації".

Натомість для корпоративного юзкейсу з технічними даними по проєктам, чи в певних наукоємних сферах, чи банально для вибудови “розумного” пошуку на великих і пов’язаних корпусах (наприклад, по книзі, серії робіт, etc.) вочевидь ліпше підійде другий підхід.

Для мого юзкейсу релевантний перший підхід — точний локальний пошук по сусідніх вузлах, інтегрований з поточним векторним пошуком як паралельний індекс.


Частина 2: Як побудувати це у моєму контексті

image

Враховуючи мою специфіку (Bookstack CMS для довгих текстів, Excel для Q&A, та користувацькі запити типу "вибиває, підари блядь"), ось як я схематично бачу архітектуру.

Крок 1: Жорстка Онтологія (Схема)

Перше і найважливіше архітектурне рішення — не дозволяти LLM самостійно вирішувати, які типи сутностей існують у домені.

Якщо дати моделі свободу на етапі екстракції ("витягни всі важливі сутності з цього тексту"), на виході ви отримаєте 500 різних типів вузлів: Документ, Папірець, Довідка, Сертик, Папір, Документація. Граф перетвориться на смітник, бо кожен новий шматок тексту LLM буде іменувати по-різному.

Замість цього треба заздалегідь зафіксувати схему даних — closed-world ontology, де набір типів вузлів і ребер визначений і обмежений. Для саппорт-домену це може виглядати так:

  • Типи вузлів: SystemComponent (А+ — навколоармійський застосунок), Document (Рапорт, Сертифікат), Action (Авторизація, Підписання), Error (Не завантажується, Помилка даних).

  • Типи зв'язків: REQUIRES (вимагає), RESOLVES (вирішує), RELATES_TO (стосується), CAUSED_BY (спричинено).

На етапі екстракції LLM дістає промпт, який містить цю схему явно, з прикладами, і інструкцію класифікувати все знайдене у ці типи або відкидати, якщо нічого не підходить. Це дорожче по токенах на одну екстракцію, але економить вам пів року роботи з прибиранням бруду з графа потім. 

Чи можна без LLM? Можна, насправді, але займе по часу надто багато, щоб розглядати для соло-реалізації. Так, я теж вірю в purpose-built narrow domain рішення, просто так виходить не завжди.

Крок 2: Інгестія та Екстракція (This is where the fun begins)

Pipeline інгестії доведеться суттєво розширити. Замість простого перетворення тексту на вектори, треба прогнати "чисті" тексти (Excel-відповіді і Bookstack-статті) через LLM для екстракції сутностей за схемою з кроку 1.

Проблема морфології. Якщо не нормалізувати сутності перед записом у граф, ви отримаєте окремі вузли для Рапорт, Рапорту, Рапортом, репіртУ (з очепяткою), і граф буде дірявий (прям як мл-інженер, який таке зробив, га-га-га) — пошук по Рапорт не знайде зв'язків, які записані під назвою ноди Рапорту. spaCy лематизація вирішує базовий випадок (відмінкові форми), але цього, очевидно, недостатньо.

Чому лише лематизації мало? Сутності в реальних текстах — багатослівні (помилка авторизації в застосунку), і лематизація кожного слова окремо не дає канонічного представлення. Ще гірше з синонімами: помилка входу, проблема з авторизацією, не пускає в систему — це одна сутність для користувача, але три різні рядки для лематизатора. 

Тут, імхо, потрібен додатковий етап entity resolution: після лематизації ви ембедите назву сутності через ту саму multilingual-e5-large і дивитесь, чи не існує вже в графі вузла з cosine similarity вище порогу (наприклад, 0.92). 

Якщо так — приклеюєтеся до існуючого вузла. Якщо ні — створюєте новий. Це той самий dual-store з Qdrant, який потрібен буде і для retrieval (див. крок 3), просто використовується ще й на write-side.

Послідовність дій схематично:

  1. LLM витягує сутності і зв'язки за фіксованою онтологією.

  2. spaCy лематизує назви вузлів (нормалізує морфологію).

  3. Entity resolution через ембединги: шукаємо схожі вузли, які вже є в графі.

  4. Записуємо нормалізований триплет у графову БД, склеюючись з існуючими вузлами, де це можливо.

Приклад. Текст з бази знань: "Для вирішення помилки авторизації в застосунку необхідно оновити дані через ТЦК". Після екстракції і нормалізації: [помилка авторизації] → (REQUIRES) → [оновлення даних], [оновлення даних] → (VIA) → [ТЦК].

Підхід, звичайно, дещо наївний, але не думаю, що прямо зовсім неробочий.

Крок 3: Сховище (Graph DB + Qdrant)

Qdrant викидати не треба, звичайно ж. Сучасний робочий патерн — hybrid graph-vector storage, де структура зв'язків живе у графовій БД, а ембединги назв і описів вузлів — у векторній.

Графова БД: Neo4j або Memgraph. Це найпопулярніший вибір на сьогодні (мається на увазі — open-source / self-hosted, не Neptune або інші cloud-only, їх я не розглядаю з очевидних причин, але логіка роботи з ними була б схожою). 

В чому між ними різниця:

  • Neo4j — індустріальний стандарт, велике community, зрілі інструменти візуалізації (Neo4j Browser, Bloom), широка підтримка. Дисковий движок, хороша персистентність, але повільніший на write-heavy навантаженнях і latency на простих traversal-запитах помітно вища.

  • Memgraph — in-memory движок на C++, сумісний з Cypher (мовою запитів Neo4j), помітно швидший на “гарячому шляху” retrieval. Мінуси: молодше community, менше готових інструментів дебагу графа, більше пам'яті для великих графів (бо все в RAM). 

Для саппорт-юзкейсу з відносно невеликим графом (десятки тисяч вузлів, не мільярди) і чутливістю до latency вибір на користь Memgraph виправданий. Для домену з графом у мільйони вузлів — Neo4j безпечніший варіант.

Qdrant використовується для двох речей: (а) entity linking — знайти, до якого вузла графа прив'язується вхідний користувацький запит (див. крок 4); (б) entity resolution на write-side — склеювати дублікати вузлів при інгестії (див. крок 2). 

В обох випадках ви ембедите назву/опис вузла через multilingual-e5-large і зберігаєте з ID, що матчиться з ID вузла в графі.

Cypher — для тих, хто з графовими БД не працював: це SQL-подібна мова запитів, яку придумали в Neo4j і яка стала фактичним стандартом. Memgraph її повністю підтримує. Виглядає приблизно так: MATCH (e:Error {name: "помилка авторизації"})-[r]-(neighbor) RETURN e, r, neighbor.

Тобто "знайди вузол типу Error з певною назвою і дай мені його разом з усіма сусідами і ребрами".

Крок 4: Retrieval (Пошук по графу з користувацького тікету)

Тут підключається існуючий query rewriter — механізм самокорекції. Граф — це детермінована структура; якщо на вхід приходить "вибиває, підари блядь, дармоєди" він відпрацює не краще, ніж умовний SQL.

Послідовність кроків:

  1. Rewrite. LLM переписує сирий запит у нормалізовану форму: "помилка авторизації в застосунку".

  2. Entity linking (вектор → граф). Чистий запит ембедиться і шукається у Qdrant. Qdrant повертає top-k найбільш релевантних вузлів графа за cosine similarity. На цьому етапі добре поставити поріг релевантності — якщо немає жодного вузла з similarity вище ~0.7, граф для цього запиту нерелевантний, і pipeline падає назад на чистий векторний пошук по чанках.

  3. Graph traversal. Знайдений вузол використовується як точка входу. Cypher-запит до Memgraph: дай мені цей вузол і всіх сусідів на глибину 1–2 хопи. Глибина 1 для простих запитів, 2 для multi-hop ("якщо рапорт ігнорується, що далі"). Глибше за 2 — рідко доцільно: з моїм наївним розумінням станеться вибух кількості вузлів і втрата релевантності результатів пошукового запиту.

  4. Context assembly. Знайдений підграф конвертується назад у текст у формі лінеаризованих фактів: "Помилка авторизації вимагає оновлення даних. Оновлення даних здійснюється через ТЦК". Тут є тонкощі — порядок фактів, чи зберігати назви ребер дослівно, чи перекладати у природну мову — але базова логіка така.

  5. Generation. Зібраний контекст плюс оригінальний запит подаються в Gemma 3 27B для генерації відповіді.


Реальність та Трейдофи (Чи варто це робити?)

image

(Не варто, лол, 20к ГЗ, 5-й рік служби по мобілізації, яке варто, ви шо, йобнулись, ахахаха)

Якщо ви розглядаєте цей шлях, ось інженерні реалії, з якими доведеться стикнутися, на моє обмежене розуміння:

Що виграєте (Pros):

  • Multi-hop reasoning. Це головна причина взагалі дивитися в бік графів. Якщо стаття А каже "Рапорт подається командиру", а стаття Б каже "Якщо командир ігнорує, пиши на ВСП", векторний пошук на запит "що робити, якщо командир ігнорує рапорт" поверне обидві статті, тільки якщо їхні чанки семантично близькі до запиту — а вони можуть і не бути.

    Граф проходить ребром [Рапорт] → (ІГНОРУЄТЬСЯ) → [ВСП] детерміновано.

  • Якщо у вас є eval-сет з multi-hop запитами і RAGAS-замірами Context Recall — це місце, де варто очікувати найбільшого приросту. Цифри будуть залежати від конкретного корпусу і питань; абстрактних "X.XX/5 → Y.YY/5" обіцянок без вимірювань я б не давали, бо це Ragas, він варіативний навіть на одному й тому ж eval-сеті, бо вся ця історія по своїй суті стохастична.

  • Менше впевнених галюцинацій від моделі. Векторний пошук завжди повертає top-k результатів — навіть коли в базі немає нічого релевантного, він поверне щось з cosine similarity 0.45 і цим засвітить LLM сміттєвий контекст, на якому модель упевнено напише упевнену брехню.

    Графовий traversal або знаходить шлях, або повертає порожньо. Для саппорту це означає, що сапорт-агент скоріше отримає чесне "не знаю" замість галюцинованої відповіді з посиланням на неіснуючу процедуру чи документацію.

Де буде боляче (Cons):

  • Вартість інгестії. Векторизація — це один прогін через embedding-модель, копійки по compute. Екстракція сутностей плюс entity resolution — це один або кілька прогонів через LLM плюс додаткові ембединги і пошук у Qdrant. Орієнтовно — на порядок або два дорожче по часу і токенах на один документ. Кожне оновлення статті в CMS означає, що відповідну ділянку графа треба інвалідувати і проводити повторну екстракцію.

  • Якщо контент-менеджер любить редагувати статті по тричі на день, графовий індекс буде в постійному стані часткової реконструкції. Це треба продумувати на рівні pipeline — incremental updates замість повної переекстракції, queue з дедуплікацією, асинхронна обробка, ніхуйовий інженерний оверхед.

  • Крихкість екстракції. Якщо LLM на одному фрагменті назве вузол ТЦК та СП, а на іншому просто ТЦК, і ваші лематизація плюс entity resolution цього не зловлять — граф розірветься на два непов'язані компоненти. Векторний пошук цю проблему ховає під собою (за рахунок косинусної подібності він би все одно знайшов обидва документи), граф вимагає навколо-ідеального entity resolution для роботи.

    Це означає окремий моніторинг: метрики на кшталт "скільки вузлів у графі", "розмір найбільшої зв'язної компоненти", "вузли з нульовою кількістю ребер" — це ваш сигнал, що екстракція деградує, а це додаткові точки відмови, їх і так вдосталь.

  • Latency. Додавання traversal-етапу до pipeline (який уже містить retrieval, reranking, relevance check, generation) додає, орієнтовно, 100–500мс на простому traversal в Memgraph і помітно більше у Neo4j. Точна цифра залежить від глибини, кількості вузлів-кандидатів, навантаження.

    Для асинхронного фонового сапорту це переважно ок, для real-time чату — треба міряти, може якщо користуватись не self-hosted рішеннями і закриватись UI/UX і стрімити різонінг і відповіді прямо по ходу діла, тоді буде прийнятно.

  • Операційна складність. Ще одна БД у стеку, ще один сервіс для бекапів, моніторингу, оновлень. Якщо команда невелика — це нетривіально. Якщо в команді тільки я — я обригаюсь, чесно. Плюс тримати Memgraph in-memory означає, що при падінні треба швидко відновлюватися з персистентного снепшота, інакше граф треба перебудовувати з нуля.

Короткий висновок

Повноцінний GraphRAG як заміна векторному пошуку на поточному етапі — гарантований оверхед без пропорційного виграшу. Але GraphRAG як паралельний індекс саме для процедурних знань — статей з CMS, де описуються багатокрокові процеси з умовами і альтернативними гілками — це потенційно вигідний крок з конкретним механізмом виграшу.

Архітектурно це виглядає так:

  • Простий Q&A залишається на чистому векторному пошуку. Прямі питання-відповіді не потребують multi-hop, граф там нічого не додає.

  • CMS-статті паралельно інгестяться у векторну базу і в граф.

  • Існуючий relevance gate (або окремий routing-крок) класифікує вхідний запит: "проста довідка" → вектор, "процедурне питання / вимагає multi-hop" → граф з fallback на вектор, якщо entity linking не знайшов опори.

  • Generation залишається на поточній LLM, з контекстом, зібраним з відповідного джерела.

Цей патерн дає змогу інкрементально перевірити гіпотезу: побудувати граф на одному обмеженому піддомені (наприклад, тільки процедури, пов'язані з помилками авторизації), поміряти Context Recall на multi-hop запитах у цьому піддомені проти baseline, і тільки тоді приймати рішення про розширення на весь корпус. Якщо приріст є — масштабуємося. Якщо немає — виносимо код у архів і повертаємося до посилення векторного індексу.

Enjoy this post?

Buy Strange Rin a token

More from Strange Rin