← назад к разделу

«Нам нужен поиск» — и первая мысль об Elasticsearch. Но это второй кластер, отдельный пайплайн синхронизации и новые точки отказа. У PostgreSQL полнотекстовый поиск встроен, и он закрывает большинство задач. Разберём, что когда брать.

один товар, один запрос — два пути PostgreSQL: индекс рядомдвижок: своя копиязаписали «диван раскладной»документа тут ещё нет та же транзакция правит GIN-индексGIN: диван → 3 документапоиск — ещё одно условие того же SQLsearch_vector @@ queryAND price < 5000новый товар в выдаче сразубэкап и восстановление — с базой копию доставляет пайплайнкопия: 2 документа из 3лаг около секунды: CDC или события копия догнала: 3 из 3BM25 и фасеты одним ответомв ответе id, карточки из PostgreSQLкластер, снапшоты, мониторинг — свои

Разница не в том, кто ищет лучше, а в том, где лежит поисковый индекс. В PostgreSQL он лежит рядом с данными и обновляется той же транзакцией, поэтому поиск — просто ещё одно условие запроса, а новая запись видна сразу. У отдельного движка индекс построен по копии документов, и копию надо доставлять: отсюда лаг, пайплайн и отдельная эксплуатация — а взамен BM25, фасеты и горизонтальное масштабирование.

Обязательно

Как PostgreSQL ищет по тексту

Раньше поиск по тексту делали через LIKE '%запрос%'. Проблема: база вынуждена просматривать каждую строку целиком — на больших таблицах это медленно, и индексы здесь не помогают.

PostgreSQL решает это иначе. Специальный тип tsvector хранит текст в разобранном виде — список корневых форм слов с их весами и позициями. Например, строка «красный диван угловой» превращается в словарный набор со стеммингом: поиск по «диванов» найдёт «диван».

Про русский язык здесь нужна честная оговорка. Встроенная настройка russian, которая стоит во всех примерах ниже, — это стеммер Snowball: он режет окончания по правилам и с «диван — дивана — диванов» справляется прекрасно. Но словарей он не знает, а значит, не сводит слово к начальной форме по-настоящему: «шёл» и «идти» для него разные слова, «лучше» и «хороший» — тоже. Чтобы поиск понимал словоформы как следует, к PostgreSQL подключают словари ispell или hunspell и собирают из них свою настройку. Это делается и работает, но это уже отдельная работа, а не «включилось само».

Объявляют это один раз в схеме — вычисляемой колонкой и индексом по ней:

-- так это выглядит в схеме (в песочнице курса колонка и индекс уже созданы)
ALTER TABLE products ADD COLUMN search tsvector
    GENERATED ALWAYS AS (
        to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(description, ''))
    ) STORED;

CREATE INDEX products_search_gin ON products USING GIN (search);

А дальше ищут обычным запросом — этот можно запустить прямо здесь:

живой пример

SELECT p.title,
       round(ts_rank(p.search, q)::numeric, 4) AS rank
FROM products p, websearch_to_tsquery('russian', 'наушники') AS q
WHERE p.search @@ q
ORDER BY rank DESC, p.title
LIMIT 10;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

GIN-индекс хранит обратный словарь: слово → список строк, где оно встречается. Вместо просмотра всей таблицы база идёт сразу к нужным строкам: на миллионах строк это десятки миллисекунд.

Оговорка: так быстро выходит, пока слово редкое. По запросу «угловой диван» список совпадений короткий. А если слово встречается в трети товаров, база достанет сотни тысяч строк, посчитает для каждой релевантность (индекса по ней не существует) и отсортирует. Здесь PostgreSQL и начинает отставать от движков, у которых на такой случай есть свои приёмы отбора.

Что такое Elasticsearch

Elasticsearch — отдельный сервис, специализированный на поиске. В основе тот же обратный индекс, но возможности шире: алгоритм ранжирования BM25 (учитывает частоту слова в документе и редкость слова в коллекции), фасеты (подсчёт результатов по категориям прямо в поисковом запросе), развитая работа с синонимами, автодополнение, многоязычные анализаторы.

