[nevr]
· 8 мин чтения

71% нод без связей: как я построил граф, который не работал как граф

1083 ноды. 388 рёбер. Я смотрел на дашборд и гордился: Knowledge Graph растёт, сущности извлекаются, пользователи работают. Потом запустили один SQL-запрос — и картина рухнула.

SELECT COUNT(*) FROM kg2_nodes
WHERE id NOT IN (
  SELECT source_node_id FROM kg2_edges
  UNION
  SELECT target_node_id FROM kg2_edges
);
-- Результат: 769

769 из 1083 нод не имели ни одной связи. 71%. Мой “граф знаний” был облаком точек.

Как я это обнаружил

Не потому что был внимателен. Пользователь задал вопрос: “Покажи все боли, которые связаны с моим основным сегментом”. Ответ: пусто. При этом в проекте было 14 болей и 3 сегмента. Факты были извлечены. Ноды существовали. Но между ними не было ни одного ребра.

Я проверил ещё 10 проектов. Паттерн везде один: ноды создаются, рёбра — нет. Среднее соотношение edges/nodes — 0.36. В здоровом графе знаний эта метрика должна быть 2-5.

Три корневые причины

1. Метрический перекос в extraction pipeline

Мой EntityExtractor оптимизировался на количество извлечённых сущностей. Больше нод = лучше работает. Никто не мерил плотность связей. В промпте было:

Extract all entities from the text: pains, segments, competitors,
features, trends, constraints...

LLM послушно генерировал 5-8 нод на сообщение. Но RelationDiscoverer, который должен был связать новые ноды с существующими, получал их уже после — отдельным вызовом, без контекста исходного сообщения. Он видел две ноды и пытался угадать, как они связаны. Часто не мог.

2. Мусорные лейблы

Когда LLM извлекает “сущности” из свободного текста, он щедр. Из сообщения “Наши клиенты — владельцы малого бизнеса, которые тратят 40% времени на рутину” я получал:

  • segment: владельцы малого бизнеса — полезно
  • pain: тратят 40% времени на рутину — полезно
  • metric: 40% — мусор
  • constraint: время — мусор

Два мусорных нода из четырёх. Они никогда не получат связей, потому что “40%” и “время” — слишком абстрактны для осмысленного ребра. Но они раздувают счётчик нод и создают иллюзию растущего графа.

3. Разреженные рёбра

RelationDiscoverer работал в режиме “осторожного минимализма”. Промпт требовал высокой уверенности:

Only create edges when the relationship is EXPLICITLY stated
in the source text. Do not infer.

Благородная цель — избежать галлюцинаций. Катастрофический результат — граф, в котором связи существуют только когда пользователь буквально произнёс “сегмент X испытывает боль Y”. Люди так не разговаривают. Они говорят “Наши клиенты жалуются на медленную зарядку” — здесь имплицитно и сегмент, и боль, и связь между ними. Но “EXPLICITLY stated” это не пропускает.

Почему я не заметил раньше

Потому что мерил не то. Мои метрики:

  1. Количество нод — росло. Хорошо.
  2. Количество рёбер — тоже росло. Хорошо.
  3. Время extraction — стабильно. Хорошо.
  4. Ошибки — ноль. Хорошо.

Всё зелёное. Всё работает. Никто не спросил: “А какой процент нод вообще связан?”

Это классическая ловушка vanity metrics. Граф из 1000 нод и 400 рёбер выглядит внушительно в репорте. На практике 700 нод болтаются в пустоте, а 300 связанных — это 20-30 маленьких кластеров по 3-5 нод, не соединённых между собой.

Граф без связей — не граф. Это таблица с модным названием.

Как починили

Шаг 1: Чистка мусора

Добавили фильтр в EntityExtractor. Правила жёсткие:

  • Ноды типа metric без числового значения — отклонять
  • Лейблы короче 3 слов для типов pain, segment, feature — отклонять
  • Дубли по canonical_key с Levenshtein distance < 3 — мержить

Одна чистка убрала 340 мусорных нод. Граф уменьшился, но стал честнее.

Шаг 2: EntityLinker — связывание на этапе извлечения

Главное архитектурное изменение. Раньше пайплайн работал так:

Message → EntityExtractor → [nodes] → RelationDiscoverer → [edges]

Теперь:

Message → EntityExtractor+Linker → [nodes + edges одновременно]

LLM получает не только текст сообщения, но и существующие ноды проекта. Новый промпт:

Given this message and existing entities in the project,
extract NEW entities AND relationships between them
(including relationships to existing entities).

Один вызов вместо двух. LLM видит контекст: “В проекте уже есть segment:solo_founder. Новое сообщение говорит о боли с зарядкой. Эта боль относится к этому сегменту”. Связь создаётся сразу.

Шаг 3: Перезапись промпта RelationDiscoverer

Убрали “EXPLICITLY stated”. Заменили на калиброванные уровни:

Confidence 80-100: relationship explicitly stated
Confidence 50-79: relationship strongly implied by context
Confidence 30-49: relationship inferred from domain knowledge
Below 30: do not create

Это позволило фиксировать имплицитные связи (которых в человеческой речи большинство) с пониженным confidence. Лучше связь с confidence 55, чем orphan node.

Шаг 4: Метрика здоровья графа

Добавили в KgHealthCheckJob:

orphan_pct = nodes_without_edges.count.to_f / total_nodes * 100
alert(:critical) if orphan_pct > 40
alert(:warning) if orphan_pct > 25

Теперь orphan rate — метрика первого класса, наравне с количеством нод. Алерт летит в Telegram если граф “расползается”.

Результат

До починки: 1083 ноды, 388 рёбер, 71% orphans.

После: 959 нод (после чистки мусора), 651 ребро, 53% orphans. Edges/nodes ratio вырос с 0.36 до 0.68. Ещё не идеально — но граф впервые стал работать как граф.

Запросы вроде “покажи боли сегмента X” стали возвращать результаты. Artifact generation получил реальный контекст вместо разрозненных фактов. Работа продолжается — цель ниже 25%.

Три урока

Мерьте связность, а не объём. 1000 нод без рёбер хуже, чем 200 нод с 500 рёбрами. Граф — это рёбра. Ноды без связей — это JSON-массив, притворяющийся графом.

Extraction и linking — одна операция, не две. Если LLM создаёт ноду, он должен сразу решить, к чему она подключается. Разделение на два этапа теряет контекст, который невозможно восстановить.

“Не инферить” — плохой промпт для extraction. Люди не говорят эксплицитно. 90% связей между сущностями имплицитны. Если extraction промпт запрещает inference — вы получите разреженный граф, где видны только очевидные вещи. Вместо бинарного “извлекай / не извлекай” используйте калибровку confidence: чем больше inference — тем ниже вес.


Граф знаний — не фича, которую можно “добавить” и забыть. Это живая структура, которая деградирует без правильных метрик. Я узнал это, когда 71% моих данных оказались островами без мостов. Надеюсь, вам не придётся учиться на собственных orphan nodes.


Посмотрите, как работает Knowledge Graph — AICPO или напишите nevr@aicpo.com