«Нужен поиск — ставим Elasticsearch» — рефлекс, который стоит второго stateful-кластера и пайплайна синхронизации. У PostgreSQL есть полнотекстовый поиск (tsvector/tsquery), триграммы (pg_trgm) и этого хватает большему числу проектов, чем принято думать. Но у PG-поиска есть честный потолок — и упереться в него посреди роста хуже, чем спланировать переход.
Что умеет сам PostgreSQL
Чтобы развилка была честной, сначала — возможности встроенного:
ALTER TABLE product ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX product_search_idx ON product USING GIN (search_vector);
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM product, websearch_to_tsquery('russian', 'красный диван угловой') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Морфология (стемминг русского из коробки), веса полей, ранжирование ts_rank, поисковый синтаксис websearch_to_tsquery. Плюс pg_trgm — опечатки и поиск по подстроке с GIN-индексом вместо мёртвого ILIKE '%...%'. Всё это — внутри транзакции, без синхронизации, без второго кластера.
Восемь критериев
1. Качество ранжирования
PG хватает, если результат — «найти все подходящие и отсортировать по дате/цене/весу поля». ts_rank примитивен, но для каталога с фильтрами этого достаточно.
ES, если релевантность — продукт: BM25, boosting по свежести и популярности, function score, подмешивание бизнес-сигналов. Тюнинг выдачи — ежедневная работа, а не разовая настройка.
2. Фасеты и агрегации по выдаче
PG: фасетов нет или они считаются отдельными запросами по фильтрам — терпимо при простых каталогах.
ES: фасеты по категориям/брендам/цене с подсчётом в один запрос — родной жанр aggregations (разбор).
3. Опечатки, синонимы, автодополнение
PG: pg_trgm закрывает опечатки и префиксный поиск; словарь синонимов подключается, но управлять им неудобно.
ES: fuzzy, suggesters, synonym graph, переиндексация со сменой анализатора — поисковая лингвистика как управляемая система.
4. Объём и нагрузка
PG: миллионы документов и десятки поисковых запросов в секунду GIN-индекс держит уверенно.
ES: десятки миллионов документов, сотни RPS, требования к латентности выдачи — горизонтальное масштабирование шардами.
5. Свежесть индекса
PG: индекс обновляется в той же транзакции — найденное всегда согласовано с данными. Это сильный, часто недооцениваемый аргумент.
ES: между записью в PG и видимостью в поиске — пайплайн и refresh interval, секунды. Для каталога — норма; для «создал и сразу ищу» — источник багов и костылей.
6. Сложность запросов к поиску
PG: поиск — один из фильтров в обычном SQL (WHERE search_vector @@ query AND price < 1000 AND store_id = 5) — и он комбинируется с JOIN-ами бесплатно.
ES: весь запрос живёт в Query DSL; зато multi-field, nested, highlight, «more like this».
7. Эксплуатация
PG: ноль новых компонентов. Индекс бэкапится вместе с базой.
ES: кластер, шарды, ILM, snapshots, мониторинг (operations) + пайплайн синхронизации (CDC/события) со своим мониторингом.
8. Команда и горизонт
PG: поиск — вспомогательная функция продукта, выделенного владельца не будет.
ES: поиск — ядро продукта (маркетплейс, контент-платформа), и у него будет хозяин, который тюнит выдачу.
Чек-лист «возьми балл»
Балл Elasticsearch за каждое «да»:
- Релевантность выдачи — продуктовая метрика, её будут тюнить.
- Нужны фасеты с подсчётами в каждой выдаче.
- Синонимы/автодополнение/исправление опечаток — требования, а не «потом».
- Документов больше ~10 млн или поисковых запросов больше ~50 RPS.
- Допустим лаг индексации в секунды.
- Kafka/CDC-пайплайн уже есть или планируется.
- У поиска будет владелец.
0–2 — PG FTS + pg_trgm, и не забудьте GENERATED-колонку с GIN. 3–4 — начинайте с PG, проектируйте модель так, чтобы поиск было легко вынести (поисковые поля и логика индексации — в одном месте). 5+ — Elasticsearch, и сразу с честным пайплайном (Spring Data ES + CDC).
Типичные ошибки
- ES ради
LIKE. Кластер Elasticsearch ради поиска по названию в админке на 100 тысяч строк.pg_trgmрешает это одним индексом. ILIKE '%query%'по миллионам строк без индекса. Обратная ошибка: seq scan на каждый поиск, «PG не умеет искать». Умеет — GIN поtsvectorили триграммам.- Dual write в ES из Handler-а. Поиск разъезжается с базой при первом сбое; синхронизация — только пайплайном (CDC или события), с переиндексацией как штатной операцией.
- ES как источник правды. Документы живут только в ES, PG «для транзакций». При смене mapping или потере кластера данные не из чего пересобрать. ES — производная, пересоздаваемая из PG.
- Игнорировать согласованность. «Создал товар — в поиске нет» без объяснения в UX и контракте API. Лаг — свойство архитектуры, его декларируют, а не скрывают.
Когда оба — нормально
Зрелое состояние крупного каталога: PG — источник правды и точные фильтры, ES — полнотекст, релевантность и фасеты, между ними пайплайн. Поиск из ES возвращает id, карточки добираются из PG — каждый делает то, что умеет лучше.
Что почитать дальше
- Elasticsearch Fundamentals — inverted index и анализаторы: за что вы платите кластером.
- Query DSL и relevance — BM25 и фасеты, ради которых ES берут.
- PostgreSQL или ClickHouse — параллельная развилка для аналитической нагрузки.
- Распределённые паттерны — синхронизация двух хранилищ без dual write.