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

Чем кончилось: простой поиск клиента требовал 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 (строка на каждый атрибут: сущность, атрибут, значение) — грабли всегда одни и те же четыре:
-
Нет типов. Всё — строка.
значение = "2026-13-99"ляжет так же охотно, как валидная дата. Проверки расползаются по прикладному коду или отсутствуют. -
Невозможно запросить. Чтобы собрать одну «запись», нужно джойнить таблицу с собой раз за разом — те самые 6–10 self-join’ов у Vision. Простой отчёт превращается в SQL, который никто не хочет писать и который планировщик не хочет исполнять.
-
Нет производительности. Неиндексированный генерик сканирует, индексы деградируют, кэш не работает. На объёме это стена — Vision встал.
-
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, никаких партиций и отдельных дисков. Время — установившееся, на полностью прогретом кэше:
|
Операция |
План |
Время |
|---|---|---|
|
Собрать запись со всеми реквизитами ( |
Index Scan |
0.16 мс |
|
Точное совпадение по значению внутри типа |
Index Scan |
0.08 мс |
|
Обход связей |
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


