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” это не пропускает.
Почему я не заметил раньше
Потому что мерил не то. Мои метрики:
- Количество нод — росло. Хорошо.
- Количество рёбер — тоже росло. Хорошо.
- Время extraction — стабильно. Хорошо.
- Ошибки — ноль. Хорошо.
Всё зелёное. Всё работает. Никто не спросил: “А какой процент нод вообще связан?”
Это классическая ловушка 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