Начало: когда реляционка ломает LLM
Два года назад мы запускали клиент для Ollama с амбициями: легковесный, быстрый, с поддержкой длинных сессий и streaming. Выбрали PostgreSQL — казалось логичным: ACID, индексы, знакомый ORM. Через полгода это решение аукнулось так, что пришлось переписывать половину бэкенда.
Проблема оказалась не в том, что PostgreSQL плох. Он отличный — для CRM, банков, трекеров задач. Но когда ты хранишь истории чатов с LLM, каждый диалог — это вложенный JSON: промт, ответ, параметры модели, метаданные, цепочки мыслей. Всё это разнородно, меняется от версии модели к версии. Попробуйте натянуть нормальную форму на то, что в каждом сообщении может быть разный набор полей — и вы поймете боль.
Я уже писал о том, как защитить локальные LLM от утечек — тогда мы ещё не знали, что узким местом станет БД. Но давайте по порядку.
PostgreSQL: первый удар по производительности
Основные грабли, на которые мы наступили:
- JSONB — не панацея. Индексы на вложенные поля работают, но
@>оператор для поиска в глубоких структурах тормозит при 100k+ записей. А у нас их миллионы. - Блокировки на вставку. Streaming ответа LLM означает частые UPDATEs того же сообщения (добавляются токены). Даже при row-level locking конфликты на высокой конкуренции (10+ одновременных сессий) приводили к дедлокам.
- Схема кричит о помощи. Каждая новая версия модели добавляла поля. Миграции с ALTER TABLE на 10M строк превращались в многочасовой ад.
Какой вывод? LLM-данные по своей природе — документы, а не таблицы. Реляционная модель добавляет оверлей, который не нужен.
Ошибка: Мы любили PostgreSQL за его надежность и пытались впихнуть документальную модель в реляционную. Не делайте так. Если ваши данные — это JSON с непредсказуемой структурой, берите документную БД с рождения.
MongoDB: почему не Couchbase, не DynamoDB?
Сравнение вариантов было быстрым. Couchbase сложен в настройке кластера. DynamoDB — привязка к AWS, мы за self-hosted. MongoDB 7.x (на момент миграции) давал то, что надо: гибкая схема, репликация, транзакции (да, они появились), агрегация для аналитики. И главное — встроенный TTL-индекс для автоочистки старых логов.
| Критерий | PostgreSQL | MongoDB |
|---|---|---|
| Гибкость схемы | Низкая (миграции) | Высокая (схема на лету) |
| Производительность streaming обновлений | Средняя (блокировки) | Высокая (upsert без блокировок) |
| Индексация вложенных полей | Ограничена (GIN) | Полноценная (compound + text) |
| TTL для устаревших данных | Нет нативных возможностей | Есть (TTL index) |
Миграция: как перетащить 5 миллионов документов без даунтайма
1 Подготовка: дуалируем запись
Мы не выключали старую БД. Включили dual-write: каждое изменение писалось и в PostgreSQL, и в MongoDB. Неделю мониторили ошибки, сверяли данные скриптом. Выявили 0.3% расхождений — в основном из-за таймингов streaming. Поправили логику повторных попыток.
2 Батчевый экспорт/импорт
# Пример скрипта миграции (упрощённо)
import psycopg2
from pymongo import MongoClient
from datetime import datetime
pg_conn = psycopg2.connect("dbname=ollama host=...")
mongo_client = MongoClient("mongodb://...")
cursor = pg_conn.cursor("select * from conversations where created_at > '2025-01-01'")
batch = []
for row in cursor:
doc = {
"_id": row[0],
"title": row[1],
"messages": row[2], # уже JSON
"model": row[3],
"created_at": row[4],
"updated_at": row[5]
}
batch.append(doc)
if len(batch) >= 1000:
mongo_client.conversations.insert_many(batch, ordered=False)
batch = []
if batch:
mongo_client.conversations.insert_many(batch)
Ключевое: ordered=False — вставка без остановки при дубликатах. Запускали в часы наименьшей нагрузки.
3 Переключение чтения
После проверки целостности перевели read-traffic на MongoDB. Write оставался dual ещё неделю — чтобы откатиться если что. Потом отключили PostgreSQL.
Совет: Не пытайтесь сделать миграцию за один день. Двойная запись на две недели — это не роскошь, это страховка. Я видел проекты, где откат с MongoDB на PostgreSQL после недели тестов стоил команде двух недель нервов.
Фронтенд: когда браузер задыхается от 10k токенов
Отдельная боль — сам клиент. Мы использовали React + Zustand + React Query. Когда модель генерирует длинный ответ (суммаризация документов на 50k токенов), браузер тупил: перерисовка стейта на каждый новый токен вешала UI на секунду. Решение — неожиданное.
Что НЕ работает: тупая отписка по таймеру
Первая мысль: обновлять интерфейс раз в 200 мс, а не на каждый чанк. Но это давало рывки — текст появлялся порциями, нарушая плавность. Пользователи жаловались.
Решение: виртуализация + streaming в canvas
Мы переписали компонент сообщения на @tanstack/react-virtual. Суть: рендерить только видимые сообщения, а потоковые токены — записывать напрямую в canvas через Web Worker. Звучит сложно? На деле — 150 строк кода.
// Упрощённая версия обработчика streaming с отрисовкой через requestAnimationFrame
let pendingTokens = [];
function streamHandler(chunk) {
pendingTokens.push(chunk);
if (pendingTokens.length === 1) {
requestAnimationFrame(flushTokens);
}
}
function flushTokens() {
const batch = pendingTokens.splice(0);
// обновляем store оптом, без перерисовки компонентов
useChatStore.setState(state => ({
messages: updateLastMessage(state.messages, batch.join(''))
}));
if (pendingTokens.length > 0) {
requestAnimationFrame(flushTokens);
}
}
Фокус в том, что React не видит каждый токен отдельно — он получает один батч за кадр. Это снизило количество ререндеров с 100 до 16 в секунду. UI перестал фризить.
Если вам кажется, что это overengineering — вспомните, что EdgeVec + локальная LLM работают в браузере через WebAssembly. Мы пошли чуть проще, но идея та же: не насиловать DOM.
Ошибки, которые мы совершили (и вы, скорее всего, тоже)
- Не настроили TTL index сразу. В MongoDB мы храним логи запросов к моделям для статистики. Без TTL база разрослась до 200 ГБ за месяц. Пришлось срочно писать скрипт очистки.
- Забыли про индексы на вложенные массивы. Поиск сообщений по содержимому требовал
$textиндекс на полеmessages.text. Сначала не поставили — запросы висели по 10 секунд. - Использовали find().toArray() для пагинации. Без limit и skip при 500k документах — OOM на ноде. Перешли на курсоры.
- Доверились дефолтному драйверу. pymongo без пула соединений — узкое место. Настроили maxPoolSize=50, и всё встало на место.
Больная мозоль: В одном из проектов коллеги решили не мигрировать, а докрутить PostgreSQL — повесили триггеры на каждое обновление streaming. В итоге latency подскочила до 500 мс. Не пытайтесь лечить симптомы, если болезнь в архитектуре. Если вам интересны альтернативы — посмотрите сравнение vLLM и llama.cpp, там похожая дилемма: старое vs новое.
Что изменилось после миграции?
Цифры говорят сами за себя:
- Время записи streaming снизилось с 300 мс до 15 мс в среднем.
- Объём хранимых данных уменьшился на 40% за счёт отказа от нормализации (раньше хранили части в отдельных таблицах).
- Фронтенд перестал “зависать” на длинных ответах — FPS держится 60 даже при 30k токенов в чате.
- Добавить новую метрику в лог сессии стало делом 5 минут — не надо создавать миграцию.
Конечно, MongoDB не серебряная пуля. Транзакции у неё не такие надёжные, как в PostgreSQL (хотя в 7.0 стали лучше). Для финансовых данных я бы всё равно выбрал реляционку. Но для LLM-приложений — документная модель побеждает.
Финальный прогноз (без воды)
Через два-три года мы увидим специализированные БД для LLM, которые объединят векторные индексы, гибкую схему и встроенную обработку streaming. Возможно, что-то вроде Pinecone + MongoDB в одном флаконе. Но пока — документные СУБД остаются лучшим выбором.
Не повторяйте наших ошибок: не бойтесь менять стек, если архитектура трещит по швам. И обязательно настройте мониторинг — я писал об этом в статье 5 ключевых метрик для стабильной работы self-hosted LLM. Потому что без метрик вы летите вслепую, а с MongoDB это чревато внезапным падением кластера.
Ну и напоследок — если вы всё ещё думаете, стоит ли переезжать с PostgreSQL на MongoDB для своего Ollama-клиента, ответьте на один вопрос: ваши данные — это таблицы или документы? Если второе — ответ очевиден.