Почему мы не написали ещё один Bad CaRMa. Bad CaRMa.. Bad CaRMa. Datomic.. Bad CaRMa. Datomic. EAV.. Bad CaRMa. Datomic. EAV. PostgreSQL.. Bad CaRMa. Datomic. EAV. PostgreSQL. ии-агенты.. Bad CaRMa. Datomic. EAV. PostgreSQL. ии-агенты. модель данных.. Bad CaRMa. Datomic. EAV. PostgreSQL. ии-агенты. модель данных. память агента.

«Bad CaRMa» — глава из Dreaming in Code Скотта Розенберга (каламбур на CRM и «карму») про CRM-систему Vision в компании Upstart. Архитектор задумал предельно гибкую схему: одна-единственная таблица DATA, куда сложили все 150+ бизнес-сущностей — 240+ колонок с именами вроде string82 и numeric31, метаданные и данные вперемешку. Схему ведь больше «никогда не придётся менять».

Почему мы не написали ещё один Bad CaRMa - 1

Чем кончилось: простой поиск клиента требовал 6–10 рекурсивных self-join’ов к DATA (сложный запрос — 12–15), 50 ГБ данных за год обрастали ещё 250 ГБ индексов, производительность встала. Когда внешнему консультанту Джо объяснили, что обучение нового разработчика занимает полгода, а бухгалтерия не может закрыть месяц, он молча написал на доске: «RUN LIKE HELL!». Систему запустили в октябре 1993-го и списали через 13 месяцев; убыток оценили примерно в $10 млн, а Upstart вскоре продали.

А мы строим ровно то, что на бумаге выглядит как первый абзац этой истории: обобщённую модель, где «всё есть термин» и физических таблиц нет. Нас закономерно спрашивают — чем это не очередной Vision. Отвечаю честно и технически, потому что ответ и есть самое интересное.

И сразу снимаю возражение, которое иначе повиснет над всем текстом: это не свежая ставка, которую мы обкатываем на памяти агента. Обобщённой модели-квартету — больше двадцати лет. Идея оформилась в 2002-м; в веб-архиве сохранились сайты 2003–2005 годов, уже работавшие на IDEAV; в производстве она с 2006-го, в нынешнем виде структура устоялась к 2008-му, с 2015-го на ней ведётся разработка в разных проектах. Vision-образную модель мы не изобретаем заново — мы четверть века живём внутри неё. Потому и про грабли ниже говорим предметно: не «а вдруг рванёт», а «вот где рвануло и что мы с этим сделали».

Почему это вопрос жизни и смерти для памяти агента

Может показаться, что от истории 1993 года до ИИ-агентов далеко. Близко, и вот почему.

Память агента из первой статьи — это граф: узлы (заметки, тикеты, решения) и типизированные рёбра между ними («из-за этого», «исправлено вот этим»). Весь причинный recall, который поднял поиск прошлого решения с 38% до 87%, — это обход такого графа. И лежит он не в графовой СУБД, не в pgvector и не в векторном движке, а в одной-единственной таблице из четырёх колонок — в том самом «всё есть термин».

То есть вопрос «а не Bad CaRMa ли это» — не академический спор о вкусах в моделировании данных. Если обобщённая модель действительно обречена на 6–10 self-join’ов и деградацию индексов, то обречена и память: обход графа станет неподъёмным ровно тогда, когда база дорастёт до интересного размера, и все цифры первой статьи превратятся в лабораторный курьёз. Поэтому дальше — не рассуждения, а разбор четырёх конкретных граблей и замер на живой базе.

Чем на самом деле плох «голый» подход общего назначения

