1. Введение
1.1. Предыстория
Мы в Motley AI разрабатываем гибкую систему автоматизированной аналитики и отчётности с ИИ для B2B компаний, и это не просто чат с GPT и файлами на выходе или базовая автоматизация через n8n, а платформа с документами, шаблонами, источниками данных различных форматов и constrained AI – то есть, упор на воспроизводимость и детерминизм с минимизацией рисков галлюцинирования. ИИ у нас используется в нескольких случаях:
Агент помогает формировать шаблоны, что можно делать и вручную в веб приложении или по API, но нормальной речью проще, а вручную остаётся лишь править детали.
Можно скормить агенту пример документа и он его обобщает в шаблон, заменяя фактические формулировки, числа, графики и диаграммы на шаблонные элементы с настройками, формулами, промптами.
В процессе резолва/рендеринга шаблона по заданным входным параметрам, большинство шаблонных блоков обрабатываются чисто алгоритмически, однако текстовые блоки могут использовать LLM для описания числовых результатов, составления выводов, гипотез и рекомендаций (особенно если в ваших источниках данных есть подходящий контекст с описанием бизнес-логики), а также для красивого оформления чисел в удобный текст.
Интеграция результатов нашей системы с другими технологиями – часто в ad-hoc формате посредством диалога агента с подключенными MCP к нашей платформе и сторонним сервисам.
Соответственно, нам нужно чтобы и наша программная система, и ИИ агенты могли легко взаимодействовать с различными базами данных понимая их схемы, составлять и запускать удобочитаемые запросы и формулы, определять переиспользуемые запросы для очистки, дедубликации данных и бизнес метрики. И с такими задачами как раз помогают семантические слои!
1.2. Семантические слои
Что это за технология-посредник над базами данных вроде бы неплохо рассказывают эти статьи:
Почему важны семантические слои, и как собрать такой слой на DuckDB,
Семантический слой для Аналитики ключевых метрик – dbt Metrics vs Cube.
От себя скажу, что это нечто вроде data mart с OLAP кубами поверх OLTP баз данных со star schema, а-ля вьюхи с бизнес-метриками над голыми транзакционными данными, только независимые от конкретных типов СУБД – одинаково работая на SQLite, MySQL, Postgres, BigQuery, Snowflake и многом другом. Зачастую их называют headless BI – семантические слои часто используются как источники показателей, высокоуровневых данных в дашбордах или для разработки своих аналитических подсистем.
По существу, семантические слои позволяют определять:
Модели – обёртки над таблицами с метриками и измерениями, и зачастую это не плоская обёртка над таблицей в БД, а полноценный SQL подзапрос, где можно очищать данные или определять виртуальные колонки полезные для последующих вычислений.
Метрики/показатели (measures) – колонки, обычно числовые значения, которые используются для агрегации, и часто агрегация сохраняется в модели как часть метрики (
revenue_avg,revenue_sum,revenue_min/max)Измерения (dimensions) – колонки, которые используются как ключи группировки, часто это просто текстовые/строковые идентификаторы.
Помимо просто такой терминологии поверх колонок и таблиц баз данных, семантические слои привносят дополнительную пользу: кэширование и предагрегация данных, независимость от вида БД и диалекта SQL, и прочее в зависимости от конкретной технологии.
1.3. Мотивирующие недостатки
Сначала мы использовали Cube – современный, опенсорсный, удобный и производительный, написанный на JS/TS с performance-critical частями на Расте, а также имеющий хорошую интеграцию с Питоном, что мы интенсивно использовали для реализации массы кастомного функционала. Однако со временем приходило понимание, что многое из его функционала мы не используем, и слишком многое доопределяем на своей стороны костыльными путями, включая подзапросы / multi-stage queries и инжест БД и автоматическое формирование моделей. Многие семантические слои создавались для разовой ручной настройки моделей (с фиксацией нужных сложных подзапросов там же) и переиспользования командой продуктовых аналитиков, мы же, как B2B сервис, должны уметь легко подключаться к новым БД.
Из альтернатив никакие другие решения не дают нам нужной гибкости и универсальности, поэтому было принято ответственное решение разработать свой семантический слой, с учётом большого опыта команды как применения Куба в нашем проекте, так и разнообразных аналитических компетенций в прошлом – так родился SLayer aka Слеер!
2. Каким получился наш семантический слой
2.1. AI-First интерфейсы
Большинство семантических слоёв исторически проектировались как бэкэнд для BI-систем и дашбордов. Нам же было важно, чтобы слой одинаково удобно работал:
из Python,
через HTTP API,
через CLI,
для AI-агентов.
Мы сразу проектировали систему полноценно работающей по различным интерфейсам, включая конечно же Model Context Protocol (MCP) (через SSE и stdio) с доступом ко всему функционалу, администрированию и мета-данным, чтобы агенты могли помнить детали о конкретных таблицах и полях – для Клода и любого другого агента SLayer выглядит не как база данных, а как набор строго типизированных инструментов с чётким описанием, примерами и понятными текстами ошибок.
При этом, для классического бэкенда остаются доступны и чистый Python SDK, позволяя Слееру работать в одном процессе с нашими скриптами и серверами отдавая Pandas датафреймы; а также привычный HTTP REST API для запуска Слеера в виде отдельного сервиса; и CLI для быстрых вспомогательных задач или автоматизации различных воркфлоу, пайплайнов.
При этом, API и MCP-интерфейс нашей Motley Platform – это, по сути, надмножество над API и MCP SLayer'а: всё, что умеет SLayer, остаётся доступным «как есть» уровнем выше, без лишних прослоек, но с дополнением для работы с воспроизводимыми документами.
2.2. Взрыв кардинальности
"Корректная семантика кардинальности" звучит абстрактно, но на практике сводится к одному инварианту: добавление новой метрики в запрос никогда не должно менять ни количество строк, ни значения других метрик. Это та граница, за которой "обёртка над SQL" превращается в полноценный семантический слой – и именно об это регулярно спотыкаются самописные витрины данных и даже промышленные инструменты.
И тогда получается одна из самых неприятных проблем аналитических запросов – потеря контроля над кардинальностью. Достаточно одного неаккуратного JOIN’а:
SELECT customers.country, SUM(orders.revenue) FROM customers JOIN orders ... JOIN order_items ... GROUP BY 1
и внезапно SUM(revenue) начинает считать значительно большие доходы – руководство сначала разглядывает дашборды и радуется, а потом смотрит на банковские счета и грустит. Затем идёт к разрабам/аналитикам, и те начинают грустить ещё сильнее. И порой такие ошибки выглядят правдоподобно и подолгу живут в продакшене.
Поэтому внутри SLayer мы пришли к идее неявных подзапросов – джоины изолируются в подзапросы автоматически, что предотвращает подобные комбинаторные взрывы и обманчивые значения. Фактически семантический слой начинает выступать не просто абстракция/обёртка над SQL, а система сохранения корректной аналитической семантики.
2.3. Агрегации отделены от метрик
В большинстве классических инструментов (включая Cube и dbt) логика агрегации статична – вы сразу объявляете все нужные метрики на уровне модели:
revenue_sum revenue_avg revenue_min
Для этого надо или чётко знать, что вам понадобится, а что нет, либо, как это часто бывает, топорно объявлять декартово произведение колонок с методами агрегации, что быстро приводит либо к взрывному росту числа метрик, раздуванию моделей и контекстного окна ИИ агентов. Мы пошли немного другим путём:
revenue:sum revenue:avg revenue:min
То есть, метрика описывает суть данных, а способ агрегации определяется уже в запросе. Помимо упрощения модели, это позволяет не хардкодить сложные метрики на уровне SQL, а определять как формулы из нескольких агрегаций или производить агрегации с аргументами:
price:weighted_avg(weight=quantity) last(change(revenue:sum)) revenue:sum / time_shift(revenue:sum, -1, 'year')
2.4. Временной контекст без монструозных запросов
Сравнить текущий период с прошлым годом, посчитать скользящее среднее (rolling average), кумулятивную сумму или когортный LTV – для аналитика или LLM это обычно требует написания многоэтажных SQL-запросов с подзапросами и/или оконными функциями.
Дабы уменьшить когнитивную нагрузку на наших пользователей, будь то человек или ИИ, мы заложили удобные механики для работы со временем в ядро SLayer'a – first, last, change, change_pct, etc. – чтобы всевозможные смещения времени выглядели как простые функции в запросе, а движок сам генерирует диалектный SQL с нужными подзапросами под целевую СУБД – опять же, сохраняя кардинальность оригинального запроса!
2.5. Query Lists aka подзапросы как граф вычислений
На практике часто бывает нужным выполнять сложные, вложенные или многоэтапные запросы:
промежуточнык rollup'ы
дедупликация
оконные вычисления
обогащение метрик
un-pivot
И человеку, и LLM с этим трудно – приходится одновременно удерживать:
гранулярность каждой стадии,
порядок вычислений,
алиасы,
последовательности группировок и агрегаций,
и совместимость между уровнями вложенности.
Часть из этого мы решаем удобными встроенными функциями с неявными подзапросами, но так или иначе полноценный аналитический инструмент должен предоставлять универсальный и гибкий функционал.
Поэтому в SLayer мы сделали Query List – механизм именованных многостадийных запросов, где подзапросы описываются обычными объектами Слеер-запросов в списке/массиве, однако теперь они могут ссылаться не только на модели, но и друг на друга, а движок сам строит DAG (ациклический граф зависимостей) и генерирует итоговый SQL.
2.6. Динамический инжест и Запросы как Модели
Традиционный семантический слой – это статическая вещь, которую дата-инженер настраивает неделями. Но когда в B2B SaaS продукте новый клиент подключает свою БД, мы должны уметь начинать работать с ней здесь и сейчас.
SLayer умеет интроспектировать схему БД на лету, формируя модель или валидируя её на соответствие действительности. Помимо этого, мы реализовали сохранение запросов в модели (как объект в нашем формате, а не чёрный ящик SQL), что позволяет определять более комплексные метрики и переиспользовать сложную логику.
2.7. Помнящие агенты
Все, кто много работал с ИИ агентами знают, как они легко теряют оперативный контекст, насколько трудно, но важно хранить многочисленные детали вида:
Строки в каких таблицах нужно дедуплицировать
Какие поля "грязные" и могут требовать предобработки
Какие поля, метрики deprecated, что вместо чего использовать
Где дата-время с нормализованными часовыми поясами, а где нет
И много других мелких, но важных моментов, влияющих на адекватность аналитических отчётов.
Для этого мы добавили механику «уроков» (learnings) – агент, работающий со SLayer'ом, может сохранять заметки на естественном языке привязанные к каноническим идентификаторам сущностей вида mydb.orders.amount. В следующей сессии, когда агент ищет что-то в моделях, эти заметки возвращаются ранжированными вместе с самой документацией моделей. Удалили колонку – SLayer каскадно почистит теги из всех связанных заметок. Сам поиск – это слияние BM25 по тегам, full-text индекса tantivy по всем сущностям и плотных эмбеддингов через Reciprocal Rank Fusion. Полнотекстовый индекс пересобирается в памяти на каждый запрос – звучит как безумие пока не замеришь: на нескольких тысячах сущностей это миллисекунды, зато мы избавились от целого класса багов связанных с устаревшими индексами.
2.8. Чего пока нет
На данный момент в Слеере отсутствует следующий функционал, который встречается в других технологиях:
Auth/IAM – вместо выбора и навязывания какого-то одного метода аунтентификации и авторизации, мы проектировали SLayer гибким в том числе изнутри, он легко расширяется, поэтому в случае Motley Platform за управление идентификацией и доступом отвечает наша интеграция.
Web UI – в связи с тем, что наши основные интерфейсы это MCP и Python, у нас мало потребности в графическом интерфейсе, хотя возможно со временем мы и его добавим.
Кэширование и пре-агрегация – в наших задачах важно гибкое построение сложных запросов и актуальные данные, это сложно грамотно и эффективно кэшировать, поэтому пока что мы полагаемся на оптимизации СУБД – когда упрёмся в производительность, то будем думать, что и как оптимизировать и кэшировать на нашей стороне. Может перепишем ядро на мой любимый Rust 🦀
2.9. Open Source
SLayer изначально задумывался как внутренняя утилита, но чем дальше мы проектировали, тем больше приходили к пониманию, что эта технология и описанный функционал будут полезны как внутри Motley Platform, так и сами по себе, широкому кругу пользователей. Поэтому мы сразу опубликовали SLayer под MIT – и для прозрачности, и как потенциальную ступеньку к нашему основному продукту, и потому что хорошие инструменты обычно живут и развиваются ярче и дольше за пределами одной компании.
3. На практике
3.1. Пример запроса
Допустим, вам нужен примерно такой запрос:
Метрика: процентное изменение суммы дохода
Время: 2026 год в разбивке по месяцам
Группировка: по имени региона
Фильтр: показать только прирост
Как это можно описать в Слеере, минимум технических деталей, максимум бизнесовых намерений:
{ "source_model": "orders", "measures": [{ "formula": "change_pct(revenue:sum)", "name": "revenue_growth_pct" }], "dimensions": [ "customers.regions.name" ], "time_dimensions": [{ "dimension": "created_at", "granularity": "month", "date_range": ["2026-01-01", "2026-12-31"] }], "filters": [ "change(revenue:sum) > 0" ] }
Какой получится фактический SQL запрос:
WITH monthly_revenue AS ( SELECT r.name AS region, DATE_TRUNC('month', o.created_at) AS month, SUM(o.amount) AS revenue FROM orders o LEFT JOIN customers c ON o.customer_id = c.id LEFT JOIN regions r ON c.region_id = r.id WHERE o.created_at >= '2026-01-01' AND o.created_at < '2027-01-01' GROUP BY r.name, DATE_TRUNC('month', o.created_at) ), with_prev AS ( SELECT region, month, revenue, LAG(revenue) OVER (PARTITION BY region ORDER BY month) AS prev_revenue FROM monthly_revenue ) SELECT region, month, (revenue - prev_revenue) / NULLIF(prev_revenue, 0) AS revenue_growth_pct FROM with_prev WHERE revenue - prev_revenue > 0;
Что из этого проще писать и понимать вам и вашим близким агентам?
3.2. Как запустить
Одним из практических требований к SLayer с самого начала была простота использования и запуска в различных сценариях, включая встраивание в приложение как изнутри Питона, так и по HTTP REST API, использование через CLI в пайплайнах, и конечно же простой MCP для ИИ агентов – и чтобы это всё можно было легко поднять локально без особой инфраструктуры. Поэтому у нас есть много разных способов запуска описанных в документации описанных в Getting Started. Рассмотрим здесь основные:
UV – чтобы использовать Слеер через CLI или по HTTP REST или MCP, проще всего установить SLayer через UV, установить можно так:
uv tool install motley-slayer slayer
Или запустить разово, без формальной установки:
uvx --from 'motley-slayer' slayer serve --demo
Классическая установка с PyPI – Если нужно встроить в питонячий скрипт или приложение:
pip install motley-slayer
Docker – изолированная среда для более серьёзного продакшена на Compose, Kubernetes, etc. У нас пока нет официального публичного Докер-образа, но наш репозиторий весит немного и Докер-файл собирается быстро.
3.3. Где данные?
Своя БД
Слеер может подключаться к различным СУБД – Postgres, MySQL, SQLite, DuckDB, BigQuery, Snowflake и другие.
Подробнее смотрите в Getting Started для каждого конкретного способа запуска и в Configuration: Storage.
Демо
Чтобы не заставлять пользователей сразу подключать свои базы данных, мы добавили поддержку тестового набора данных Jaffle Shop. Это классический демонстрационный ecommerce-датасет, который часто используется для проверки аналитических моделей. Он содержит типичную структуру с покупателями, заказами, товарами, магазинами, и прочее, что позволяет сразу попробовать сильные стороны Slayer'a на реалистичные аналитических запросах.
Запускается аргументом командной строки: --demo
4. Инженерные детали
Небольшая экскурсия по памятным внутренностям SLayer'a для самых любознательных.
4.1. Как оно устроено под капотом
Модель в SLayer'е это Pydantic-объект содержащий такие основные поля:
Источник – имя исходной таблицы, или SQL-запрос, или SLayer-запрос.
Список колонок – мы решили унифицировать метрики и измерения на уровне моделей.
А также именованные формулы, список join'ов, кастомные агрегации, стандартные фильтры.
Запросы у нас это конечно же тоже Pydantic-объекты, основное содержимое:
Источник – имя модели или другой запрос.
Формулы и метрики для агрегации.
Измерения для группировки.
Фильтры.
Когда вы вызываете engine.execute(query), SLayer обогащает запрос – резолвит имена колонок, раскрывает отсылки к разным моделям, desugars (дешугарит?) магические функции-трансформации в подзапросы, затем, используя мощную библиотеку sqlglot, конструирует AST (абстрактное синтаксическое дерево) (никакой ручной работы с SQL-строками), и транслирует в диалект SQL'a целевой СУБД. Выполняются запросы через SQLAlchemy и по возможности асинхронно, в зависимости от доступных драйверов БД.
У нас есть пара уровней и синтаксисов выражений:
Внутри формул и фильтров запросов имеется небольшой DSL:
revenue:sum / *:count,rank(amount:sum) <= 10, арифметические и логические операторы, универсальные функции. Обрабатывается на данный момент Питонячим AST парсером с кастомной обработкой.Внутри же
Column.sqlвы можете задавать определение колонки посредством чистого SQL выражения (sqlglot всё распарсит), и вам доступны диалектно-специфические формулы и конструкции вродеCASE WHEN,json_extractи многого другого. Аналогично SQL доступен вModel.aggregationsдля определения кастомных агрегаций.
4.2. sqlglot – иногда обманывающий спаситель
Весь кросс-диалектный SQL внутри SLayer'a собирается как AST с помощью чудесного пакета sqlglot и потом транспилируется для целевой СУБД. sqlglot вытягивает 99% различий между Postgres / DuckDB / SQLite / MySQL / ClickHouse / BigQuery / Snowflake / Trino без нашего участия. Однако оставшийся 1% выходит боком.
Свежий пример, на который мы наступили буквально на прошлой неделе при настройке CI для MySQL-примера: MySQL-диалект sqlglot'а переписывает VAR_POP(x) в VARIANCE_POP(x) – функцию, которой в MySQL не существует (и получаешь 500). Заодно VAR_SAMP(x) переписывается в VARIANCE(x), а VARIANCE в MySQL – это алиас для VAR_POP. То есть выборочная дисперсия тихо превращается в популяционную. Никакой ошибки, просто слегка не то число на дашборде. Сразу два бага всего в восьми символах SQL. Лечим это через exp.Anonymous – единственный узел AST, который sqlglot не переписывает, и мы с хирургической точностью прописываем там подобную специфику.
Мораль: кросс-диалектные генераторы SQL – прекрасная вещь, но они не идеальны, к этой асимптоте совместимости можно приближаться бесконечно, и если вы хотите гарантировать совместимость с различными СУБД, то вам обязательно нужно разрабатывать и запускать интеграционные тесты для каждого диалекта, который вы поддерживаете. Именно поэтому у нас в CI поднимаются живые SQLite, DuckDB, Postgres, MySQL и ClickHouse.
4.3. В SQLite нет статистических агрегаций. Так что мы сделали свои.
Многие СУБД предоставляют большой набор встроенных статистических функций вроде median, percentile_cont, stddev_samp, stddev_pop, var_samp, var_pop, corr, covar_samp, covar_pop – ничего из этого в SQLite нет. Мы регистрируем их как внешние Python-функции при подключении. SLayer вроде бы семантический слой, но на SQLite он по совместительству ещё и тихая библиотека статистики. Оказалось весёлым писать однопроходный алгоритм Welford'а для потоковой корреляции, когда альтернатива – NotImplementedError.
4.4. Миграции живут только в Pydantic классах
Мы несколько раз меняли схему данных – SlayerModel уже на v6, а SlayerQuery на v3. При этом, любой тип хранилища – YAML-файлы, SQLite, и всё что позже добавим мы или пользователи – все получают поддержку миграций автоматически, потому что цепочка конвертации висит на @model_validator(mode="before") самих Pydantic-классов. Движок видит только последнюю версию и даже не знает, что миграции существуют.
5. Заключение
Иронично, что несмотря на AI-first позиционирование, основные сложности при разработке SLayer'a оказались абсолютно классическими – программными, алгоритмическими и аналитическими. Корректная семантика кардинальности, кросс-диалектный SQL, миграции схем, асинхронный движок, удобный DSL – всё то, чем разработчики занимались задолго до появления GPT и LLM. Большая языковая модель это отличный пользователь хорошей системы, но плохая замена самой системе. Чем строже и предсказуемее инструмент, тем адекватнее и полезнее агент, который им управляет.
Кому и когда SLayer пригодится
AI-командам, которым нужно дать агенту структурированный и надёжный доступ к данным клиентов вместо «держи SQL REPL и удачи».
SaaS, где новые клиенты и источники данных подключаются регулярно и не хотелось бы тратить лишнее время на ручную настройку и адаптацию запросов.
Разработчикам дашбордов и аналитических продуктов, которым хочется headless-BI без вендор-лока на конкретный SQL-диалект.
Аналитикам, уставшим от громоздких моделей в Cube/dbt и хрупких монструозных SQL запросов.
Если вы узнали что-то из перечисленного – добро пожаловать:
📦 Репозиторий: https://github.com/MotleyAI/slayer
📚 Документация: https://motley-slayer.readthedocs.io/en/latest/
💬 Issues и PR'ы приветствуются!
⭐ Звёзды тоже ;)
А если вам нужен не только семантическое, а готовое решение под ключ для аналитики и отчётности – добро пожаловать на Motley Platform!