За это платят: Elasticsearch — отдельный кластер со своей эксплуатацией, и данные в нём всегда вторичны относительно PostgreSQL — нужен пайплайн синхронизации.

И ещё один вопрос, который лучше решить до выбора, а не после. В 2021 году Elastic сменила лицензию: Elasticsearch перестал быть полностью открытым и распространяется под SSPL и Elastic License (в 2024-м к ним добавили AGPL). Тогда же от последней открытой версии отпочковался форк — OpenSearch, который развивает Amazon; по устройству и языку запросов это очень близкий родственник, и почти всё написанное ниже верно для обоих. Отсюда и разные названия в наших статьях: где речь про поисковый движок вообще, мы пишем «Elasticsearch», а в советах по выбору стека чаще встретится OpenSearch — как вариант без лицензионных вопросов.

По каким критериям выбирать

Качество ранжирования

PostgreSQL ранжирует по ts_rank — простое число на основе частоты слов и весов полей. Для каталога с фильтрами обычно достаточно.

Elasticsearch использует BM25 и позволяет подмешивать бизнес-сигналы: понижать старые товары, поднимать популярные, применять разные веса по контексту запроса. Если релевантность выдачи — это продуктовая метрика, которую регулярно улучшают, PostgreSQL не хватит.

ts_rank частота слова вес поля A-D нормировка BM25 частота слова длина документа редкость в корпусе

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

Фасеты

Фасеты — это счётчики рядом с фильтрами: «Ноутбуки (234)», «Смартфоны (87)». В PostgreSQL их считают отдельными запросами. В Elasticsearch агрегации возвращают фасеты вместе с результатами, одним запросом.

Опечатки и автодополнение

Расширение pg_trgm закрывает опечатки, префиксный поиск и поиск по подстроке — через триграммы, без полного сканирования таблицы. Одного CREATE EXTENSION для этого мало: под триграммы нужен свой индекс — CREATE INDEX ... USING GIN (title gin_trgm_ops) или его вариант на GiST. Тот GIN, что построен по tsvector, здесь не помогает: это два разных индекса по двум разным разборам одного и того же текста, и в таблице они живут рядом, каждый за свои деньги. Словарь синонимов в PostgreSQL подключается, но управлять им неудобно.

Elasticsearch даёт это готовым: нечёткий поиск (fuzzy), suggesters для автодополнения, граф синонимов, переиндексацию с другим анализатором без простоя — через переключение алиаса.

Объём данных и нагрузка

GIN-индекс уверенно держит миллионы документов и десятки поисковых запросов в секунду — для большинства продуктов этого достаточно.

Elasticsearch горизонтально масштабируется шардами: десятки миллионов документов, сотни запросов в секунду, требования к задержке выдачи менее 50 мс.

Актуальность данных в поиске

В PostgreSQL поисковый индекс обновляется в той же транзакции: создали товар — сразу видно в поиске. Это сильный аргумент в пользу PostgreSQL там, где важна согласованность.

У этой медали есть и обратная сторона. Обновлять GIN-индекс дорого: одно слово попадает в десятки мест словаря, и на потоке записи это заметно. Чтобы вставка не тормозила, PostgreSQL по умолчанию складывает свежие записи в отложенный список (fastupdate) и разбирает его пачками. Список не бесплатен: пока он не разобран, поиск читает и его тоже, и на активной записи время ответа гуляет. Ручки для этого есть — fastupdate и gin_pending_list_limit, — но знать про них надо заранее.

В Elasticsearch между записью и видимостью в поиске — пайплайн и интервал обновления (refresh interval), обычно секунда. Для каталога это нормально. Для сценария «создал и сразу ищу» — источник проблем.

На маленькой программе это видно целиком: индекс рядом с данными пополняется записью, копия в движке — доставкой.