Vision — крайняя форма, но семейство одно: обобщённая модель, где схема сама становится данными. Будь то одна широкая таблица DATA с колонками string82, как у Vision, или классический Entity-Attribute-Value (строка на каждый атрибут: сущность, атрибут, значение) — грабли всегда одни и те же четыре:

  1. Нет типов. Всё — строка. значение = "2026-13-99" ляжет так же охотно, как валидная дата. Проверки расползаются по прикладному коду или отсутствуют.

  2. Невозможно запросить. Чтобы собрать одну «запись», нужно джойнить таблицу с собой раз за разом — те самые 6–10 self-join’ов у Vision. Простой отчёт превращается в SQL, который никто не хочет писать и который планировщик не хочет исполнять.

  3. Нет производительности. Неиндексированный генерик сканирует, индексы деградируют, кэш не работает. На объёме это стена — Vision встал.

  4. Inner-platform effect. Вы, сами того не замечая, пишете свою СУБД поверх СУБД — только хуже родной.

«Bad CaRMa» — это когда вы получили все четыре сразу и назвали это гибкостью.

Что мы сделали иначе: квартет + контроллер + индексы

Наша модель — «квартет» (id, up, t, val)id записи, up — родитель (иерархия/подчинённость), t — тип, val — значение. Да, это семейство EAV. Разница — в трёх слоях, которых у «голого» EAV нет, и именно их отсутствие и делает EAV «плохой кармой».

Первое — настоящая система типов поверх EAV. Базовые типы (число, дата, текст, ссылка, …) зашиты в ядро; всё, что за их пределами (t больше граничного значения) — пользовательский тип-термин, в том числе ссылка на другой термин. «Колонка» у нас — не свободная строка, а именованный типизированный термин; ссылка — не текст "user_42", а настоящая связь (хранится инвертированно: t = цель, val = реквизит). Типовая дисциплина живёт в модели, а не вручную в разбросанном по коду наборе проверок.

Второе — контроллер. Никто и никогда не пишет тот самый десятикратный self-join руками. Контроллер отдаёт строки, таблицы и отчёты; сами отчёты компилируются в SQL — включая нативный WITH RECURSIVE для иерархий и обхода связей. EAV остаётся под капотом, наверху — привычные таблицы и запросы. Это ровно тот слой, которого не было у Bad CaRMa: тот, что делает генерик-модель пригодной для людей (а в нашем случае — и для ИИ-агентов).

Третье — индексы, а не надежда. Композитные покрывающие индексы по (t, val)(up, t), отдельные рёберные индексы для связей. Доступ идёт по индексному пути, а не сканом. И это не теория: та же однотабличная архитектура квартета — партиции по диапазонам id, разнесённые по физическим дискам — проверена на 31 млрд квартетов и 4.5 ТБ данных: на загруженной базе точное совпадение отдаётся за 0.77 с, поиск по маске — за 1.57 с, сбор всех реквизитов записи — за 0.95 с.

Но чужие числа — слабый аргумент, поэтому вот наши, на той самой базе памяти, где живёт корпус из четвёртой статьи (это будет про историю тикетов ydb-platform/ydb, кратко упомянутую здесь) : 133 731 квартет, 418 типов, 168 МБ данных и 40 МБ индексов. Ноутбучный Postgres, никаких партиций и отдельных дисков. Время — установившееся, на полностью прогретом кэше:

Операция

План

Время

Собрать запись со всеми реквизитами (up = N) — то, что у Vision требовало 6–10 self-join’ов

Index Scan

0.16 мс

Точное совпадение по значению внутри типа

Index Scan

0.08 мс

Обход связей WITH RECURSIVE на 3 уровня — «граф» без графовой СУБД

Index Scan внутри Recursive Union

0.30 мс

Скрипт замера печатает план вместе со временем. Пункт «нет производительности» снимается планом запроса.

Где на этом попались мы сами

Готовя этот замер, мы прогнали EXPLAIN по остальным своим горячим запросам — и один оказался Seq Scan’ом. Точка входа в ANN-индекс искала служебные маркеры по val LIKE 'префикс%', не задавая при этом ни t, ни up. Все наши индексы — с ведущей колонкой t или up, поэтому ни один не применим; а обычный btree в не-C коллации префиксный LIKE вообще не обслуживает. Результат: 1.1 с на 32 тысячах квартетов и около 4 с на 133 тысячах — на каждый recall, линейно по N. То есть ровно третьи грабли из списка выше, в системе, которая этот список и объясняет.

