1. Введение

1.1. Предыстория

Мы в Motley AI разрабатываем гибкую систему автоматизированной аналитики и отчётности с ИИ для B2B компаний, и это не просто чат с GPT и файлами на выходе или базовая автоматизация через n8n, а платформа с документами, шаблонами, источниками данных различных форматов и constrained AI – то есть, упор на воспроизводимость и детерминизм с минимизацией рисков галлюцинирования. ИИ у нас используется в нескольких случаях:

  1. Агент помогает формировать шаблоны, что можно делать и вручную в веб приложении или по API, но нормальной речью проще, а вручную остаётся лишь править детали.

  2. Можно скормить агенту пример документа и он его обобщает в шаблон, заменяя фактические формулировки, числа, графики и диаграммы на шаблонные элементы с настройками, формулами, промптами.

  3. В процессе резолва/рендеринга шаблона по заданным входным параметрам, большинство шаблонных блоков обрабатываются чисто алгоритмически, однако текстовые блоки могут использовать LLM для описания числовых результатов, составления выводов, гипотез и рекомендаций (особенно если в ваших источниках данных есть подходящий контекст с описанием бизнес-логики), а также для красивого оформления чисел в удобный текст.

  4. Интеграция результатов нашей системы с другими технологиями – часто в ad-hoc формате посредством диалога агента с подключенными MCP к нашей платформе и сторонним сервисам.

Соответственно, нам нужно чтобы и наша программная система, и ИИ агенты могли легко взаимодействовать с различными базами данных понимая их схемы, составлять и запускать удобочитаемые запросы и формулы, определять переиспользуемые запросы для очистки, дедубликации данных и бизнес метрики. И с такими задачами как раз помогают семантические слои!

1.2. Семантические слои

Что это за технология-посредник над базами данных вроде бы неплохо рассказывают эти статьи:

  1. Почему важны семантические слои, и как собрать такой слой на DuckDB,

  2. Семантический слой для Аналитики ключевых метрик – dbt Metrics vs Cube.

От себя скажу, что это нечто вроде data mart с OLAP кубами поверх OLTP баз данных со star schema, а-ля вьюхи с бизнес-метриками над голыми транзакционными данными, только независимые от конкретных типов СУБД – одинаково работая на SQLite, MySQL, Postgres, BigQuery, Snowflake и многом другом. Зачастую их называют headless BI – семантические слои часто используются как источники показателей, высокоуровневых данных в дашбордах или для разработки своих аналитических подсистем.

По существу, семантические слои позволяют определять:

  1. Модели – обёртки над таблицами с метриками и измерениями, и зачастую это не плоская обёртка над таблицей в БД, а полноценный SQL подзапрос, где можно очищать данные или определять виртуальные колонки полезные для последующих вычислений.

  2. Метрики/показатели (measures) – колонки, обычно числовые значения, которые используются для агрегации, и часто агрегация сохраняется в модели как часть метрики (revenue_avg, revenue_sum, revenue_min/max)

  3. Измерения (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 и по возможности асинхронно, в зависимости от доступных драйверов БД.

У нас есть пара уровней и синтаксисов выражений:

  1. Внутри формул и фильтров запросов имеется небольшой DSL: revenue:sum / *:count, rank(amount:sum) <= 10, арифметические и логические операторы, универсальные функции. Обрабатывается на данный момент Питонячим AST парсером с кастомной обработкой.

  2. Внутри же 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 запросов.

Если вы узнали что-то из перечисленного – добро пожаловать:

А если вам нужен не только семантическое, а готовое решение под ключ для аналитики и отчётности – добро пожаловать на Motley Platform!