Проектирование баз данных: SQL и PostgreSQL для ИИ-агентов

Курс по созданию надежной инфраструктуры хранения данных для ИИ-систем. Вы научитесь проектировать схемы БД для истории диалогов, системных логов и метаданных, обеспечивая долгосрочную память и прослеживаемость работы агентов.

1. Реляционная модель и PostgreSQL: фундамент хранения структурированных данных для ИИ

Реляционная модель и PostgreSQL: фундамент хранения структурированных данных для ИИ

При проектировании современных ИИ-систем фокус внимания часто смещается в сторону векторных баз данных. Кажется, что способность находить смысловую близость между текстами — это всё, что нужно для создания умного агента. Однако при попытке развернуть такую систему в production разработчики сталкиваются с суровой реальностью: векторный поиск не способен ответить на вопрос «сколько токенов потратил этот пользователь за вторник?» или «какой статус у задачи, которую агент отправил в Celery пять минут назад?». Вероятностная природа больших языковых моделей требует жесткого, детерминированного каркаса для управления состоянием, биллингом и историей. Эту роль берет на себя реляционная база данных.

Архитектура двойной памяти ИИ-агента

Чтобы мульти-агентная система функционировала надежно, ей необходимы два принципиально разных типа хранилищ, которые работают в тандеме.

Векторная база данных (например, Qdrant) выступает в роли семантической памяти. Она хранит эмбеддинги документов и позволяет агенту находить контекст по смыслу. Если пользователь спрашивает: «Как настроить таймауты в httpx?», агент извлекает из векторной БД фрагменты документации, где векторы математически близки к вектору вопроса. Здесь правит нечеткая логика (Fuzzy Logic) и вероятностное сходство.

Реляционная база данных (PostgreSQL) выполняет функцию эпизодической и системной памяти. Она хранит точные, структурированные факты. Когда агент формирует ответ, система должна сохранить сам факт диалога: кто спросил, когда, в рамках какой сессии, какая модель использовалась и каков точный текст ответа.

!Архитектура двойной памяти ИИ-системы: разделение потоков данных между векторной и реляционной БД

Разница между этими хранилищами становится очевидной при выполнении операций удаления или точного поиска. Если пользователь требует удалить всю историю его переписки за прошлый месяц (требование GDPR), векторная база данных с трудом справится с этой задачей без сложных метаданных, так как ее первичная функция — поиск по сходству, а не фильтрация по датам. В реляционной модели это решается тривиальным запросом с условием строгого равенства и диапазона дат.

Реляционная парадигма: математика надежности

Реляционная модель, предложенная Эдгаром Коддом, базируется на математической теории множеств. Данные организуются в виде двумерных таблиц (отношений), где строки (кортежи) представляют конкретные экземпляры сущностей, а столбцы (атрибуты) — их свойства.

Для ИИ-инфраструктуры критически важны два аспекта этой модели: строгая типизация и ссылочная целостность.

Даже при использовании строгих контрактов Pydantic (о которых шла речь при проектировании FastAPI-приложений), всегда существует риск сбоя на уровне приложения или изменения логики, при котором в систему попытаются записать некорректные данные. Реляционная база данных выступает последним рубежом обороны. Если столбец token_count определен как целое число (INTEGER), база данных физически не позволит записать туда строку, сгенерированную галлюцинирующей LLM.

Ссылочная целостность (Foreign Keys) гарантирует, что данные не повиснут в воздухе. В мульти-агентной системе на базе LangGraph состояние передается между узлами графа. Если мы сохраняем лог вызова конкретного инструмента (Tool Call), он должен быть жестко привязан к конкретному сообщению (Message), которое, в свою очередь, привязано к сессии (Session). Реляционная БД гарантирует, что невозможно удалить сессию, оставив в системе осиротевшие сообщения, если настроено каскадное удаление, или, наоборот, заблокирует удаление сессии, защищая исторические данные.

Транзакции и гарантии ACID

Любая операция в реляционной базе данных выполняется в рамках транзакции, которая подчиняется принципам ACID (Atomicity, Consistency, Isolation, Durability). Для ИИ-агентов наиболее показательна атомарность (Atomicity) — принцип «всё или ничего».