живой пример

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class SearchFreshness {
    static final Map<String, List<String>> nextToData = new TreeMap<>();
    static final Map<String, List<String>> inEngine = new TreeMap<>();
    static final Deque<String[]> pipeline = new ArrayDeque<>();

    static void save(String id, String title) {
        for (String word : title.split(" ")) {
            nextToData.computeIfAbsent(word, key -> new ArrayList<>()).add(id);
        }
        pipeline.add(new String[]{id, title});
    }

    static void deliver() {
        while (!pipeline.isEmpty()) {
            String[] document = pipeline.poll();
            for (String word : document[1].split(" ")) {
                inEngine.computeIfAbsent(word, key -> new ArrayList<>()).add(document[0]);
            }
        }
    }

    static void report(String moment) {
        System.out.println(moment + ": рядом с данными " + nextToData.get("диван")
                + ", в движке " + inEngine.get("диван")
                + ", ждёт доставки " + pipeline.size());
    }

    public static void main(String[] args) {
        save("prod-01", "диван угловой серый");
        save("prod-02", "диван прямой синий");
        deliver();
        report("после доставки  ");
        save("prod-03", "диван раскладной бежевый");
        report("сразу после записи");
        deliver();
        report("пайплайн отработал");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"strings"
)

type document struct{ id, title string }

var nextToData = map[string][]string{}
var inEngine = map[string][]string{}
var pipeline []document

func save(id, title string) {
	for _, word := range strings.Fields(title) {
		nextToData[word] = append(nextToData[word], id)
	}
	pipeline = append(pipeline, document{id, title})
}

func deliver() {
	for len(pipeline) > 0 {
		doc := pipeline[0]
		pipeline = pipeline[1:]
		for _, word := range strings.Fields(doc.title) {
			inEngine[word] = append(inEngine[word], doc.id)
		}
	}
}

func report(moment string) {
	fmt.Printf("%s: рядом с данными [%s], в движке [%s], ждёт доставки %d\n",
		moment, strings.Join(nextToData["диван"], ", "), strings.Join(inEngine["диван"], ", "), len(pipeline))
}

func main() {
	save("prod-01", "диван угловой серый")
	save("prod-02", "диван прямой синий")
	deliver()
	report("после доставки  ")
	save("prod-03", "диван раскладной бежевый")
	report("сразу после записи")
	deliver()
	report("пайплайн отработал")
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const nextToData = new Map(), inEngine = new Map();
const pipeline = [];

function save(id, title) {
  for (const word of title.split(' ')) nextToData.set(word, [...(nextToData.get(word) ?? []), id]);
  pipeline.push({ id, title });
}

function deliver() {
  while (pipeline.length > 0) {
    const document = pipeline.shift();
    for (const word of document.title.split(' ')) inEngine.set(word, [...(inEngine.get(word) ?? []), document.id]);
  }
}

function report(moment) {
  console.log(`${moment}: рядом с данными [${(nextToData.get('диван') ?? []).join(', ')}], в движке [${(inEngine.get('диван') ?? []).join(', ')}], ждёт доставки ${pipeline.length}`);
}

save('prod-01', 'диван угловой серый');
save('prod-02', 'диван прямой синий');
deliver();
report('после доставки  ');
save('prod-03', 'диван раскладной бежевый');
report('сразу после записи');
deliver();
report('пайплайн отработал');
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from collections import deque

next_to_data: dict[str, list[str]] = {}
in_engine: dict[str, list[str]] = {}
pipeline: deque[tuple[str, str]] = deque()


def save(doc_id: str, title: str) -> None:
    for word in title.split(" "):
        next_to_data.setdefault(word, []).append(doc_id)
    pipeline.append((doc_id, title))


def deliver() -> None:
    while pipeline:
        doc_id, title = pipeline.popleft()
        for word in title.split(" "):
            in_engine.setdefault(word, []).append(doc_id)


def report(moment: str) -> None:
    print(f"{moment}: рядом с данными [{', '.join(next_to_data.get('диван', []))}], "
          f"в движке [{', '.join(in_engine.get('диван', []))}], ждёт доставки {len(pipeline)}")


save("prod-01", "диван угловой серый")
save("prod-02", "диван прямой синий")
deliver()
report("после доставки  ")
save("prod-03", "диван раскладной бежевый")
report("сразу после записи")
deliver()
report("пайплайн отработал")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Средняя строка вывода — и есть лаг: товар уже в базе, а в движке его ещё нет.

Комбинирование с обычными фильтрами

PostgreSQL: поиск — просто ещё один фильтр в SQL.

живой пример

SELECT p.title, p.price
FROM products p, websearch_to_tsquery('russian', 'наушники') AS q
WHERE p.search @@ q
  AND p.price < 6000
  AND p.status = 'PUBLISHED'
  AND p.currency = 'RUB'
LIMIT 10;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

JOIN-ы, вложенные запросы, оконные функции — всё работает привычно.

В Elasticsearch весь запрос описывается в Query DSL — отдельном JSON-формате. Зато есть highlight (подсветка найденных слов), nested-документы и «похожие документы».

Сложность эксплуатации

PostgreSQL: поисковый индекс бэкапится вместе с базой, новых компонентов ноль.

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

Фасеты в PostgreSQL: сколько это стоит

«Считают отдельными запросами» — правда, но полезно знать, во что это обходится, потому что фасеты и есть главная причина, по которой берут поисковый движок.

Фасет — это «сколько товаров в каждой категории, в каждом ценовом диапазоне, у каждого бренда» для текущего поискового запроса. В PostgreSQL их считают тремя способами.

Первый: отдельный запрос на каждый фасет. Понятно и дорого: пять фасетов — пять раз повторить поиск и сгруппировать. Второй: один запрос с GROUPING SETS — база делает один проход и считает несколько разрезов сразу:

живой пример

SELECT p.status, p.currency, count(*)
FROM products p, websearch_to_tsquery('russian', 'наушники') AS q
WHERE p.search @@ q
GROUP BY GROUPING SETS ((p.status), (p.currency), ());
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Это заметно дешевле, чем пять запросов, но всё равно означает, что база прочитала все подошедшие строки — а в поиске это может быть сотня тысяч. Третий способ, который и применяют на больших каталогах: предагрегаты. Счётчики по категориям и брендам считают заранее (материализованным представлением или отдельной таблицей) и пересчитывают по расписанию; тогда фасеты приблизительные, зато мгновенные.

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

Глубокие страницы

Вторая практическая разница всплывает на второй неделе. В PostgreSQL постраничный вывод по релевантности — это ORDER BY rank LIMIT … OFFSET …, и на глубоких страницах он проседает: чтобы отдать строки с 10 000 по 10 020, база вычислит релевантность для всех подошедших строк и отсортирует их целиком. Обходят это так же, как в обычной пагинации: сужают выборку фильтрами, а «бесконечную прокрутку» делают от последней показанной строки по уникальному ключу, а не по номеру страницы.

В Elasticsearch у глубоких страниц есть жёсткий предел: по умолчанию сумма смещения и размера страницы ограничена десятью тысячами (max_result_window), и запрос за пределом просто отклоняется. Дальше работает search_after — продолжение от последнего результата, то же самое, что «от последней показанной строки». Для выгрузки всего результата есть отдельный механизм точки во времени.

Практический вывод одинаков для обеих систем: интерфейс, который позволяет пролистать поиск до тысячной страницы, — ошибка проектирования, а не задача на оптимизацию.

Переиндексация: она нужна и здесь

Про Elasticsearch обычно знают, что смена настроек поля требует переиндексации через новый индекс и переключение псевдонима. В PostgreSQL ровно та же проблема, просто выглядит иначе.

Поменяли настройку словаря (подключили hunspell, добавили синонимы, сменили конфигурацию с russian на свою) — значит, все значения tsvector посчитаны по старым правилам и их надо пересчитать. Для вычисляемой колонки это ALTER TABLE … ALTER COLUMN … SET EXPRESSION (или удаление и повторное добавление колонки) — то есть перезапись всей таблицы под тяжёлой блокировкой, плюс перестройка GIN-индекса.

Безопасный порядок тот же, что с псевдонимом в поисковом движке: добавить вторую колонку с новым выражением, заполнить её (для вычисляемой колонки это происходит при добавлении, поэтому на большой таблице делают через обычную колонку и порционное заполнение), построить рядом второй GIN-индекс, переключить запросы на новую колонку, удалить старую. Тот же приём расширения и сжатия схемы, что в статье про миграции.

Чек-лист: когда Elasticsearch оправдан

Балл за каждое «да»:

  1. Релевантность выдачи — продуктовая метрика, её будут регулярно улучшать.
  2. Нужны фасеты с подсчётами при каждом поиске.
  3. Синонимы, автодополнение, исправление опечаток — требования сейчас, не «потом».
  4. Больше десяти миллионов документов или больше пятидесяти поисковых запросов в секунду.
  5. Допустим лаг индексации в секунды.
  6. Пайплайн CDC или событий уже есть или запланирован.
  7. У поиска будет выделенный владелец.

0–2 балла — PostgreSQL FTS + pg_trgm: GENERATED-колонка и GIN-индекс.

3–4 балла — начинайте с PostgreSQL, но держите поисковую логику в одном месте, чтобы было удобно вынести позже.

5+ баллов — Elasticsearch, и сразу с нормальным пайплайном синхронизации: CDC или доменные события, переиндексация как штатная операция.

Что делать при 3–4 баллах

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

Шаг первый — интерфейс. Поиск в коде описывается одним портом: «найди товары по запросу с фильтрами, верни идентификаторы и релевантность». Никаких tsvector и никаких запросов движка в прикладном коде.

public interface ProductSearch {
    SearchPage search(SearchQuery query);   // текст, фильтры, страница, сортировка
}
type ProductSearch interface {
	Search(ctx context.Context, query SearchQuery) (SearchPage, error) // текст, фильтры, страница, сортировка
}
// search.ts
export interface ProductSearch {
  search(query: SearchQuery): Promise<SearchPage>;   // текст, фильтры, страница, сортировка
}
class ProductSearch(Protocol):
    def search(self, query: SearchQuery) -> SearchPage: ...   # текст, фильтры, страница, сортировка

Шаг второй — первая реализация на PostgreSQL. Она уже есть в базе, её не надо эксплуатировать, и для большинства каталогов её достаточно.

Шаг третий — метрики, которые ответят на вопрос вместо спора: время ответа поиска на девяносто пятом и девяносто девятом процентиле, доля запросов, где пользователь не нашёл ничего, число запросов в секунду. Эти же метрики потом покажут, что вторая реализация действительно лучше.

Шаг четвёртый (если метрики упёрлись) — вторая реализация того же порта на Elasticsearch, включаемая флагом. Тогда переключение и возврат — это настройка, а не релиз, а сравнение честное: один и тот же код, две реализации.

Этот порядок закрывает и типичную ошибку «перенесли поиск в движок, а фильтры остались в базе»: за портом видно, что фильтры и поиск должны жить в одном месте, иначе получается пересечение двух наборов идентификаторов в коде приложения — самый медленный из возможных вариантов.

Типичные ошибки

Elasticsearch ради LIKE. Поднять кластер Elasticsearch ради поиска по названию в административной панели на сто тысяч строк. pg_trgm с GIN-индексом решает это без дополнительных компонентов.

ILIKE '%запрос%' без индекса. Противоположная ошибка: последовательное сканирование на каждый поиск и вывод «PostgreSQL не умеет искать». Умеет — нужен GIN-индекс по tsvector или триграммам.

Запись в Elasticsearch прямо из обработчика команды. При первом сбое поиск разъезжается с базой. Синхронизация — только через пайплайн: CDC или события.

Elasticsearch как источник правды. Документы живут только в Elasticsearch, PostgreSQL «для транзакций». При смене структуры индекса или потере кластера данные не из чего восстановить: Elasticsearch — производная, которую пересоздают из PostgreSQL.

Игнорировать лаг индексации. Лаг — свойство архитектуры, его нужно декларировать в API-контракте и объяснять в интерфейсе, а не скрывать.

Когда используют оба вместе

Зрелый крупный каталог: PostgreSQL — источник правды и точные фильтры (цена, наличие, категория), Elasticsearch — полнотекстовый поиск, ранжирование и фасеты. Поиск возвращает идентификаторы, карточки подгружаются из PostgreSQL.

Дополнительно: при первом чтении можно пропустить

Глубже: цифры: размер индекса, реплики и цена терабайтарасширенное

Критерии выше качественные. Решение «выносить ли поиск в Elasticsearch» принимают ещё и по объёму, и вот оценки, с которых начинают, пока не измерили своё.

Поисковый индекс не меньше данных, а обычно больше. Elasticsearch хранит исходный документ (_source) плюс инвертированный индекс по каждому текстовому полю плюс колоночные структуры для сортировки и агрегаций (doc_values). Для каталога товаров с описаниями индекс весит от одного до трёх объёмов исходного JSON; реплика удваивает. Гигабайт описаний в PostgreSQL превращается в два-шесть гигабайт в кластере поиска. Измеряют по GET _cat/indices?v, колонки store.size и pri.store.size, и сравнивают с pg_total_relation_size таблицы.

В PostgreSQL полнотекстовый индекс GIN по tsvector занимает примерно от трети до половины объёма индексируемого текста, а индекс триграмм для LIKE '%…%' бывает и больше самих данных. То есть по месту на диске PostgreSQL выигрывает, а по памяти проигрывает: Elasticsearch держит горячий индекс в памяти файлового кэша, и правило размера кластера это половина оперативной памяти узла под кучу JVM, половина под кэш, и на узел не больше 20–30 индексных шардов на гигабайт кучи.

Снимок Elasticsearch инкрементальный: первый весит как индекс, следующие только новые сегменты, и после слияния сегментов «новых» может оказаться много. У PostgreSQL резервная копия одна на всё, и поисковый индекс в неё уже входит.

Цена терабайта в месяц по порядку величины: объектное хранилище около 20–25 долларов, сетевой SSD-диск виртуальной машины 80–100, а терабайт горячего индекса Elasticsearch это не диск, а два-три узла с 64 ГБ памяти каждый, то есть сотни долларов, и вдвое больше с репликой. Именно поэтому в статье про эксплуатацию Elasticsearch появляются уровни hot/warm/cold: старые индексы уезжают на дешёвые узлы и в снимки в S3.

Практический порог: пока текст, по которому ищут, умещается в десятки гигабайт, а запросов поиска меньше сотни в секунду, PostgreSQL с GIN справляется на уже существующем железе, и цифры выше ему в плюс. Дальше выносят, и первую оценку кластера делают по формуле «объём индекса с репликой делить на половину памяти узла».

Коротко

  • PostgreSQL FTS: tsvector + GIN-индекс + pg_trgm — встроено, транзакционно, без новых компонентов.
  • Индекс PostgreSQL обновляется в той же транзакции — новая запись видна сразу; у отдельного движка между записью и выдачей лаг около секунды. Плата за это — дорогая вставка в GIN и отложенный список, из-за которого на потоке записи поиск подтормаживает.
  • GIN-индекс держит миллионы строк и десятки запросов в секунду; на частом слове релевантность всё равно считается по сотням тысяч строк, и по тем же строкам считаются фасеты — их делают отдельными запросами, через GROUPING SETS или предагрегатами.
  • Elasticsearch нужен, когда релевантность — продуктовая метрика, нужны фасеты и автодополнение, данных десятки миллионов или нагрузка сотни запросов в секунду.
  • Elasticsearch — всегда производная от PostgreSQL: синхронизация только через CDC или события, запись прямо из обработчика команды — антипаттерн.
  • ILIKE '%...%' без индекса — частая причина медленного поиска: нужен GIN по tsvector или триграммам.
  • Индекс Elasticsearch весит один-три объёма исходных данных и удваивается репликой, GIN в PostgreSQL треть-половину текста; терабайт горячего индекса это узлы с памятью, а не диск, поэтому старое уезжает в холодные уровни.
  • Глубокие страницы плохи в обеих системах: в PostgreSQL проседает OFFSET, в Elasticsearch стоит жёсткий предел max_result_window — листают от последней показанной строки.
  • Смена словаря в PostgreSQL требует пересчёта всех векторов, то есть той же переиндексации через вторую колонку и индекс, что переключение псевдонима в движке.
  • При спорной оценке выбор откладывают: поиск за портом, первая реализация на PostgreSQL, метрики времени ответа и доли пустых результатов, вторая реализация под флагом.

Что почитать дальше