Лечение — частичный индекс (val text_pattern_ops) WHERE length(val) <= 32 плюс тот же страж длины в запросе (без него планировщик не докажет применимость частичного индекса). Маркеры короткие, а длинные значения — векторы и тексты — маркерами быть не могут и в индекс не попадают: 688 КБ на 133 тысячи квартетов. Тот же запрос после правки:

Корпус

Было

Стало

32 223 квартета

Seq Scan, 1152 мс

Index Scan, 0.15 мс

133 731 квартет

Seq Scan, 3889–4119 мс

Index Scan, 0.09–0.12 мс

Вывод не «мы молодцы, что починили», а про метод: «у нас есть индексы» — свойство не схемы, а каждого конкретного запроса, и проверяется оно EXPLAIN‘ом, а не убеждённостью. Граница между Bad CaRMa и рабочей генерик-моделью проходит ровно здесь — и, как видите, случайно оказаться не на той стороне может кто угодно, включая авторов статьи о том, как не оказаться не на той стороне.

По пунктам: типы → есть; запросы → через контроллер и отчёты, без ручных джойнов; производительность → индексы плюс партиционирование. Остаётся четвёртый — inner-platform effect. И тут самая честная часть.

Про inner-platform: мы написали платформу не «случайно», а нарочно

Inner-platform effect плох, когда он случайный и недоделанный: хотели пару гибких полей — получили недо-СУБД, о существовании которой не подозревали. Мы же с самого начала строим именно платформу — с контроллером, моделью прав, отчётами и API. Это не побочный эффект, это цель. Граница между «Bad CaRMa» и осознанным метадвижком проходит не по генеричности как таковой, а по тому, доделали ли вы три недостающих слоя или остановились на голой тройке колонок.

Здесь удобно назвать вторую точку отсчёта — противоположную Bad CaRMa.

Правильная параллель: Datomic

Datomic (Рич Хикки, Cognitect) — это генерик-EAV, сделанный правильно. Датомы (сущность, атрибут, значение, транзакция), неизменяемость и append-only, запросы на Datalog, встроенное «путешествие во времени». Инженерно — цельно и красиво; часть идей мы разделяем.

И при этом Datomic так и не стал мейнстримом: долгие годы проприетарный и платный (бесплатным он стал только в апреле 2023-го — «Datomic is Free», причём лицензия Apache 2.0 распространяется на бинарники, а исходники остаются закрытыми), завязанный на Clojure/JVM, нишевый. Вывод, который мы держим про самих себя: хорошая инженерия сама по себе не гарантирует принятия. Между Bad CaRMa (плохой EAV, провалившийся технически) и Datomic (хороший EAV, технически отличный, но нишевый) есть третий сценарий — сделать модель правильно и не проиграть в эргономике, экосистеме, дистрибуции и тайминге. Первую половину, «сделать правильно», мы, кажется, закрыли. Вторую — ещё предстоит, и мы не делаем вид, будто элегантность схемы кому-то её заменит.

Вывод для тех, кто пишет свой EAV-велосипед

Генеричность (общее назначение) — не грех. Грех — остановиться на трёх колонках и назвать это гибкостью. Если вы строите обобщённую модель, честно спросите себя, есть ли у вас система типовконтроллер, снимающий ручные self-join’ы, и реальные индексы — причём последнее проверяется не по схеме, а EXPLAIN‘ом по каждому горячему запросу, мы только что показали почему. Нет хотя бы одного — вы пишете Bad CaRMa.

Есть все три — поздравляю, это хорошая инженерия. У нас они собраны в конструкторе Интеграм; но разбор выше — про модель, а не про инструмент: те же три вопроса задавайте любому своему EAV. И тут же начинается отдельная, куда более трудная работа под названием «чтобы этим кто-то стал пользоваться».

А для памяти агента из первой статьи ответ теперь конкретный: граф причинных связей, который достаёт прошлое решение в 94.5% случаев вместо 32%, живёт в этой самой таблице из четырёх колонок — и обходится за десятые доли миллисекунды.

Спасибо!

Автор: ideavi

Источник