Рассмотрим процесс обработки запроса пользователя:

  • Создается запись о новом сообщении пользователя.
  • Агент обращается к LLM и получает ответ.
  • Создается запись о сообщении ассистента.
  • Обновляется счетчик потраченных токенов в профиле пользователя.
  • Если на четвертом шаге произойдет сбой (например, отказ сети или исчерпание пула потоков), атомарность транзакции позволит откатить все предыдущие шаги. В базе данных не останется «висящего» ответа ассистента без списанных токенов. Система вернется в консистентное состояние, предшествующее началу транзакции.

    Многоверсионность (MVCC): как агенты не блокируют друг друга

    В асинхронном приложении FastAPI, работающем под управлением Gunicorn с несколькими воркерами, десятки запросов могут обрабатываться конкурентно. Разные агенты могут одновременно писать логи в базу данных, пока аналитический сервис пытается прочитать историю диалогов для расчета метрик.

    В ранних системах управления базами данных чтение данных блокировало их запись, а запись блокировала чтение. Это приводило к катастрофическому падению пропускной способности. PostgreSQL решает эту проблему с помощью механизма MVCC (Multi-Version Concurrency Control — многоверсионное управление конкурентным доступом).

    Фундаментальное правило MVCC: читающие транзакции никогда не блокируют пишущие, а пишущие никогда не блокируют читающие.

    !Механика MVCC: неблокирующее чтение и запись в PostgreSQL

    Вместо того чтобы перезаписывать данные физически поверх старых, PostgreSQL при операции UPDATE или DELETE помечает старую строку как невидимую для новых транзакций и создает новую версию строки. Каждая строка в PostgreSQL имеет скрытые системные столбцы: xmin (идентификатор транзакции, создавшей строку) и xmax (идентификатор транзакции, удалившей или обновившей строку).

    Когда аналитический запрос начинает сканировать таблицу истории диалогов (транзакция ), база данных фиксирует снимок состояния (Snapshot). Если в этот же момент ИИ-агент завершает генерацию ответа и обновляет статус сессии (транзакция ), он создает новую версию строки. Транзакция продолжит читать старую версию строки, потому что новая версия была создана транзакцией, начавшейся позже момента фиксации снимка.

    Это означает, что тяжелые аналитические запросы (например, расчет ROI или оценка качества ответов через LangSmith-подобные платформы) могут выполняться на рабочей базе данных, не замедляя работу агентов в реальном времени.

    Почему PostgreSQL стал стандартом де-факто для ИИ-инфраструктуры

    Выбор PostgreSQL среди множества реляционных СУБД (MySQL, Oracle, SQL Server) для ИИ-проектов обусловлен его архитектурной гибкостью. PostgreSQL — это объектно-реляционная база данных, которая позволяет расширять систему типов и адаптироваться к полуструктурированным данным, что критически важно при работе с LLM.

    JSONB: мост между строгими схемами и гибкостью LLM

    Вывод языковых моделей, даже при использовании техник структурированного промптинга, часто содержит динамические ключи и вариативные структуры. Например, если агент использует инструмент (Tool) search_internet, аргументы будут одними (ключ query), а для инструмента calculate_mortgage — совершенно другими (amount, rate, years).

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

    PostgreSQL предлагает тип данных JSONB (Binary JSON). В отличие от простого текстового хранения JSON, JSONB парсится при записи и сохраняется в бинарном формате. Это дает два колоссальных преимущества:

  • Валидация на уровне БД: PostgreSQL не позволит записать невалидный JSON.
  • Индексирование: Внутри JSONB можно строить индексы (например, GIN — Generalized Inverted Index) по конкретным ключам.
  • В архитектуре ИИ-памяти JSONB позволяет применять гибридный подход. Жесткие атрибуты (кто, когда, в какой сессии) хранятся в классических типизированных столбцах. Динамические атрибуты (сырой ответ модели, JSON-аргументы вызова функции, метаданные промпта) сохраняются в столбце payload типа JSONB.

    Это позволяет выполнять мощные SQL-запросы прямо по структуре, сгенерированной нейросетью. Можно написать запрос, который найдет все сессии, где агент использовал инструмент calculate_mortgage с суммой кредита строго больше 10 миллионов, обращаясь внутрь JSON-объекта на лету.

    Декларативная природа SQL: отказ от микроменеджмента данных

    Взаимодействие с PostgreSQL осуществляется через язык SQL (Structured Query Language). Его главная особенность — декларативность. Разработчик описывает, какие данные он хочет получить, а не как база данных должна их искать.

    При работе с коллекциями в Python (даже с использованием генераторов или filter) программист реализует императивный подход: возьми список, пройди по каждому элементу, проверь условие, положи в новый список.

    В SQL запрос выглядит иначе: SELECT session_id, sum(prompt_tokens) FROM llm_logs WHERE model_name = 'llama-3' GROUP BY session_id;

    Получив этот запрос, внутренний компонент PostgreSQL — Планировщик запросов (Query Planner) — строит дерево выполнения. Он анализирует статистику распределения данных в таблице llm_logs и принимает решение:

  • Если записей с llama-3 мало, а на столбце model_name есть индекс, планировщик использует индексное сканирование (Index Scan), мгновенно переходя к нужным строкам.
  • Если записей с llama-3 больше 30-40% от всей таблицы, планировщик проигнорирует индекс и запустит последовательное сканирование (Sequential Scan), так как чтение данных подряд с диска окажется быстрее, чем постоянные прыжки по дереву индекса.
  • Эта интеллектуальная маршрутизация внутри СУБД снимает с разработчика ИИ-системы колоссальную когнитивную нагрузку. Нет необходимости менять код приложения по мере роста объема исторических данных — PostgreSQL сам адаптирует стратегию выполнения запросов.

    Проекция на мульти-агентную систему

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

    Вместо того чтобы хранить историю диалогов в оперативной памяти Python-процесса (что приведет к потере контекста при перезапуске воркера Gunicorn) или в неструктурированных файлах, система получает надежное хранилище. Каждое взаимодействие пользователя с ботом в Telegram, каждый внутренний цикл размышлений агента (Chain-of-Thought), каждая ошибка валидации Pydantic-контракта оседают в реляционных таблицах.

    Это открывает путь к построению систем трассировки вызовов (аналогов LangSmith), где можно точно отследить, какой системный промпт привел к конкретному ответу, сколько миллисекунд заняла генерация и какие инструменты были вызваны на промежуточных этапах. В следующих главах мы спроектируем конкретную схему таблиц, которая реализует эту долгосрочную память, и настроим асинхронное взаимодействие с ней через SQLAlchemy, не блокируя Event Loop нашего веб-сервера.

    10. Отказоустойчивость и масштабирование: бэкапы, пулинг соединений и подготовка к Kubernetes

    Стандартная конфигурация PostgreSQL из коробки допускает 100 одновременных подключений. В классическом веб-приложении этого хватает для обслуживания тысяч пользователей, поскольку транзакция длится миллисекунды. В системах на базе LLM профиль нагрузки радикально меняется. Агент может начать транзакцию, отправить запрос к OpenAI, ожидать ответа 40 секунд и только затем зафиксировать изменения. Если в этот момент 101 пользователь попытается отправить сообщение, база данных отвергнет подключение с ошибкой FATAL: sorry, too many clients already. Проблема усугубляется при переходе к микросервисной архитектуре, где каждый экземпляр приложения создает свой собственный пул соединений.

    Мультипликация пулов и инфраструктурный пулинг

    В асинхронных приложениях на FastAPI пул соединений настраивается на уровне ORM (например, в SQLAlchemy через pool_size). Если размер локального пула равен 20, это означает, что один процесс приложения может удерживать до 20 открытых TCP-соединений с базой данных.

    При горизонтальном масштабировании в Kubernetes или Docker Swarm количество соединений начинает расти кратно. Формула расчета общего числа потенциальных подключений выглядит так:

    Где — количество воркеров (например, Gunicorn) в одном контейнере, — размер пула SQLAlchemy, — количество запущенных реплик контейнера. Если у нас 5 подов, в каждом 4 воркера, а размер пула равен 10, приложение попытается открыть 200 соединений. База данных начнет тратить процессорное время не на выполнение запросов, а на переключение контекста между сотнями простаивающих процессов, ожидающих завершения сетевых I/O операций от LLM.

    Решением этой проблемы выступает инфраструктурный пулер соединений, такой как PgBouncer. Он устанавливается между приложением и PostgreSQL, принимая на себя тысячи легковесных клиентских подключений и мультиплексируя их в небольшое количество (например, 50) реальных серверных соединений с БД.

    !Архитектура пулинга PgBouncer

    PgBouncer поддерживает три режима работы:

  • Session pooling — серверное соединение закрепляется за клиентом на все время жизни клиентского подключения. Для асинхронных приложений с долгоживущими сокетами этот режим бесполезен, так как не решает проблему мультипликации.
  • Statement pooling — серверное соединение возвращается в пул после каждого отдельного SQL-запроса. Не поддерживает транзакции, состоящие из нескольких команд.
  • Transaction pooling — серверное соединение выдается клиенту в момент выполнения BEGIN и возвращается в пул сразу после COMMIT или ROLLBACK.
  • Для ИИ-агентов критически важно использовать именно Transaction pooling. Это заставляет пересмотреть подход к написанию кода: транзакции должны быть максимально короткими.

    Антипаттерн:

  • Открыть транзакцию (BEGIN).
  • Записать промпт пользователя в БД.
  • Сделать HTTP-запрос к LLM (ожидание 30 секунд).
  • Записать ответ модели.
  • Зафиксировать транзакцию (COMMIT).
  • В таком сценарии соединение с БД удерживается 30 секунд. При использовании Transaction pooling правильный подход — разбить операцию:

  • Открыть транзакцию, записать промпт, зафиксировать (COMMIT). Соединение возвращается в PgBouncer.
  • Выполнить долгий запрос к LLM (БД не задействована).
  • Открыть новую транзакцию, записать ответ, зафиксировать (COMMIT).
  • Репликация и разделение нагрузки (Read-Write Splitting)

    По мере роста базы знаний агентов (RAG) и истории диалогов, один сервер PostgreSQL перестает справляться с нагрузкой. Аналитические запросы — например, LangGraph-узел, который суммаризирует последние 100 сообщений сессии для формирования контекста — начинают конкурировать за ресурсы диска и процессора с операциями записи (сохранение новых логов, биллинг, обновление состояния).

    Архитектурный стандарт для масштабирования реляционных баз данных — паттерн Primary-Replica на основе потоковой репликации (Streaming Replication).

    !Архитектура PostgreSQL Primary-Replica

    В этой топологии существует один мастер-узел (Primary), который принимает все операции записи (INSERT, UPDATE, DELETE). Реплика (Replica) работает в режиме Read-Only и непрерывно получает поток изменений из журнала транзакций мастера, применяя их к своим файлам данных. Приложение настраивается таким образом, чтобы направлять тяжелые запросы на чтение (SELECT) к реплике, освобождая ресурсы мастера.

    Потоковая репликация бывает асинхронной и синхронной. В синхронной репликации транзакция на Primary не считается зафиксированной до тех пор, пока реплика не подтвердит запись данных на свой диск. Это гарантирует нулевую потерю данных при падении мастера, но удваивает задержку (latency) каждой операции записи. В асинхронной репликации Primary фиксирует транзакцию локально и сразу отвечает приложению, а данные отправляются на реплику в фоновом режиме. Это работает быстро, но порождает задержку репликации (Replication Lag) — временной зазор между записью данных на мастере и их появлением на реплике.

    Для ИИ-систем асинхронная репликация предпочтительнее из-за требований к скорости записи логов и промежуточных шагов рассуждений (Chain-of-Thought). Однако она порождает проблему консистентности «Read-your-writes» (чтение собственных записей).

    Если агент записывает новое сообщение в Primary и на следующем шаге делает запрос к Реплике для получения полного контекста диалога, из-за лага в 50 миллисекунд последнее сообщение может еще не доехать до Реплики. Агент получит устаревший контекст и начнет галлюцинировать.

    Решение проблемы «Read-your-writes» достигается на уровне драйвера БД или сервисного слоя приложения:

  • Использование липких сессий (Sticky routing): если пользователь или агент сделал запись в рамках текущей HTTP-сессии, все последующие SELECT запросы в течение следующих нескольких секунд принудительно маршрутизируются на Primary.
  • Версионирование: приложение запрашивает у Реплики текущую позицию примененного журнала транзакций (LSN - Log Sequence Number). Если LSN Реплики меньше, чем LSN последней записи агента, запрос перенаправляется на Primary.
  • Непрерывное архивирование и Point-in-Time Recovery (PITR)

    Логическое резервное копирование с помощью утилиты pg_dump создает SQL-скрипт со всеми данными на момент запуска. Если бэкап делается раз в сутки в 02:00 ночи, а в 14:00 происходит сбой (например, баг в коде агента привел к массовому удалению или искажению RAG-документов), все данные за 12 часов будут безвозвратно потеряны. Для систем, где история диалогов накапливается ежесекундно, логический бэкап неприемлем.

    Надежная стратегия резервного копирования строится на физических бэкапах и механизме Point-in-Time Recovery (PITR). Этот подход использует уже знакомый нам журнал транзакций.

    Процесс состоит из двух параллельных механизмов:

  • Base Backup (Базовая копия) — физическое копирование всех файлов данных PostgreSQL (содержимого директории PGDATA) на удаленное хранилище (например, AWS S3) раз в неделю или раз в сутки.
  • WAL Archiving (Архивирование журнала) — как только PostgreSQL заполняет очередной сегмент журнала транзакций (обычно 16 МБ), специальный процесс (например, утилита pgBackRest или WAL-G) автоматически сжимает его и отправляет в то же удаленное хранилище.
  • !Восстановление на точку во времени (PITR)

    Если в 14:32:15 происходит логическое повреждение данных, администратор инициирует процедуру PITR. Система разворачивает последний Base Backup (например, от 02:00), а затем последовательно скачивает и применяет все заархивированные сегменты журнала транзакций строго до отметки времени 14:32:14. База данных восстанавливается в идеальное консистентное состояние за секунду до аварии. Ни одна транзакция, зафиксированная до этого момента, не теряется.

    PITR защищает не только от программных ошибок, но и от аппаратных сбоев. Если сервер с Primary БД физически сгорает, новый сервер поднимается из Base Backup + архива журналов. Потеряны будут только те транзакции, которые находились в последнем, еще не отправленном в архив 16-мегабайтном сегменте (обычно это окно потери данных составляет от нескольких секунд до минуты).

    Подготовка инфраструктуры к Kubernetes: StatefulSets и Operators

    Перенос мультягентной системы в Kubernetes (K8s) требует особого подхода к слою хранения данных. Kubernetes изначально создавался для stateless (не хранящих состояние) приложений — веб-серверов, API, воркеров очередей. В классическом K8s-ресурсе Deployment поды (контейнеры) эфемерны: они могут быть уничтожены, перенесены на другой узел кластера и перезапущены с новым IP-адресом. Локальная файловая система пода уничтожается вместе с ним.

    Запуск PostgreSQL через стандартный Deployment приведет к потере базы данных при первом же рестарте контейнера. Для stateful (хранящих состояние) приложений Kubernetes предоставляет ресурс StatefulSet.

    Отличия StatefulSet от Deployment:

  • Стабильные сетевые идентификаторы: Поды получают предсказуемые имена (например, db-0, db-1), которые сохраняются при перезапуске. Это критично для настройки репликации, где Replica (db-1) должна точно знать адрес Primary (db-0).
  • Постоянное хранилище (Persistent Volumes): К каждому моду StatefulSet привязывается независимый сетевой диск (Persistent Volume Claim). Если под db-0 падает и пересоздается на другом физическом узле кластера, Kubernetes автоматически отмонтирует диск от старого узла и примонтирует к новому. Данные сохраняются.
  • Упорядоченный запуск и остановка: Поды запускаются строго по очереди. db-1 не начнет запуск, пока db-0 не перейдет в состояние Ready. Это необходимо для корректной инициализации кластера БД.
  • Однако самого по себе StatefulSet недостаточно для создания отказоустойчивой базы данных в production. Настройка потоковой репликации, автоматический failover (переключение мастера при сбое), инициализация PgBouncer и управление PITR-бэкапами требуют сложной императивной логики, которую K8s из коробки не предоставляет.

    Здесь на сцену выходит паттерн Kubernetes Operator. Оператор — это специализированный контроллер, написанный на Go или Python, который работает внутри кластера и берет на себя роль автоматизированного администратора базы данных (DBA).

    Вместо того чтобы вручную писать десятки YAML-манифестов для подов, сервисов и дисков, разработчик передает Оператору один высокоуровневый манифест (Custom Resource Definition). Например, используя популярный CloudNativePG (CNPG) или Zalando Postgres Operator, конфигурация выглядит как декларативное требование: «Мне нужен кластер PostgreSQL 15 версии из 3 узлов (1 Primary, 2 Replica), с пулингом PgBouncer и автоматической отправкой WAL-архивов в S3 бакет».

    Оператор самостоятельно:

  • Создает StatefulSet и распределяет поды по разным физическим серверам (Node Affinity) для защиты от падения дата-центра.
  • Настраивает потоковую репликацию между узлами.
  • Непрерывно мониторит состояние Primary. Если db-0 перестает отвечать, Оператор автоматически выбирает реплику с наименьшим отставанием, повышает ее до Primary (Failover) и перенастраивает внутренний DNS-сервис так, чтобы приложение начало писать в новый мастер без ручного вмешательства.
  • Управляет ротацией паролей и TLS-сертификатами для шифрования трафика между приложением и БД.
  • Использование Операторов позволяет абстрагироваться от низкоуровневой настройки PostgreSQL и управлять базой данных в парадигме Infrastructure as Code (IaC), что идеально ложится в пайплайны CI/CD. Разработчику ИИ-системы остается лишь передать URL подключения к БД (предоставленный Оператором) в переменные окружения FastAPI и Celery-воркеров. Контейнеризация самой бизнес-логики агентов, настройка очередей и оркестрация микросервисов станут следующим логическим шагом в построении отказоустойчивой архитектуры.

    2. Проектирование схемы долгосрочной памяти: таблицы, связи и нормализация диалогов

    Проектирование схемы долгосрочной памяти: таблицы, связи и нормализация диалогов

    Современные языковые модели с контекстным окном в 128 тысяч токенов создают иллюзию, что историю диалога можно просто хранить в виде единого текстового файла и передавать модели целиком при каждом запросе. На практике этот подход разрушается в первые же дни эксплуатации системы. Пользователь общается с ИИ-агентом неделями, контекст переполняется, стоимость API-вызовов растет экспоненциально, а аналитики не могут посчитать, сколько раз агент использовал конкретный инструмент (Tool) для доступа к базе данных. Чтобы ИИ-система могла масштабироваться, история ее взаимодействий должна быть декомпозирована и уложена в строгую реляционную структуру.

    Проектирование схемы базы данных для мульти-агентной системы кардинально отличается от проектирования обычного мессенджера. В WhatsApp или Telegram сообщение — это просто текст от отправителя к получателю. В системах на базе LangChain или LangGraph сообщение — это сложный объект, который может содержать внутренние рассуждения модели (Chain-of-Thought), вызовы внешних функций, системные инструкции и ссылки на векторные документы (RAG).

    Нормализация данных в контексте LLM

    Процесс приведения структуры базы данных к виду, минимизирующему избыточность и аномалии при изменении данных, называется нормализацией. В контексте ИИ-агентов отказ от нормализации обычно выглядит как создание одной широкой таблицы chat_history, где есть колонка user_id и колонка messages_payload с гигантским JSON-массивом всей переписки.

    Такой антипаттерн нарушает Первую нормальную форму (1NF), требующую атомарности атрибутов. Если потребуется удалить одно сообщение, содержащее персональные данные (GDPR), или найти все сессии, где модель вызывала инструмент search_web, движку базы данных придется извлекать, парсить и перезаписывать тяжеловесные JSON-объекты целиком.

    Вместо этого мы применим правила нормализации и разобьем предметную область на логические сущности, связанные между собой. Базовая иерархия долгосрочной памяти агента строится на трех уровнях: Пользователь Сессия Сообщение.

    Базовая иерархия: Пользователи и Сессии

    Первый уровень изоляции данных — это таблица пользователей. Даже если на этапе MVP система не требует сложной авторизации, идентификатор пользователя необходим для разделения контекста.

    Второй, критически важный уровень — это сессии (или треды). Сессия определяет границы контекста. Когда мы собираем массив сообщений для отправки в API OpenAI или локальную Ollama, мы извлекаем сообщения только в рамках одной активной сессии. Это предотвращает интерференцию — ситуацию, когда агент путает детали из обсуждения рецепта пирога с кодом на Python, который пользователь просил написать вчера.

    Связь между users и sessions описывается отношением (один ко многим). Внешний ключ user_id использует правило ON DELETE CASCADE. Это означает, что при удалении записи пользователя из таблицы users, СУБД автоматически и атомарно удалит все его сессии. В системах, обрабатывающих пользовательские данные, каскадное удаление — надежный способ обеспечить право на забвение без написания сложной логики очистки на уровне Python-кода.

    Колонка system_prompt_version здесь не случайна. Системные промпты эволюционируют. Если вы измените поведение агента в коде, старые диалоги могут потерять смысл при их анализе, так как они генерировались в других условиях. Фиксация версии промпта на уровне сессии позволяет ML-инженерам проводить A/B-тестирование и корректно оценивать качество ответов.

    Анатомия ИИ-сообщения: Роли и Инструменты

    Таблица сообщений — это самое высоконагруженное место в реляционной памяти агента. Именно сюда пишутся логи каждого шага генерации.

    Для строгого контроля ролей (system, user, assistant, tool) мы не будем использовать обычный текст. Реляционные базы данных позволяют создавать собственные типы данных. Использование перечисления (ENUM) защищает систему от ошибок на уровне приложения — например, если разработчик опечатается и попытается записать роль assistent вместо assistant, база данных отклонит транзакцию.

    Эта структура учитывает специфику работы современных LLM:

  • Разделение текста и вызовов функций: Колонка content содержит человекочитаемый текст. Однако, если агент решает вызвать внешнюю функцию (например, поиск по базе Qdrant), content может быть пустым (NULL), а аргументы функции запишутся в бинарную колонку tool_calls.
  • Ответы инструментов: Когда Python-код выполняет запрошенную агентом функцию, результат возвращается в модель в виде нового сообщения с ролью tool. Для этого используется колонка tool_call_id, чтобы модель могла сопоставить свой запрос с полученным результатом.
  • Биллинг на уровне строк: Колонки prompt_tokens и completion_tokens позволяют агрегировать затраты. Запрос SELECT SUM(completion_tokens) FROM messages WHERE session_id = '...' мгновенно покажет, сколько ресурсов потребил конкретный диалог.
  • Древовидная структура диалогов: Adjacency List

    Линейная структура таблицы messages, где сообщения просто сортируются по created_at, работает только для простейших чат-ботов. В профессиональных интерфейсах (как в ChatGPT) пользователь имеет возможность отредактировать свое старое сообщение или нажать кнопку «Regenerate» (сгенерировать заново).

    Если пользователь возвращается на три шага назад и меняет свой вопрос, линейная история ломается. Старые ответы агента теряют смысл, но мы не можем их просто удалить (Soft Delete) — пользователь может захотеть переключиться между ветками диалога. Диалог ИИ — это не линия, это дерево (Directed Acyclic Graph).

    Для реализации ветвления в SQL применяется паттерн Adjacency List (Список смежности). В таблицу messages добавляется колонка, ссылающаяся на первичный ключ этой же самой таблицы:

    Теперь каждое сообщение знает только своего прямого родителя.

  • Первое сообщение в сессии имеет parent_message_id = NULL.
  • Если пользователь нажимает «Regenerate» на последнем ответе, создается новый запрос к LLM. Ответ записывается как новая строка, у которой parent_message_id указывает на тот же вопрос пользователя, что и у предыдущего (отброшенного) ответа.
  • При сборке контекста для LangChain мы больше не делаем простую выборку по session_id. Мы берем ID текущего «листового» (последнего) сообщения и рекурсивно поднимаемся вверх по дереву до корня, собирая только ту ветку, которую пользователь видит на экране прямо сейчас. В PostgreSQL это элегантно решается с помощью рекурсивных обобщенных табличных выражений (Recursive CTE), которые позволяют развернуть дерево в плоский список за один проход по базе.

    Связь : Интеграция RAG и цитирования

    Когда ИИ-агент работает в режиме Retrieval-Augmented Generation (RAG), он опирается на внешние документы. Хорошим тоном при проектировании корпоративных систем считается предоставление ссылок на источники — если агент утверждает, что «выручка выросла на 15%», он должен указать, из какого PDF-отчета взят этот факт.

    Документы и их метаданные хранятся в отдельной таблице documents (сами векторы при этом лежат в Qdrant, а PostgreSQL хранит только идентификаторы и названия). Одно сообщение от агента может ссылаться на несколько документов. В то же время, один и тот же популярный документ (например, «Политика отпусков») может цитироваться во множестве разных сообщений от разных агентов.

    Такая связь называется (многие ко многим). Реляционная модель не позволяет хранить массивы ссылок напрямую в строке. Для разрешения этой связи создается промежуточная таблица-связка (Junction Table).

    Таблица message_documents состоит из композитного первичного ключа (message_id, document_id). Это гарантирует, что один и тот же документ не будет привязан к одному сообщению дважды.

    Добавление колонки relevance_score в таблицу-связку — это классический прием проектирования. Оценка релевантности (схожести вектора вопроса с вектором документа) не является свойством самого документа, и не является свойством сообщения. Это свойство самого факта связи между конкретным вопросом и конкретным документом в данный момент времени.

    Изоляция внутренних шагов: Трассировка графа

    Библиотеки вроде LangGraph позволяют строить агентов с циклическим поведением. Агент получает задачу, планирует шаги, вызывает инструмент, анализирует ошибку, вызывает инструмент снова, и только потом выдает финальный ответ пользователю.

    Если мы будем писать все эти внутренние итерации в таблицу messages, мы засорим интерфейс пользователя служебным мусором. Пользователь должен видеть только конечный результат. Для хранения промежуточных шагов рассуждения создается таблица agent_traces (или agent_steps).

    Связь здесь строится по принципу от сообщения пользователя к шагам агента.

    Эта структура является фундаментом для построения систем оценки качества (LLM-as-a-Judge) и интеграции с платформами вроде LangSmith. Когда аналитик видит, что агент дал неверный ответ, он находит message_id этого ответа, а затем делает выборку из agent_traces по trigger_message_id, восстанавливая всю цепочку рассуждений: какие инструменты были вызваны, какие данные они вернули, и сколько миллисекунд (latency_ms) занял каждый шаг.

    Стратегия мягкого удаления (Soft Delete)

    В ИИ-системах данные диалогов представляют колоссальную ценность для дообучения будущих моделей (Fine-tuning). Физическое удаление строк через команду DELETE уничтожает этот актив.

    Для таблиц sessions и messages часто применяется паттерн Soft Delete. Вместо удаления строки, мы меняем ее состояние.

    При сборке контекста для LLM приложение добавляет условие WHERE is_deleted = FALSE. Пользователь видит, что сообщение исчезло, контекстное окно модели не засоряется, но дата-инженеры сохраняют доступ к полному датасету для анализа причин, по которым пользователи удаляют те или иные ветки диалога. Исключение составляют только требования комплаенса (например, явный запрос на полное удаление аккаунта) — в этом случае отрабатывает спроектированное нами ранее каскадное удаление ON DELETE CASCADE.

    Спроектированная схема из пяти таблиц (users, sessions, messages, documents, message_documents) покрывает 95% архитектурных потребностей при разработке корпоративных RAG-систем и мульти-агентных сетей. Она обеспечивает строгую типизацию ролей, поддерживает нелинейное развитие диалога через списки смежности, позволяет связывать ответы с векторными источниками и сохраняет промежуточные логики агентов для отладки. Такая структура переносит тяжесть управления связями с Python-кода на оптимизированный движок PostgreSQL, гарантируя целостность контекста при любом количестве конкурентных запросов.

    3. Типы данных PostgreSQL: от JSONB для гибких логов до UUID для распределенных систем

    Типы данных PostgreSQL: от JSONB для гибких логов до UUID для распределенных систем

    Представьте систему, где пятьдесят независимых ИИ-агентов, развернутых в Kubernetes-кластере, одновременно завершают генерацию ответов и пытаются записать логи в единую базу данных. Если первичным ключом таблицы логов выступает обычный счетчик, база данных вынуждена выстраивать эти запросы в очередь, чтобы выдать каждому агенту следующий порядковый номер. Возникает узкое горлышко, сводящее на нет преимущества асинхронной архитектуры. Эффективность хранения и обработки данных ИИ-системы закладывается не на уровне SQL-запросов, а на этапе выбора фундаментальных типов данных.

    Идентификаторы в распределенных системах: проблема SERIAL и эволюция UUID

    В классических монолитных приложениях стандартом де-факто для первичных ключей является автоинкрементный счетчик — SERIAL или BIGSERIAL (в современных версиях PostgreSQL реализуемый через GENERATED ALWAYS AS IDENTITY). Под капотом это объект-последовательность (sequence), который гарантирует уникальность выдаваемых чисел.

    Для ИИ-систем, построенных на микросервисной архитектуре, использование последовательностей порождает две критические проблемы:

  • Предсказуемость. Если эндпойнт API принимает идентификатор сессии в виде целого числа (/api/v1/sessions/1042), злоумышленник может легко перебрать соседние значения. Для систем, хранящих конфиденциальные диалоги пользователей с ИИ, это недопустимая уязвимость (Insecure Direct Object Reference).
  • Блокировки при распределенной генерации. Агент не знает ID своей записи до момента физической вставки строки в таблицу. Это усложняет создание связанных сущностей в памяти (например, сообщения и привязанных к нему RAG-документов) до завершения транзакции.
  • Решением выступает UUID (Universally Unique Identifier) — 128-битное число, гарантирующее глобальную уникальность. Агенты могут генерировать UUID локально, связывать графы объектов в оперативной памяти и отправлять их в базу данных единым пакетом.

    UUIDv4 против UUIDv7: анатомия B-дерева

    Долгое время стандартом случайной генерации был UUID версии 4 (UUIDv4). Однако его использование в качестве первичного ключа (Primary Key) в PostgreSQL приводит к серьезной деградации производительности на больших объемах данных из-за механики работы индексов.

    По умолчанию PostgreSQL строит для первичных ключей индекс типа B-Tree (сбалансированное дерево). Данные в B-дереве хранятся в отсортированном виде на страницах памяти (обычно размером КБ). Когда вставляется случайный UUIDv4, база данных должна найти соответствующее место в дереве. Из-за полной случайности значения каждая новая запись попадает в произвольную страницу индекса. Если целевая страница уже заполнена, PostgreSQL вынужден разделить ее пополам (Page Split), переместив часть данных на новую страницу. Это вызывает лавину дисковых операций, известную как Write Amplification (усиление записи), и приводит к сильной фрагментации индекса: страницы оказываются заполненными лишь частично, индекс раздувается в объеме, а кэш оперативной памяти используется неэффективно.

    Спецификация UUIDv7 решает эту проблему, объединяя временную метку и случайность. Первые бит UUIDv7 содержат Unix-таймстемп с точностью до миллисекунды, а оставшиеся биты заполняются криптографически стойкой случайной последовательностью.

    Для базы данных это означает, что новые ключи всегда монотонно возрастают. При вставке UUIDv7 новые записи ложатся строго в самую правую (последнюю) страницу B-дерева. Разделение страниц (Page Split) происходит предсказуемо и только тогда, когда страница заполняется на 100%. Индекс остается плотным, компактным и легко кэшируется в RAM, обеспечивая скорость вставки, сопоставимую с классическим BIGSERIAL, но сохраняя все преимущества распределенной генерации.

    > Важно учитывать стоимость хранения: BIGINT занимает байт, тогда как UUID — байт. При миллиарде записей только сам столбец первичного ключа потребует дополнительные ГБ на диске и в оперативной памяти (плюс столько же для каждого вторичного индекса, включающего этот ключ).

    Глубокое погружение в JSONB: индексирование неструктурированных логов

    Архитектура ИИ-агентов, в частности построенных на LangGraph, генерирует огромные объемы полиморфных данных. Вызов инструмента (Tool Call) может содержать аргументы { "query": "SQL error", "limit": 10 } для поиска по логам, или { "lat": 55.75, "lon": 37.61 } для запроса погоды. Создавать отдельные колонки для каждого возможного аргумента инструмента — антипаттерн.

    Для таких задач используется тип JSONB (Binary JSON). В отличие от простого типа JSON, который хранит точную текстовую копию ввода (включая пробелы и порядок ключей), JSONB при записи парсит текст и сохраняет его в оптимизированном бинарном формате. Запись происходит чуть медленнее, но чтение и поиск ускоряются на порядки.

    Операторы извлечения данных

    PostgreSQL предоставляет специализированные операторы для работы с JSONB. Разница между ними критична для корректной типизации в SQL-запросах:

  • Оператор -> извлекает значение по ключу, возвращая тип JSONB.
  • Оператор ->> извлекает значение по ключу, возвращая тип TEXT.
  • Если в таблице agent_traces есть колонка llm_response типа JSONB, и нам нужно посчитать суммарное количество потраченных токенов, запрос будет выглядеть так:

    Здесь мы сначала спускаемся внутрь объекта по ключу usage (возвращается JSONB), затем извлекаем total_tokens как текст (оператор ->>), приводим текст к целому числу (::INTEGER) и только потом суммируем.

    GIN-индексы: jsonb_ops против jsonb_path_ops

    Главная сила JSONB — поддержка инвертированных индексов GIN (Generalized Inverted Index). GIN работает подобно алфавитному указателю в конце книги: он хранит ключи и указывает, в каких строках таблицы они встречаются.

    При создании GIN-индекса для JSONB PostgreSQL предлагает два класса операторов (стратегий индексирования):

  • jsonb_ops (по умолчанию). Индексирует каждый ключ и каждое значение отдельно. Это позволяет эффективно выполнять запросы на проверку существования ключа (оператор ?).
  • Пример: найти все логи, где в ответе вообще присутствует ключ error_code.
  • jsonb_path_ops. Индексирует хэши комбинаций «путь-значение». Этот индекс не может ответить на вопрос «есть ли тут ключ X?», но он идеально и невероятно быстро отвечает на запросы поиска по вхождению (оператор @>).
  • Для ИИ-систем чаще всего требуется именно поиск по вхождению. Например, найти все шаги агента, где была вызвана конкретная функция с конкретным аргументом:

    Если создать индекс с использованием jsonb_path_ops:

    Такой индекс будет занимать значительно меньше места на диске (так как не хранит отдельные ключи) и обеспечит максимальную производительность для оператора @>, что делает его оптимальным выбором для фильтрации логов LangChain и LangGraph.

    Текст и строки: развенчание мифа о VARCHAR

    В схемах баз данных, перенесенных из других СУБД (например, MySQL), часто можно встретить колонки типа VARCHAR(255) для хранения имен, названий моделей или коротких системных промптов.

    В архитектуре PostgreSQL типы TEXT, VARCHAR и VARCHAR(n) используют абсолютно идентичную внутреннюю структуру хранения — тип varlena (variable length array). Ограничение длины в VARCHAR(n) не дает никаких оптимизаций на уровне хранения или выделения памяти. Более того, при каждой вставке или обновлении строки PostgreSQL вынужден тратить процессорные такты на проверку длины строки.

    В контексте ИИ-приложений, где длина системного промпта может внезапно вырасти с символов до при смене бизнес-требований, жесткие ограничения на уровне базы данных приводят к необходимости запускать тяжелые миграции схемы (ALTER TABLE).

    Современный подход предписывает использовать тип TEXT для любых строковых данных в PostgreSQL. Если бизнес-логика требует ограничения длины (например, имя пользователя не более символов), эта валидация должна происходить на уровне приложения (в Pydantic-моделях FastAPI), а не в слое хранения.

    Хронология агентов: управление временем и таймзонами

    В распределенных ИИ-системах точный учет времени критичен. Он необходим для расчета метрик производительности (Time To First Token), определения порядка сообщений в диалоге и агрегации биллинга.

    PostgreSQL предлагает два основных типа для хранения времени: TIMESTAMP (без временной зоны) и TIMESTAMP WITH TIME ZONE (сокращенно timestamptz). Использование TIMESTAMP без зоны в распределенных системах является грубой архитектурной ошибкой.

    Тип timestamptz работает по следующему механизму:

  • При вставке данных PostgreSQL анализирует переданную временную метку (например, 2023-10-25 14:00:00+03).
  • База данных конвертирует это время в универсальное координированное время (UTC). В данном случае — в 11:00:00 UTC.
  • На диск сохраняется только значение в UTC. Никакая информация об исходной таймзоне (+03) не сохраняется.
  • При чтении данных PostgreSQL берет сохраненное UTC-значение и конвертирует его в таймзону, установленную в параметрах текущего подключения клиента.
  • Это обеспечивает абсолютную хронологическую консистентность. Если один микросервис работает в кластере в Европе, а другой в Азии, и оба записывают логи с использованием timestamptz, база данных корректно сведет их в единую временную шкалу. Разница между completed_at и started_at всегда даст точную задержку выполнения, независимо от того, где физически находились агенты.

    Массивы в PostgreSQL: когда JSONB избыточен

    Хотя JSONB универсален, PostgreSQL обладает мощной встроенной поддержкой типизированных массивов. Если необходимо сохранить простой список однородных элементов — например, список ID активированных инструментов ["search", "calculator", "weather"] или векторные представления (эмбеддинги) до перехода на специализированные векторные расширения — использование массивов предпочтительнее.

    Тип TEXT[] (массив строк) занимает меньше места, чем аналогичный массив в JSONB, так как не требует хранения метаданных структуры JSON. Кроме того, PostgreSQL предоставляет специфические операторы для массивов, такие как && (поиск пересечений).

    Например, чтобы найти сессии, где агенту был доступен хотя бы один инструмент из заданного списка:

    Для массивов также можно строить GIN-индексы, что делает их отличным легковесным инструментом для организации тегов, ролей или категорий в метаданных ИИ-системы, не прибегая к тяжеловесному парсингу JSON.

    Выбор правильного типа данных — это компромисс между гибкостью, объемом занимаемой памяти и скоростью поиска. UUIDv7 решает проблему конкурентной записи, JSONB с jsonb_path_ops обеспечивает мгновенную фильтрацию сложных логов агентов, TEXT снимает искусственные ограничения, а timestamptz гарантирует надежность метрик. Эта строгая физическая структура подготавливает фундамент для следующего шага — связывания базы данных с асинхронным Python-кодом.

    4. Асинхронное взаимодействие с БД: SQLAlchemy 2.0 и драйвер asyncpg в FastAPI

    Асинхронное взаимодействие с БД: SQLAlchemy 2.0 и драйвер asyncpg в FastAPI

    Когда ИИ-агент генерирует ответ, он выполняет множество операций ввода-вывода: обращается к API языковой модели, запрашивает векторную базу данных, вызывает внешние инструменты. Если в этот момент сервер попытается сохранить промежуточный лог рассуждений в реляционную базу данных через синхронное соединение, весь Event Loop остановится. Все остальные пользователи будут ждать, пока база данных не подтвердит запись. Для высоконагруженных систем с потоковой передачей токенов (Server-Sent Events) это означает катастрофическую деградацию производительности. Решением становится полный переход на асинхронное взаимодействие с PostgreSQL.

    Драйвер asyncpg: бинарный протокол и обход GIL

    Стандартным драйвером для работы с PostgreSQL в Python исторически являлся psycopg2. Он написан на C и использует библиотеку libpq. Главная проблема psycopg2 в контексте FastAPI заключается в том, что он выполняет сетевые вызовы синхронно, блокируя поток операционной системы до получения ответа от СУБД.

    Для асинхронных приложений стандартом де-факто стал драйвер asyncpg. В отличие от традиционных драйверов, соответствующих спецификации Python DB-API (PEP 249), asyncpg изначально проектировался исключительно для asyncio.

    Ключевые архитектурные особенности asyncpg:

  • Бинарный протокол PostgreSQL: Драйвер не использует текстовую передачу данных. Он сериализует и десериализует типы данных Python напрямую в бинарный формат PostgreSQL. Это исключает накладные расходы на парсинг строк на стороне сервера БД.
  • Отсутствие зависимости от libpq: asyncpg реализует протокол PostgreSQL с нуля на Cython, что позволяет ему глубоко интегрироваться с механизмами Event Loop без оверхеда на контекстные переключения потоков.
  • Поддержка подготовленных запросов (Prepared Statements): Драйвер автоматически кэширует планы выполнения запросов на стороне сервера, что критически важно при массовой вставке сообщений диалога, где меняются только параметры (текст, токены), а структура SQL остается неизменной.
  • Архитектура SQLAlchemy 2.0 для асинхронной работы

    Библиотека SQLAlchemy версии 2.0 претерпела фундаментальные изменения, объединив парадигмы Core (SQL-выражения) и ORM (объектно-реляционное отображение). В новой архитектуре полностью удален старый объект Query, а все запросы строятся через унифицированную конструкцию select().

    Для работы с asyncpg SQLAlchemy предоставляет асинхронный диалект и набор асинхронных оберток над классическими объектами.

    Настройка асинхронного движка (AsyncEngine)

    Точкой входа в базу данных является AsyncEngine. Он управляет пулом соединений и транслирует SQL-выражения в вызовы asyncpg.

    Параметры пула соединений требуют тонкой настройки с учетом специфики ИИ-нагрузок:

  • pool_size — количество постоянных TCP-соединений, которые SQLAlchemy держит открытыми.
  • max_overflow — количество дополнительных соединений, которые могут быть созданы при пиковой нагрузке (например, при массовом возврате результатов от LLM).
  • pool_pre_ping — механизм проверки жизнеспособности соединения перед его выдачей из пула. SQLAlchemy отправляет легковесный запрос SELECT 1. Это защищает приложение от ошибок разрыва соединения, если PostgreSQL был перезагружен.
  • expire_on_commit=False — критически важный параметр для асинхронной работы. По умолчанию SQLAlchemy помечает все объекты как "устаревшие" после коммита транзакции. При следующей попытке прочитать атрибут объекта ORM попытается выполнить синхронный запрос к БД для обновления данных, что в асинхронном коде приведет к фатальной ошибке.
  • Маппинг специфичных типов PostgreSQL в SQLAlchemy

    В предыдущих главах была спроектирована схема с использованием UUIDv7, JSONB и ENUM. SQLAlchemy 2.0 использует строгую типизацию Python (type hints) для генерации схемы БД через класс Mapped и функцию mapped_column.

    Реализуем модели для сессий и сообщений агента:

    Особенности маппинга:

  • UUID(as_uuid=True) указывает SQLAlchemy конвертировать строковое представление из PostgreSQL в нативный объект uuid.UUID в Python.
  • JSONB мапится на стандартный словарь Python (dict). При записи SQLAlchemy автоматически сериализует словарь в бинарный JSON, а при чтении — десериализует обратно.
  • Использование server_default=text(...) делегирует генерацию времени создания на сторону БД, гарантируя единый источник времени (UTC) независимо от часового пояса сервера приложения.
  • Проблема MissingGreenlet и стратегии загрузки связей

    Самая частая ошибка при переходе с синхронной SQLAlchemy на асинхронную — это исключение sqlalchemy.exc.MissingGreenlet.

    SQLAlchemy использует библиотеку greenlet под капотом для создания моста между синхронной архитектурой ORM (где обращение к атрибуту может вызвать SQL-запрос) и асинхронным драйвером asyncpg. Если попытаться обратиться к связанному объекту, который не был загружен из БД (например, session.messages), ORM попытается выполнить неявный I/O-запрос. В асинхронном контексте это запрещено, так как любой I/O должен сопровождаться ключевым словом await, которое невозможно поместить внутрь магического метода доступа к атрибуту.

    Ленивая загрузка (Lazy Loading) — антипаттерн в Asyncio

    В синхронном коде следующий паттерн работает корректно:

    В асинхронном коде обращение к session_obj.messages выбросит MissingGreenlet. Чтобы решить эту проблему, необходимо использовать жадную загрузку (Eager Loading), явно указывая планировщику запросов извлечь связанные данные в момент основного запроса.

    Жадная загрузка: joinedload против selectinload

    SQLAlchemy предоставляет две основные стратегии жадной загрузки:

  • joinedload: Генерирует SQL-запрос с использованием LEFT OUTER JOIN. База данных возвращает денормализованный набор строк (где данные сессии дублируются для каждого сообщения). SQLAlchemy парсит этот результат и собирает объекты в памяти.
  • - Плюсы: Выполняется ровно один сетевой запрос. - Минусы: При загрузке коллекций (один-ко-многим) дублирование данных сессии перегружает сеть. Несовместимо с LIMIT и OFFSET для главной таблицы, так как лимитирование применится к денормализованным строкам, а не к сессиям.

  • selectinload: Генерирует два независимых сетевых запроса. Сначала извлекаются сессии, затем их первичные ключи собираются в массив, и выполняется второй запрос вида SELECT * FROM messages WHERE session_id IN (...).
  • - Плюсы: Нет дублирования данных. Идеально работает с пагинацией и загрузкой коллекций. - Минусы: Выполняется два сетевых запроса (однако в пуле asyncpg накладные расходы на это минимальны).

    Для загрузки истории диалогов (коллекции) строго рекомендуется использовать selectinload.

    Выполнение асинхронных операций CRUD

    Конструкция select() формирует объект SQL-выражения. Для его выполнения всегда используется метод await session.execute(stmt). Возвращаемый объект Result содержит строки базы данных.

    Метод .scalars() извлекает первый элемент из каждой строки результата, что необходимо при работе с ORM-моделями (иначе результат будет кортежем (AgentMessage, )).

    Вставка данных (Insert)

    При сохранении новых сообщений от LLM используется метод session.add() или session.add_all(). Сами по себе эти методы не выполняют запросов к БД, они лишь регистрируют объекты в памяти сессии (Unit of Work). Фактическая отправка SQL-команды INSERT происходит при вызове await session.commit() (или await session.flush(), если нужно получить сгенерированный ID до завершения транзакции).

    Обновление JSONB без перезаписи

    Частый сценарий в ИИ-системах — обновление метаданных сообщения постфактум (например, добавление оценки качества ответа от пользователя). Перезаписывать весь JSON-объект неэффективно. SQLAlchemy позволяет использовать нативные функции PostgreSQL для точечного обновления JSONB через func.jsonb_set.

    В архитектуре SQLAlchemy 2.0 массовые обновления выполняются через конструкцию update(), минуя загрузку объектов в память.

    Интеграция с FastAPI через Dependency Injection

    Чтобы безопасно управлять жизненным циклом подключения к БД в рамках HTTP-запроса, используется механизм внедрения зависимостей FastAPI (Depends).

    Сессия базы данных не является потокобезопасной (thread-safe) и не должна разделяться между разными конкурентными запросами. Каждый HTTP-запрос должен получать свой собственный изолированный экземпляр AsyncSession.

    Создадим асинхронный генератор, который будет выдавать сессию и гарантированно закрывать ее после завершения обработки маршрута, возвращая соединение в пул asyncpg.

    Использование блока try/except/finally внутри генератора get_db_session обеспечивает надежность инфраструктуры. Если во время генерации ответа LLM произойдет сетевой сбой или таймаут, исключение будет перехвачено генератором, транзакция будет безопасно отменена (rollback()), а соединение не «утечет» (подвиснув в состоянии открытой транзакции), а корректно вернется в пул.

    Переход на связку SQLAlchemy 2.0 и asyncpg требует изменения мышления: отказ от неявных запросов к БД через ленивую загрузку и явное управление границами транзакций. Однако именно эта архитектура позволяет FastAPI-приложению выдерживать тысячи одновременных диалогов с ИИ-агентами, не упираясь в ограничения I/O-блокировок.

    5. Индексирование и оптимизация запросов: ускорение поиска по истории и метаданным

    Индексирование и оптимизация запросов: ускорение поиска по истории и метаданным

    В первые недели работы прототипа мульти-агентной системы база данных кажется бесконечно быстрой. Агент-аналитик мгновенно извлекает историю переписки из таблицы messages, LangGraph без задержек восстанавливает состояние графа из sessions, а RAG-пайплайн быстро фильтрует документы по метаданным. Но когда объем логов достигает сотен тысяч строк, эндпоинт /history начинает отвечать секунды вместо миллисекунд. Процессор сервера базы данных загружен на 100%, а I/O-подсистема диска становится узким горлышком. Причина кроется в том, что СУБД вынуждена читать каждую строку таблицы, чтобы найти нужные данные.

    Реляционная база данных не знает заранее, где физически на диске лежит сообщение с конкретным session_id. Без дополнительных структур данных планировщик запросов применяет единственно возможную стратегию — последовательное сканирование.

    Анатомия поиска: от Sequential Scan к B-дереву

    Последовательное сканирование (Sequential Scan) означает, что PostgreSQL открывает файл таблицы и читает его блок за блоком (страницами по ), проверяя каждую строку на соответствие условию WHERE. Сложность такого поиска составляет , где — количество строк. Если в таблице миллион сообщений агентов, а нам нужны три последних сообщения конкретной сессии, СУБД всё равно прочитает миллион строк.

    Для изменения алгоритмической сложности поиска с линейной на логарифмическую применяется индексирование. Стандартным типом индекса в PostgreSQL (используемым по умолчанию при команде CREATE INDEX) является B-дерево (B-Tree).

    B-дерево — это сбалансированная иерархическая структура данных. Она состоит из корневого узла (Root), внутренних узлов ветвления (Branch) и листовых узлов (Leaf).

  • Внутренние узлы хранят диапазоны значений и указатели на дочерние узлы.
  • Листовые узлы хранят сами проиндексированные ключи и физические адреса строк (CTID — Tuple ID) в основной таблице.
  • Когда выполняется запрос SELECT * FROM messages WHERE session_id = '018b...123', СУБД обращается к корню B-дерева. Сравнивая искомый session_id с диапазонами в узлах, алгоритм спускается к нужному листу. Для таблицы из миллиона строк потребуется всего 3-4 чтения страниц индекса вместо десятков тысяч чтений страниц таблицы.

    Именно поэтому монотонно возрастающие ключи, такие как рассмотренный ранее UUIDv7, критически важны для производительности. При вставке нового сообщения с UUIDv7 ключ всегда добавляется в крайний правый листовой узел B-дерева. Если же использовать случайный UUIDv4, ключи вставляются хаотично, что приводит к постоянному разделению страниц (Page Split), фрагментации индекса и деградации скорости записи.

    Чтение мыслей планировщика: EXPLAIN ANALYZE

    Чтобы понять, как именно PostgreSQL выполняет запрос, используется команда EXPLAIN. Добавление флага ANALYZE заставляет СУБД не просто построить план, но и реально выполнить запрос, замерив точное время и потребление ресурсов.

    Вывод этой команды предоставляет метрики, необходимые для оптимизации:

    > Seq Scan on messages (cost=0.00..18543.00 rows=15 width=512) (actual time=0.045..123.450 rows=12 loops=1) > Filter: (session_id = '018b7c9a-1234-7abc-8901-abcdef123456'::uuid) > Rows Removed by Filter: 499988 > Planning Time: 0.120 ms > Execution Time: 123.500 ms

    Ключевые элементы плана:

  • cost=0.00..18543.00: Оценка стоимости. Это не миллисекунды, а абстрактные единицы работы (преимущественно чтение страниц с диска). Первое число — стоимость получения первой строки, второе — стоимость завершения всего узла плана.
  • rows=15: Сколько строк планировщик ожидает получить на основе внутренней статистики.
  • actual time=0.045..123.450: Реальное время в миллисекундах. Запрос выполнялся почти 124 мс.
  • Rows Removed by Filter: 499988: СУБД прочитала полмиллиона строк, чтобы найти нужные 12. Это явный маркер отсутствия индекса.
  • После создания индекса CREATE INDEX idx_messages_session_id ON messages(session_id); план кардинально меняется:

    > Index Scan using idx_messages_session_id on messages (cost=0.42..12.45 rows=15 width=512) (actual time=0.012..0.035 rows=12 loops=1) > Index Cond: (session_id = '018b7c9a-1234-7abc-8901-abcdef123456'::uuid)

    Время выполнения упало с 123 мс до 0.035 мс. СУБД использовала Index Scan: сначала нашла адреса строк в B-дереве, а затем точечно прочитала только нужные 12 строк из самой таблицы (Heap).

    Index Only Scan и Visibility Map

    Существует еще более быстрая стратегия — Index Only Scan. Если запрос требует только те колонки, которые уже есть в индексе (например, SELECT session_id FROM messages), PostgreSQL может вообще не обращаться к основной таблице.

    Однако из-за механизма MVCC (мультиверсионности) индекс содержит указатели даже на мертвые или неподтвержденные версии строк. Чтобы вернуть данные только из индекса, СУБД должна убедиться, что строка видима для текущей транзакции. Для этого используется Visibility Map (Карта видимости) — компактная структура, отмечающая страницы таблицы, где все строки гарантированно видимы всем транзакциям. Если страница отмечена в Visibility Map, Index Only Scan отрабатывает без обращения к таблице.

    Композитные индексы и правило ведущей колонки

    В реальных ИИ-приложениях запросы редко фильтруются по одному полю. При восстановлении контекста для LLM обычно требуется получить последние сообщения конкретной сессии в хронологическом порядке:

    Одиночный индекс по session_id поможет быстро найти все сообщения сессии. Но если в долгоживущей сессии 5000 сообщений, СУБД извлечет их все, поместит в оперативную память и выполнит дорогостоящую операцию сортировки (в плане запроса появится узел Sort). Если памяти не хватит, сортировка прольется на диск (Sort Method: external merge Disk), что катастрофически замедлит ответ.

    Решение — композитный (многоколоночный) индекс. Он упорядочивает данные сначала по первому ключу, а при совпадении первого — по второму.

    С этим индексом СУБД мгновенно переходит к нужному session_id, а внутри этого блока записи уже отсортированы по времени убывания. Планировщик просто берет первые 10 записей и останавливается. Узел Sort исчезает из плана выполнения.

    Правило ведущей колонки: Порядок колонок в композитном индексе критически важен. Индекс (A, B) полезен для запросов WHERE A = ? и WHERE A = ? AND B = ?. Но он абсолютно бесполезен для запроса WHERE B = ?. Структуру композитного индекса можно сравнить с телефонным справочником, где записи отсортированы по фамилии, затем по имени. Найти всех людей с фамилией «Смит» и именем «Джон» легко. Найти всех людей с именем «Джон», игнорируя фамилию, невозможно без перелистывания всей книги.

    При проектировании композитных индексов следует применять эмпирическое правило: сначала колонки с проверкой на равенство (=), затем колонки с диапазонами (>, <, BETWEEN) или сортировкой (ORDER BY).

    Частичные индексы: хирургическая точность

    Индексы занимают место на диске и в оперативной памяти. Индексировать все строки таблицы не всегда целесообразно.

    Допустим, в системе есть таблица agent_tasks, фиксирующая фоновые задачи агентов (генерация отчетов, парсинг сайтов). 99% задач завершаются успешно (status = 'success'). Инженерная команда часто делает выборки для анализа ошибок:

    Создавать индекс по колонке status для всей таблицы бессмысленно. B-дерево будет огромным, а планировщик, скорее всего, проигнорирует его для успешных задач, так как они составляют 99% таблицы (проще сделать Seq Scan, чем читать индекс и потом прыгать по таблице).

    Здесь применяется частичный индекс (Partial Index). Он строится только для подмножества строк, удовлетворяющих определенному условию.

    Этот индекс будет содержать только 1% строк. Он занимает минимальный объем памяти, обновляется только при сбоях задач и обеспечивает мгновенный поиск по времени среди ошибочных выполнений. Это мощный инструмент для работы с флагами вроде is_deleted = false, is_active = true или has_errors = true.

    Индексы на выражениях: извлечение метрик из JSONB

    Ранее было рассмотрено использование GIN-индексов для типа JSONB. GIN (Generalized Inverted Index) отлично справляется с проверкой существования ключей (оператор ?) или вхождения поддокументов (оператор @>).

    Но GIN не поддерживает операции сравнения (больше/меньше) и сортировку. Если ИИ-агент сохраняет метаданные каждого шага рассуждения в JSONB-колонку payload, и нам нужно найти шаги, где модель была не уверена в ответе:

    GIN-индекс здесь не поможет. СУБД выполнит последовательное сканирование, на лету распаковывая JSONB каждой строки, извлекая текст по ключу confidence_score и приводя его к числу numeric. Это крайне ресурсоемкая операция.

    PostgreSQL позволяет строить B-деревья не только по колонкам, но и по результатам вычисления функций — это называется индексом на выражении (Expression Index).

    При создании этого индекса СУБД один раз вычисляет выражение для каждой строки и сохраняет готовые числа в B-дерево. Теперь поиск по порогу уверенности или сортировка ORDER BY (payload->>'confidence_score')::numeric будут использовать индекс, работая со скоростью обычных реляционных колонок.

    Важный нюанс: выражение в SQL-запросе должно синтаксически до символа совпадать с выражением, указанным при создании индекса. Иначе планировщик не распознает возможность его использования.

    Темная сторона индексирования: пенальти на запись и HOT-обновления

    Индексы не бесплатны. Каждый индекс на таблице замедляет операции INSERT, UPDATE и DELETE. При вставке новой строки СУБД должна записать данные в саму таблицу (Heap), а затем обновить каждое B-дерево, связанное с этой таблицей. Если на таблице messages висит 10 индексов, одна операция INSERT превращается в 11 операций записи на диск.

    Особую проблему представляют обновления (UPDATE). Из-за архитектуры MVCC PostgreSQL не изменяет строку на месте. Он помечает старую версию как удаленную и записывает новую версию строки в свободное место на странице. Физический адрес строки (CTID) меняется. Следовательно, СУБД должна обойти все индексы таблицы и добавить в них указатели на новый CTID, даже если обновленная колонка вообще не проиндексирована.

    Для минимизации этого эффекта в PostgreSQL реализован механизм HOT (Heap-Only Tuples). Если при UPDATE:

  • Изменяются колонки, которые не участвуют ни в одном индексе.
  • На текущей физической странице таблицы есть свободное место для записи новой версии строки.
  • Тогда PostgreSQL создает HOT-обновление. Новая версия строки записывается на ту же страницу, а старая версия получает внутреннюю ссылку на новую. Главное преимущество: индексы не обновляются вообще. Указатель в индексе продолжает вести на старую версию, а СУБД, перейдя по нему, автоматически следует по внутренней ссылке к актуальным данным.

    Чтобы HOT-обновления работали эффективно в системах с высокой частотой изменений (например, когда агент постоянно обновляет статус processing_state), необходимо избегать индексирования часто изменяемых полей без крайней необходимости и настраивать параметр таблицы fillfactor (оставлять резервное место на страницах для будущих обновлений).

    Память как главный ресурс

    Скорость работы индексов напрямую зависит от того, находятся ли они в оперативной памяти. В PostgreSQL за кэширование отвечает параметр shared_buffers. Когда планировщик решает использовать B-дерево, СУБД сначала ищет страницы индекса в памяти (в выводе EXPLAIN (ANALYZE, BUFFERS) это отображается как shared hit). Если страницы там нет, происходит чтение с диска (shared read).

    Чтение с диска, даже с быстрого NVMe SSD, на порядки медленнее обращения к RAM. Если общая база индексов превышает доступный объем оперативной памяти, начинается постоянное вытеснение страниц (Thrashing). Система деградирует: индексы, которые должны ускорять работу, превращаются в генераторы случайного дискового ввода-вывода.

    Поэтому индексирование — это всегда компромисс. Инженер должен выбирать только те индексы, которые покрывают наиболее критичные пути выполнения (например, восстановление контекста для генерации ответа), и безжалостно удалять неиспользуемые индексы, чтобы сохранить ресурсы для кэширования действительно важных данных.

    6. Миграции базы данных с Alembic: управление изменениями схемы в жизненном цикле MVP

    Миграции базы данных с Alembic: управление изменениями схемы в жизненном цикле MVP

    Вы обновляете код FastAPI, добавляя в модель AgentMessage новое поле token_usage для подсчета стоимости вызовов LLM. Локальные тесты проходят успешно. Вы разворачиваете новую версию на сервере, и система мгновенно падает с ошибкой: column "token_usage" of relation "messages" does not exist. Приложение ожидает новую структуру, а база данных всё ещё работает со старой. В условиях прототипа можно было бы просто удалить базу и создать её заново командой Base.metadata.create_all(bind=engine), но в рабочей системе это означает полное уничтожение эпизодической памяти агентов, истории диалогов и системных логов.

    Для решения проблемы рассинхронизации между кодом приложения и физической структурой базы данных применяется контроль версий схемы — миграции. В экосистеме SQLAlchemy стандартом де-факто является библиотека Alembic.

    Эволюция схемы и таблица состояний

    Метод create_all(), часто используемый на старте разработки, обладает свойством идемпотентности только для создания новых объектов. Если таблица messages уже существует, SQLAlchemy проигнорирует её, даже если в Python-коде появились новые колонки. ORM не умеет автоматически вычислять разницу (diff) между классами и реальной базой данных и применять команды ALTER TABLE.

    Alembic решает эту задачу, вводя концепцию версионирования. Состояние базы данных отслеживается через служебную таблицу alembic_version, которая содержит единственную колонку version_num. В этой колонке хранится идентификатор последней успешно примененной миграции.

    Сами миграции представляют собой Python-скрипты, хранящиеся в директории проекта (обычно alembic/versions). Каждый скрипт описывает два вектора изменений:

  • upgrade() — как перевести базу данных из предыдущего состояния в новое (например, добавить колонку).
  • downgrade() — как откатить эти изменения назад (например, удалить колонку).
  • При запуске команды обновления Alembic читает значение из alembic_version, строит путь по графу скриптов миграций до актуальной версии и последовательно выполняет функции upgrade(), после чего обновляет идентификатор в служебной таблице.

    Адаптация Alembic для асинхронного стека

    Поскольку взаимодействие с базой данных построено на базе асинхронного драйвера asyncpg, стандартная конфигурация Alembic, рассчитанная на синхронные драйверы (вроде psycopg2), работать не будет. Alembic должен уметь импортировать ваши SQLAlchemy-модели и подключаться к БД для интроспекции.

    Настройка асинхронного окружения происходит в файле alembic/env.py. Ключевое изменение заключается в отказе от стандартного пула соединений в пользу NullPool (так как миграции — это разовый процесс, и поддержание пула не требуется) и оборачивании процесса выполнения миграций в асинхронный контекст.

    Для этого в env.py реализуется функция run_async_migrations, которая использует asyncio.run() для запуска корутины, создающей асинхронный движок:

    Метод run_sync здесь выступает мостом: он позволяет выполнить синхронный код ядра Alembic (do_run_migrations) внутри асинхронного соединения asyncpg, не блокируя цикл событий, что критически важно для корректной работы драйвера.

    Анатомия миграции и направленный ациклический граф

    Каждый скрипт миграции в Alembic является узлом в направленном ациклическом графе (DAG), чаще всего вырождающемся в простой связный список. Скрипт идентифицируется уникальным хэшем (Revision ID) и содержит ссылку на предыдущий узел (Down Revision).

    Рассмотрим структуру сгенерированного файла:

    Объект op (Operations) предоставляет абстрактный API для изменения схемы. При выполнении alembic upgrade head система находит текущую версию в БД (например, 1a2b3c4d5e6f), видит, что она является down_revision для 3f8a9b2c1d4e, и выполняет upgrade().

    Важно понимать асимметрию операций upgrade и downgrade в контексте данных. Если upgrade добавляет колонку, она изначально пуста. Если downgrade удаляет колонку, все данные, накопленные в ней за время работы новой версии, уничтожаются безвозвратно. По этой причине в production-системах откат миграций (downgrade) применяется крайне редко. Вместо этого используется стратегия "Roll Forward" — написание новой миграции, которая исправляет ошибку или возвращает структуру к нужному виду, сохраняя контроль над трансформацией данных.

    Слепые зоны автогенерации

    Команда alembic revision --autogenerate сравнивает метаданные SQLAlchemy (ваши классы в Python) с текущим состоянием базы данных и генерирует скрипт. Однако этот механизм не всесилен. Существуют «слепые зоны», требующие ручного вмешательства.

    Переименование колонок и таблиц

    Если вы переименуете поле query в search_query в модели, --autogenerate не распознает это как переименование. Он увидит два независимых события: удаление старой колонки и добавление новой.

    Сгенерированный код будет выглядеть так:

    Выполнение такого скрипта приведет к потере всех исторических данных из поля query. Скрипт необходимо отредактировать вручную, заменив удаление и добавление на операцию переименования:

    Модификация ENUM в PostgreSQL

    Тип данных ENUM в PostgreSQL жестко фиксируется при создании. Если в процессе развития ИИ-системы вам потребовалось добавить новую роль для внутреннего диалога агентов (например, supervisor в дополнение к system, user, assistant, tool), Alembic не сможет автоматически сгенерировать корректный ALTER TYPE.

    Автогенератор либо проигнорирует изменение, либо попытается пересоздать колонку. Для безопасного добавления значения в ENUM необходимо использовать сырой SQL через метод op.execute().

    Миграция данных: трансформация без потери контекста

    Изменение структуры таблиц — лишь половина задачи. По мере усложнения логики ИИ-агентов часто возникает необходимость трансформировать сами данные. Это называется миграцией данных (Data Migration).

    Предположим, в первых версиях MVP ответ модели вместе с вызовами инструментов (Tool Calls) и системными метаданными сохранялся в единую текстовую колонку raw_response. На новом этапе развития архитектуры было принято решение вынести вызовы инструментов в отдельную колонку tool_payload типа JSONB для эффективного поиска и индексирования, оставив в raw_response только текст для пользователя.

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

    Использование op.execute позволяет перенести тяжелую логику обработки строк на сторону СУБД. Выполнение трансформации на уровне базы данных происходит на порядки быстрее, чем выгрузка миллионов строк в память Python-приложения, их обработка и обратная загрузка.

    Безопасность в production: блокировки и индексы

    При выполнении DDL-операций (Data Definition Language), таких как ALTER TABLE, PostgreSQL накладывает эксклюзивную блокировку (AccessExclusiveLock) на таблицу. В этот момент любые запросы на чтение или запись от ИИ-агентов будут поставлены в очередь ожидания. Если миграция занимает доли секунды — это незаметно. Но если операция требует переписывания данных на диске, система фактически останавливается.

    Добавление колонки без значения по умолчанию (DEFAULT) или с константным значением по умолчанию в современных версиях PostgreSQL (начиная с 11 версии) выполняется за время , так как СУБД просто обновляет метаданные таблицы, не перезаписывая физические строки. Однако создание индексов — это всегда ресурсоемкая операция.

    Ранее мы рассматривали создание GIN-индексов для полей JSONB. Если попытаться добавить такой индекс через стандартную миграцию на таблицу с миллионами логов, таблица будет заблокирована на всё время построения B-дерева или инвертированного индекса.

    Для решения этой проблемы в PostgreSQL существует механизм конкурентного создания индексов (CONCURRENTLY). Он строит индекс в фоновом режиме, сканируя таблицу дважды, не блокируя операции записи и чтения. Alembic поддерживает этот механизм через параметр postgresql_concurrently.

    Использование autocommit_block() временно выводит операцию из-под контроля глобальной транзакции миграции. Это означает, что если создание индекса упадет с ошибкой на середине процесса, индекс останется в базе данных в состоянии INVALID, и его придется удалять вручную перед повторным запуском миграции. Это компромисс, на который идут ради обеспечения нулевого времени простоя (Zero-Downtime) высоконагруженных систем.

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

    7. Транзакции и уровни изоляции: обеспечение целостности данных при параллельной работе агентов

    Транзакции и уровни изоляции: обеспечение целостности данных при параллельной работе агентов

    Два автономных ИИ-агента одновременно получают триггер на обработку сложного запроса пользователя. Оба агента обращаются к таблице биллинга, видят на балансе 1000 токенов. Первый агент списывает 800 токенов за генерацию ответа и обновляет баланс до 200. Через миллисекунду второй агент, завершив свою часть работы стоимостью 500 токенов, вычитает их из изначально прочитанных 1000 и записывает в базу 500. В результате система «забыла» о списании первого агента, подарив пользователю 800 токенов. Эта ситуация — классическое состояние гонки (Race Condition), которое неизбежно возникает при масштабировании мультиагентных систем, если взаимодействие с базой данных не опирается на строгие правила изоляции транзакций.

    Журнал упреждающей записи (WAL) и границы транзакций

    Любое изменение данных в PostgreSQL не происходит мгновенно на жестком диске в файлах самих таблиц. Гарантия долговечности и атомарности обеспечивается механизмом Write-Ahead Log (WAL) — журналом упреждающей записи.

    Когда приложение отправляет команду UPDATE или INSERT, СУБД сначала записывает это намерение в последовательный бинарный файл WAL. Только после того, как ОС подтвердит физическое сохранение WAL на диск (через системный вызов fsync), транзакция считается успешно зафиксированной (COMMIT). Сами страницы данных в таблицах обновляются в фоне процессом Checkpointer. Если сервер внезапно теряет питание, при перезапуске PostgreSQL читает WAL и «накатывает» все подтвержденные, но не перенесенные в таблицы изменения.

    В асинхронном коде на SQLAlchemy управление границами транзакции обычно инкапсулируется в контекстные менеджеры.

    Блок async with session.begin() отправляет в базу команду BEGIN. Если внутри блока возникает любое исключение (сетевая ошибка, ошибка валидации Pydantic, таймаут LLM), контекстный менеджер автоматически отправляет ROLLBACK, отменяя все изменения в памяти СУБД до их фиксации в WAL. Если блок завершается успешно, отправляется COMMIT.

    Однако атомарность (всё или ничего) решает проблему сбоев внутри одного процесса. Когда десятки процессов (воркеров Celery, эндпоинтов FastAPI) пытаются изменить одни и те же строки одновременно, на первый план выходит изоляция.

    Аномалии конкурентного доступа

    Стандарт SQL определяет несколько классических аномалий, возникающих при параллельном выполнении транзакций. Понимание этих аномалий необходимо для выбора правильной стратегии защиты данных ИИ-пайплайнов.

    Потерянное обновление (Lost Update)

    Именно этот сценарий описан в начале статьи. Транзакция читает строку, затем транзакция читает ту же строку. обновляет строку и фиксирует изменения. Затем обновляет ту же строку на основе старых данных, которые она прочитала ранее, и фиксирует изменения. Результат работы безвозвратно перезаписан. В контексте ИИ это приводит к искажению статистики использования моделей, перезаписи метаданных сессии или потере промежуточных шагов рассуждения (Chain-of-Thought), если два агента пытаются дополнить один и тот же JSONB-документ.

    Неповторяющееся чтение (Non-Repeatable Read)

    Транзакция читает строку. В это время транзакция изменяет или удаляет эту строку и делает COMMIT. Если попытается прочитать эту же строку еще раз в рамках своей текущей транзакции, она получит другие данные (или не найдет строку вообще). Пример: агент-суммаризатор читает список документов, привязанных к сессии, чтобы оценить объем контекста. Пока он принимает решение о стратегии чанкинга, другой процесс удаляет один из документов. Когда агент пытается извлечь содержимое этих документов, часть из них уже не существует, что приводит к ошибке логики.

    Фантомное чтение (Phantom Read)

    Транзакция выполняет запрос по условию (например, SELECT * FROM tasks WHERE status = 'pending'). Транзакция вставляет новую строку, удовлетворяющую этому условию, и фиксирует ее. Если повторит тот же запрос, в выборке появится новая, «фантомная» строка. Это критично для агентов-оркестраторов, которые распределяют задачи: оркестратор может рассчитать нагрузку на основе 10 задач, а в реальности их окажется 11, что нарушит алгоритм балансировки.

    Уровни изоляции в PostgreSQL

    Для предотвращения аномалий стандарт SQL предлагает уровни изоляции. PostgreSQL реализует их с помощью механизма MVCC, что накладывает свои особенности на их поведение.

    Read Committed (Чтение зафиксированных данных)

    Это уровень изоляции по умолчанию в PostgreSQL. На этом уровне транзакция видит только те данные, которые были зафиксированы до начала выполнения текущего SQL-запроса (а не до начала самой транзакции).

    Если пытается обновить строку, которую прямо сейчас обновляет (которая еще не сделала COMMIT), будет заблокирована и будет ждать завершения . Если сделает ROLLBACK, обновит исходную строку. Но если сделает COMMIT, заново оценит условие WHERE для обновленной версии строки и, если оно выполняется, применит свое обновление к новым данным.

    Этот уровень допускает неповторяющееся чтение, фантомное чтение и, что самое опасное, потерянное обновление, если логика приложения выполняет чтение, а затем запись отдельными запросами (паттерн Read-Modify-Write). Уровень Read Committed отлично подходит для добавления новых записей (например, логов диалога), но категорически не подходит для финансовых операций или конкурентного изменения состояния агента.

    Repeatable Read (Повторяющееся чтение)

    На этом уровне транзакция видит снимок базы данных (Snapshot), зафиксированный на момент начала первого запроса внутри транзакции. Все последующие SELECT в рамках этой транзакции будут видеть одни и те же данные, даже если другие транзакции параллельно изменяют их и делают COMMIT.

    В PostgreSQL Repeatable Read реализован строже, чем требует стандарт SQL: он предотвращает не только неповторяющееся чтение, но и фантомное чтение.

    Главная особенность этого уровня — защита от потерянного обновления через генерацию ошибки. Если и читают одну и ту же строку, а затем обе пытаются ее обновить, первая успевшая транзакция () заблокирует строку. будет ждать. Когда сделает COMMIT, PostgreSQL не позволит перезаписать данные (как это было бы в Read Committed). Вместо этого будет прервана с ошибкой SerializationFailure (SQLState 40001: could not serialize access due to concurrent update).

    Использование этого уровня требует обязательной обработки ошибок на уровне приложения. В SQLAlchemy это выглядит так:

    Serializable (Сериализуемость)

    Обеспечивает самую строгую изоляцию. Результат параллельного выполнения транзакций гарантированно совпадает с результатом их последовательного выполнения в каком-либо порядке. PostgreSQL использует алгоритм Serializable Snapshot Isolation (SSI), который отслеживает зависимости между чтением и записью разных транзакций. Если СУБД обнаруживает цикл зависимостей, который может привести к аномалии (например, Serialization Anomaly, когда две транзакции читают данные друг друга и на их основе делают записи), одна из транзакций прерывается с ошибкой SerializationFailure.

    В системах ИИ-агентов уровень Serializable применяется редко из-за высоких накладных расходов на мониторинг зависимостей и высокой вероятности отката транзакций. Обычно комбинации Repeatable Read и явных блокировок достаточно для решения 99% задач.

    Пессимистичные блокировки: SELECT ... FOR UPDATE

    Полагаться на откат транзакции (как в Repeatable Read) не всегда оптимально. Если обновление данных — тяжелый процесс, лучше заблокировать строку заранее, запретив другим транзакциям ее изменять до завершения текущей. Это называется пессимистичной блокировкой.

    В PostgreSQL это реализуется конструкцией SELECT ... FOR UPDATE. Когда транзакция выполняет такой запрос, она вешает на прочитанные строки эксклюзивную блокировку на уровне строк (Row-Level Lock). Другие транзакции, пытающиеся сделать UPDATE, DELETE или SELECT ... FOR UPDATE этих же строк, «повиснут» в ожидании завершения первой транзакции.

    Паттерн SKIP LOCKED для очередей задач

    Особую ценность для ИИ-инфраструктуры представляет модификация FOR UPDATE SKIP LOCKED. Представьте таблицу rag_tasks, куда складываются задачи на векторизацию документов. Десятки воркеров Celery непрерывно опрашивают эту таблицу, чтобы взять задачу в работу.

    Если воркеры будут использовать обычный SELECT ... LIMIT 1, они все прочитают одну и ту же задачу. Если использовать SELECT ... FOR UPDATE LIMIT 1, первый воркер заблокирует задачу, а остальные 99 воркеров выстроятся в очередь, ожидая снятия блокировки, простаивая впустую (Lock Contention).

    Конструкция SKIP LOCKED заставляет СУБД игнорировать заблокированные строки при поиске. Первый воркер блокирует строку 1. Второй воркер мгновенно пропускает строку 1 и блокирует строку 2. Третий — строку 3. Это позволяет построить высокопроизводительную многопоточную очередь прямо в реляционной базе.

    Реализация в SQLAlchemy 2.0:

    Оптимистичные блокировки: контроль версий на уровне приложения

    Пессимистичная блокировка (FOR UPDATE) удерживает соединение с базой данных и физическую блокировку строки на все время транзакции. В классических веб-приложениях транзакции длятся миллисекунды. Но в ИИ-системах агент может запросить блокировку состояния, затем отправить запрос к OpenAI, ждать ответа 30 секунд, и только потом обновить статус в БД.

    Удерживать транзакцию и блокировку в СУБД на 30 секунд — архитектурный антипаттерн. Это приведет к исчерпанию пула соединений (Connection Pool Exhaustion) и деградации всей системы. Для длительных бизнес-процессов применяется Оптимистичная блокировка (Optimistic Concurrency Control).

    Суть метода в том, что база данных вообще не блокируется на время работы агента. Вместо этого в таблицу добавляется колонка целочисленной версии (например, version_id).

  • Агент читает строку: status = 'idle', version_id = 1.
  • Транзакция закрывается, соединение возвращается в пул.
  • Агент думает 30 секунд, генерирует ответ.
  • Агент открывает новую транзакцию и пытается обновить строку, явно указывая ожидаемую версию в условии:
  • UPDATE agents SET status = 'completed', version_id = 2 WHERE id = X AND version_id = 1.

    Если за эти 30 секунд другой процесс (например, администратор через админку или таймаут-монитор) изменил состояние агента, колонка version_id в базе уже будет равна 2. Запрос UPDATE ... WHERE version_id = 1 не найдет ни одной строки и вернет 0 обновленных записей. Приложение поймет, что данные устарели, и отменит операцию или запустит процесс слияния конфликтов.

    SQLAlchemy имеет встроенную поддержку этого паттерна. Достаточно указать конфигурацию маппера version_id_col:

    Теперь при вызове session.commit() SQLAlchemy автоматически добавит условие WHERE version = X в генерируемый SQL-запрос. Если база данных вернет, что обновлено 0 строк, SQLAlchemy выбросит исключение StaleDataError. Разработчику остается только перехватить его и реализовать логику компенсации (например, уведомить пользователя, что состояние системы изменилось во время генерации ответа).

    Выбор между механизмами изоляции напрямую зависит от длительности операции и допустимости ожидания. Для мгновенных математических операций (списание баланса) идеально подходит Repeatable Read с механизмом повторных попыток. Для распределения быстрых фоновых задач между воркерами — пессимистичный FOR UPDATE SKIP LOCKED. А для управления долгоживущими состояниями агентов, где I/O-задержки LLM измеряются десятками секунд, единственным безопасным решением становится оптимистичная блокировка через версионирование.

    8. Сложные выборки и оконные функции: анализ производительности и цепочек рассуждений (CoT)

    Сложные выборки и оконные функции: анализ производительности и цепочек рассуждений (CoT)

    Агент получает сложный промпт, инициирует цепочку рассуждений (Chain-of-Thought), делает три запроса к внешним API, корректирует свой план на основе полученных ошибок и, наконец, выдает ответ. Пользователь читает его, нажимает кнопку «Regenerate», и агент запускает альтернативную ветку вычислений. В базе данных остаются десятки строк логов, разбросанных во времени и связанных сложной иерархией. Если мы хотим понять, почему вторая генерация оказалась успешнее первой, или вычислить точную задержку между вызовом инструмента и парсингом его ответа, стандартных SELECT и GROUP BY будет недостаточно. База данных должна перестать быть просто хранилищем состояний и превратиться в аналитический движок.

    Для извлечения глубоких инсайтов из эпизодической памяти агентов применяются продвинутые механизмы SQL: обобщенные табличные выражения (CTE) для обхода графов и оконные функции для анализа последовательностей без потери детализации строк.

    Обобщенные табличные выражения (CTE) и рекурсия

    В реляционной модели данные хранятся в плоских таблицах. Однако история диалога с агентом, особенно при наличии функций редактирования сообщений или регенерации ответов, представляет собой дерево. Как было спроектировано ранее, мы используем паттерн Adjacency List, где каждое сообщение имеет parent_message_id.

    Обычный запрос SELECT * FROM messages WHERE session_id = 'x' ORDER BY created_at вернет хронологическую «кашу» из всех веток диалога. Чтобы извлечь одну чистую, непрерывную линию контекста от конкретного финального ответа до самого начала сессии (системного промпта), используется рекурсивное обобщенное табличное выражение (WITH RECURSIVE).

    Механика WITH RECURSIVE

    Рекурсивный CTE в PostgreSQL состоит из трех обязательных элементов:

  • Стартовый (нерекурсивный) запрос, определяющий начальную точку (якорь).
  • Оператор UNION или UNION ALL.
  • Рекурсивный запрос, который ссылается на сам CTE и выполняется итеративно, пока не вернет пустой набор строк.
  • Рассмотрим задачу: восстановить точный контекст диалога, который привел к сообщению с идентификатором msg_777. Нам нужно двигаться вверх по дереву, от потомка к предкам.

    В этом запросе мы искусственно вводим счетчик depth. На первом шаге целевому сообщению присваивается глубина 1. На каждой следующей итерации JOIN находит parent_message_id текущей строки и извлекает родителя, увеличивая depth. Финальная сортировка ORDER BY depth DESC переворачивает результат, выстраивая сообщения в правильном хронологическом порядке для передачи в API нейросети: от системного промпта до последнего запроса пользователя.

    При проектировании агентов, способных автономно порождать подзадачи (Sub-agents), глубина графа может достигать сотен узлов. Планировщик PostgreSQL выполняет рекурсивные CTE через механизм WorkTable — временную таблицу в оперативной памяти, куда складываются результаты текущей итерации для использования в следующей. Если дерево содержит циклы (что в логах агентов свидетельствует о критическом баге логики), запрос уйдет в бесконечный цикл. Начиная с PostgreSQL 14, для защиты от таких ситуаций применяется конструкция CYCLE:

    Она автоматически отслеживает уже посещенные id и прерывает ветку, устанавливая флаг is_cycle = true.

    Оконные функции: анализ последовательностей и задержек

    Агрегатные функции (SUM, AVG, MAX в связке с GROUP BY) сжимают набор строк в одну. Если мы хотим узнать среднее время ответа агента за день, агрегация отлично подойдет. Но если задача — вычислить задержку (Latency) каждого конкретного шага внутри сессии, сохранив при этом доступ к метаданным самого шага, агрегация разрушит контекст.

    Здесь на сцену выходят оконные функции (Window Functions). Они выполняют вычисления для набора строк, связанных с текущей строкой, но не группируют их в единый вывод.

    Вычисление Latency цепочки рассуждений через LAG

    Представим таблицу agent_traces, куда записывается каждый микрошаг агента: начало генерации, вызов инструмента, получение ответа, финальный вывод. Нам нужно понять, сколько секунд занял каждый шаг. Для этого необходимо вычесть created_at предыдущей строки из created_at текущей.

    Функция LAG(column, offset, default) позволяет заглянуть на указанное количество строк назад в рамках определенного «окна».

    Конструкция OVER (...) определяет окно:

  • PARTITION BY session_id изолирует вычисления. LAG не захватит последнюю строку предыдущей сессии, окно сбрасывается для каждого нового session_id.
  • ORDER BY created_at гарантирует строгую хронологию внутри партиции.
  • Первая строка в каждой сессии получит NULL в колонке step_latency_seconds, так как предыдущего шага не существует. Для обработки таких случаев используется функция COALESCE, заменяющая NULL на безопасное значение (например, 0).

    Ранжирование: поиск аномалий потребления токенов

    Мониторинг затрат требует выявления самых дорогих запросов. Найти топ-3 самых затратных сессий для каждого пользователя — классическая задача, решаемая функциями ранжирования ROW_NUMBER(), RANK() и DENSE_RANK().

    Сравним их поведение при одинаковом количестве токенов у нескольких сессий:

  • ROW_NUMBER() присвоит строго уникальные номера (1, 2, 3, 4), игнорируя ничьи.
  • RANK() присвоит одинаковые ранги при ничьей, но пропустит следующие номера (1, 1, 3, 4).
  • DENSE_RANK() присвоит одинаковые ранги, не оставляя разрывов (1, 1, 2, 3).
  • Для финансовой аналитики логичнее использовать DENSE_RANK, чтобы не упустить сессии с одинаковой стоимостью.

    Оконные функции вычисляются после фильтрации WHERE в основном запросе. Поэтому мы не можем написать WHERE token_rank <= 3 напрямую в первом SELECT — планировщик выдаст синтаксическую ошибку. Оборачивание в CTE решает проблему порядка выполнения операций.

    Скользящие окна и кумулятивные метрики

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

    Оконные функции позволяют задавать рамки (Window Frames) внутри партиции. Синтаксис ROWS BETWEEN X AND Y определяет физическое количество строк до и после текущей.

    Вычислим нарастающий итог (Running Total) токенов внутри сессии:

    Фраза ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW означает: «от самого начала текущей партиции до текущей строки включительно». По мере движения запроса сверху вниз по хронологии, сумма будет накапливаться.

    Нюанс производительности: В PostgreSQL по умолчанию (если указан ORDER BY, но не указан фрейм) используется рамка RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Разница между ROWS и RANGE критична. ROWS работает с физическими строками. RANGE работает с логическими значениями ключа сортировки и включает в сумму все строки-дубликаты, имеющие одинаковое значение created_at. Вычисление RANGE требует дополнительных затрат процессора на сравнение значений. Для логов агента, где важна строгая последовательность шагов, явное указание ROWS работает быстрее и предсказуемее.

    Разворачивание JSONB: агрегация внутри цепочек рассуждений

    В гибридной схеме данных аргументы вызовов инструментов и промежуточные размышления (thoughts) часто сохраняются массивами внутри поля JSONB.

    Пример структуры поля payload в таблице agent_traces:

    Если мы хотим построить статистику: «какие инструменты чаще всего вызывают ошибки», нам нужно превратить элементы массива tool_calls в отдельные реляционные строки. Для этого применяются функции, возвращающие множества (Set-Returning Functions, SRF), в частности jsonb_array_elements().

    Эта функция берет один массив и «взрывает» (unnest) его, создавая по одной строке для каждого элемента массива, дублируя при этом данные из остальных колонок исходной строки.

    В этом конвейере:

  • CTE UnnestedTools трансформирует одну строку с двумя вызовами инструментов в две отдельные строки.
  • Оператор ->> извлекает текстовое значение ключа name из каждого объекта.
  • Условная агрегация SUM(CASE...) подсчитывает количество ошибок без необходимости писать подзапросы.
  • Комбинирование SRF и оконных функций

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

    Здесь SUM(tool_tokens) вычисляет токены для конкретного инструмента (благодаря GROUP BY). А конструкция SUM(SUM(tool_tokens)) OVER (PARTITION BY session_id) вычисляет общую сумму токенов по всем инструментам в сессии. Оконная функция выполняется после группировки, поэтому она может оперировать результатами агрегатных функций. Это позволяет в одном запросе получить и локальную метрику, и глобальную базу для расчета процентов.

    Перенос подобных вычислений из Python-кода приложения на уровень базы данных радикально снижает объем передаваемых по сети данных и использует оптимизированные C-процедуры PostgreSQL. Способность манипулировать древовидными структурами диалогов, вычислять скользящие окна метрик и разворачивать вложенные JSON-массивы превращает реляционную базу в полноценный инструмент профилирования когнитивных процессов ИИ-агента.

    9. Полнотекстовый поиск и интеграция с векторными хранилищами: гибридные подходы к памяти

    Полнотекстовый поиск и интеграция с векторными хранилищами: гибридные подходы к памяти

    Агент технической поддержки анализирует логи базы данных и пытается найти решения для ошибки ORA-00942: table or view does not exist. Если система памяти опирается исключительно на векторный поиск (эмбеддинги), агент с высокой вероятностью получит документы, описывающие общие проблемы с доступом к базе данных, концепции авторизации или сетевые сбои. Векторные модели отлично улавливают семантическую близость «нет доступа к таблице», но часто игнорируют специфические токены вроде ORA-00942, растворяя их в общем многомерном векторе предложения. Для извлечения точных фактов, артикулов, имен собственных и кодов ошибок семантического поиска недостаточно — требуется точное лексическое совпадение.

    Организация надежной эпизодической памяти ИИ-агента требует гибридного подхода, объединяющего понимание смысла (векторы) и поиск по ключевым словам (полнотекстовый поиск).

    Механика полнотекстового поиска (FTS) в PostgreSQL

    Полнотекстовый поиск (Full-Text Search) в PostgreSQL не использует операторы LIKE или регулярные выражения, которые требуют последовательного сканирования (Sequential Scan) всех строк. FTS опирается на предварительную лингвистическую обработку текста и инвертированные индексы.

    Процесс поиска базируется на двух специализированных типах данных: tsvector и tsquery.

    Преобразование документов: tsvector

    Тип tsvector представляет собой оптимизированный для поиска список лексем. Лексема — это базовая форма слова, очищенная от окончаний, суффиксов и приведенная к нижнему регистру с помощью алгоритмов стемминга.

    Когда текст конвертируется в tsvector с помощью функции to_tsvector(), PostgreSQL выполняет следующие шаги:

  • Разбивает текст на токены (слова, числа, email-адреса).
  • Удаляет стоп-слова (предлоги, союзы, часто встречающиеся слова, не несущие смысловой нагрузки, например, «и», «в», «the», «a»).
  • Применяет словари (dictionaries) для приведения слов к их корню (стемминг).
  • Например, фраза «Агенты быстро обработали запросы пользователей» после обработки русским словарем превратится в структуру: 'агент':1 'быстр':2 'запрос':4 'обработа':3 'пользовател':5

    Числа после двоеточия указывают позицию лексемы в исходном тексте. Это позволяет базе данных не только находить слова, но и оценивать расстояние между ними (поиск фраз).

    Формирование запроса: tsquery

    Тип tsquery описывает условия поиска. Он также проходит через стемминг и удаление стоп-слов, но дополнительно поддерживает логические операторы: & (И), | (ИЛИ), ! (НЕ) и <-> (оператор следования).

    Функция to_tsquery() или plainto_tsquery() переводит пользовательский ввод в машиночитаемый запрос. Сопоставление документа и запроса выполняется с помощью оператора @@.

    Пример поиска в таблице сообщений агента:

    Ранжирование и веса

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

    Для повышения точности PostgreSQL позволяет назначать веса (от A до D) разным частям документа. Если структура памяти агента хранит заголовок документа и его тело, логично придать совпадениям в заголовке больший вес:

    При ранжировании ts_rank автоматически учтет, что лексема с меткой A важнее лексемы с меткой C. Для ускорения таких запросов на колонку search_vector вешается GIN-индекс, механика которого аналогична индексированию ключей в JSONB: он хранит отображение каждой лексемы на список ID строк (CTID), где она встречается.

    Векторный поиск внутри PostgreSQL: расширение pgvector

    Для реализации семантической памяти непосредственно в реляционной базе используется расширение pgvector. Оно добавляет тип данных vector(n), где — размерность эмбеддинга (например, 1536 для моделей OpenAI text-embedding-3-small или 384 для локальных Sentence Transformers).

    Метрики расстояния

    Степень смысловой близости между двумя векторами определяется математическими функциями расстояния. pgvector поддерживает три основных оператора:

  • Евклидово расстояние (L2): оператор <->. Вычисляет прямое расстояние между двумя точками в многомерном пространстве.
  • Внутреннее произведение (Inner Product): оператор <#>.
  • Косинусное расстояние (Cosine Distance): оператор <=>.
  • В контексте ИИ-систем и NLP чаще всего используется косинусное расстояние. Оно измеряет угол между двумя векторами, игнорируя их длину (магнитуду). Это важно, поскольку длина вектора может зависеть от объема исходного текста, а нас интересует именно направление (смысл).

    Формула косинусного расстояния:

    Где:

  • и — сравниваемые векторы эмбеддингов.
  • — размерность векторов.
  • и — компоненты векторов.
  • Выражение вычитается из единицы, так как косинусное сходство (Cosine Similarity) варьируется от 1 (идентичны) до -1 (противоположны), а базе данных для сортировки по возрастанию нужна метрика расстояния (0 — идентичны, 2 — противоположны).
  • SQL-запрос для поиска 5 наиболее близких по смыслу документов к вектору текущего запроса пользователя выглядит так:

    Индексирование векторов: ANN и HNSW

    Точный поиск ближайших соседей (K-Nearest Neighbors, KNN) требует вычисления расстояния от вектора запроса до каждого вектора в таблице. Это эквивалентно Sequential Scan и при миллионах записей делает систему непригодной для real-time ответов агента.

    Для решения этой проблемы используются алгоритмы приближенного поиска ближайших соседей (Approximate Nearest Neighbor, ANN). Они жертвуют абсолютной точностью (могут пропустить идеальное совпадение) ради кратного ускорения.

    В pgvector стандартом де-факто является индекс HNSW (Hierarchical Navigable Small World). Он строит многослойный граф, где нижний слой содержит все векторы, а верхние слои — только выбранные узлы-навигаторы. Поиск начинается с верхнего, разреженного слоя, быстро локализуя нужный регион пространства, и спускается вниз, уточняя результаты.

    Создание HNSW индекса для косинусного расстояния:

    Параметр m определяет максимальное количество двунаправленных связей для каждого узла графа (влияет на потребление памяти), а ef_construction — размер динамического списка кандидатов при построении индекса (влияет на время создания индекса и его итоговое качество).

    Гибридный поиск: объединение FTS и векторов

    Наличие FTS и векторного поиска в одной базе данных позволяет реализовать гибридный RAG (Retrieval-Augmented Generation). Существует два основных архитектурных паттерна их объединения.

    Паттерн 1: Пре-фильтрация (Metadata Filtering)

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

    Нюанс пре-фильтрации заключается во взаимодействии с ANN-индексами. Если реляционное условие (session_id = X) отсекает 99% таблицы, HNSW графу становится сложно перемещаться по оставшимся узлам — связи между ними могут быть разорваны фильтром. В таких случаях Планировщик запросов PostgreSQL может принять решение проигнорировать HNSW индекс и выполнить точный расчет расстояний (KNN) для отфильтрованного подмножества, что парадоксальным образом окажется быстрее.

    Паттерн 2: Reciprocal Rank Fusion (RRF)

    Когда необходимо найти документы по всему объему корпоративной базы, и FTS (поиск по ключевым словам), и векторный поиск (семантика) выполняются параллельно и независимо по всей таблице. Проблема заключается в том, что их метрики несопоставимы: ts_rank возвращает абстрактное положительное число, зависящее от частоты слов, а косинусное расстояние — значение от 0 до 2.

    Алгоритм Reciprocal Rank Fusion решает эту задачу, игнорируя абсолютные значения метрик (scores) и опираясь только на позицию документа в выдаче (rank).

    Формула RRF для конкретного документа:

    Где:

  • — позиция документа в результатах векторного поиска (1, 2, 3...).
  • — позиция документа в результатах полнотекстового поиска.
  • — константа сглаживания (обычно принимается равной 60).
  • Константа необходима для того, чтобы документы, занявшие первые места в одной из выдач, не получали подавляющего преимущества над документами, которые стабильно показывают хорошие (но не первые) результаты в обеих выдачах.

    Реализация RRF в PostgreSQL выполняется с помощью обобщенных табличных выражений (CTE) и полного внешнего соединения (FULL OUTER JOIN), так как документ может быть найден только одним из методов:

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

    Архитектурные компромиссы: pgvector против выделенных векторных БД

    Использование pgvector позволяет держать всю память агента (реляционные логи, JSONB-контекст, FTS-индексы и эмбеддинги) в единой системе. Это обеспечивает строгие гарантии ACID: если транзакция записи сообщения отменяется, эмбеддинг также не попадет в базу. Отсутствует проблема рассинхронизации данных между хранилищами.

    Однако при масштабировании мульти-агентной системы до сотен миллионов документов или при необходимости сложной распределенной фильтрации архитектура с единой БД становится узким местом. PostgreSQL масштабируется преимущественно вертикально (увеличение CPU и RAM), в то время как выделенные векторные базы данных (например, Qdrant) спроектированы для горизонтального шардирования и держат индексы HNSW полностью в оперативной памяти, обеспечивая субмиллисекундные задержки.

    Паттерн Dual-Database Sync (Transactional Outbox)

    При переходе к архитектуре с выделенной векторной базой данных возникает проблема распределенных транзакций. Если агент сохраняет лог в PostgreSQL, а затем отправляет эмбеддинг по API в Qdrant, сетевой сбой на втором шаге приведет к тому, что реляционная база будет содержать запись, недоступную для семантического поиска.

    Для надежной интеграции применяется паттерн Transactional Outbox (Транзакционное исходящее сообщение).

    Вместо прямой отправки данных в векторную БД, микросервис агента в рамках одной транзакции PostgreSQL выполняет два действия:

  • Записывает бизнес-данные (сообщение, документ) в основную таблицу.
  • Записывает событие «Создан документ X» в специальную таблицу outbox_events.
  • Поскольку обе вставки происходят в одной транзакции, они подчиняются правилу атомарности: либо сохранятся обе записи, либо ни одной.

    Далее отдельный фоновый процесс (Worker или инструмент Change Data Capture, такой как Debezium) непрерывно читает таблицу outbox_events. Обнаружив новое событие, воркер:

  • Вызывает API LLM для генерации эмбеддинга из payload.
  • Отправляет вектор и метаданные в Qdrant.
  • В случае успеха — помечает событие в outbox_events как обработанное (или удаляет его).
  • Если на этапе отправки в Qdrant происходит сбой, воркер просто повторит попытку позже (механизм Retry). Данные в PostgreSQL остаются консистентными, а векторное хранилище достигает состояния согласованности в конечном счете (Eventual Consistency). Эта архитектура развязывает тяжелые вычисления эмбеддингов и сетевые вызовы от быстрого цикла ответа ИИ-агента, сохраняя при этом надежность долгосрочной памяти.

    Внедрение гибридного поиска и правильное разделение ответственности между хранилищами формирует фундамент, на котором строятся сложные RAG-пайплайны, способные оперировать терабайтами контекста без потери точности фактов.