Назад к блогу

Поиск по каталогу ~250K товаров: PostgreSQL vs Elasticsearch

Как я оптимизировал поиск по каталогу Tirebase (~250K товаров) — от 2 секунд на LIKE до 15 мс с полнотекстовым поиском, триграммами и Elasticsearch.

Поиск по каталогу ~250K товаров: PostgreSQL vs Elasticsearch

В Tirebase каталог вырос до ~250 тысяч SKU, и поиск по нему начал тормозить. Менеджер в торговой точке вводит «шина мишлен 205» — ответ через 2 с лишним секунды. Когда перед тобой стоит покупатель, это неприемлемо. Нужно было разобраться, почему всё так медленно, и довести время ответа до долей секунды.

Диагностика: почему LIKE — это приговор

Первым делом я посмотрел, как устроен текущий поиск:

SELECT * FROM products
WHERE name ILIKE '%шина мишлен 205%'
ORDER BY price ASC
LIMIT 20;

EXPLAIN ANALYZE показал то, чего я ожидал:

Seq Scan on products  (cost=0.00..45231.00 rows=112 width=284)
  Filter: (name ~~* '%шина мишлен 205%')
  Rows Removed by Filter: 248576
  Planning Time: 0.15 ms
  Execution Time: 2147.32 ms

Полный скан таблицы — четверть миллиона строк прочитаны от начала до конца. Никакой индекс не поможет для ILIKE '%...%' — PostgreSQL вынужден проверять каждую строку.

Шаг 1: полнотекстовый поиск в PostgreSQL

Первое, что я сделал — перешёл на tsvector и GIN-индекс. Для русского языка нужна конфигурация russian.

-- Добавляем колонку с поисковым вектором
ALTER TABLE products ADD COLUMN search_vector tsvector;

-- Заполняем
UPDATE products SET search_vector =
    setweight(to_tsvector('russian', coalesce(name, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(brand, '')), 'B') ||
    setweight(to_tsvector('russian', coalesce(article, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(description, '')), 'C');

-- GIN-индекс
CREATE INDEX idx_products_search ON products USING GIN (search_vector);

-- Триггер для автообновления
CREATE OR REPLACE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('russian', coalesce(NEW.name, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(NEW.brand, '')), 'B') ||
        setweight(to_tsvector('russian', coalesce(NEW.article, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(NEW.description, '')), 'C');
    RETURN NEW;
END
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_search_update
    BEFORE INSERT OR UPDATE ON products
    FOR EACH ROW EXECUTE FUNCTION products_search_trigger();

Веса (A, B, C) важны: совпадение в названии и артикуле ранжируется выше, чем в описании. Теперь запрос:

SELECT *, ts_rank(search_vector, query) AS rank
FROM products, plainto_tsquery('russian', 'шина мишлен 205') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Bitmap Heap Scan on products  (cost=52.00..312.45 rows=87 width=288)
  Recheck Cond: (search_vector @@ '''шин'' & ''мишлен'' & ''205'''::tsquery)
  ->  Bitmap Index Scan on idx_products_search  (cost=0.00..51.98 rows=87 width=0)
  Planning Time: 0.82 ms
  Execution Time: 12.4 ms

С 2147 мс до 12 мс. Индекс работает — GIN находит нужные строки без полного сканирования. Но это ещё не всё.

Шаг 2: поиск с опечатками (триграммы)

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

Решение — расширение pg_trgm:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- GiST-индекс для триграмм (можно GIN, но GiST лучше для similarity)
CREATE INDEX idx_products_name_trgm ON products USING GiST (name gist_trgm_ops);

Теперь можно искать по похожести:

-- Найти товары, похожие на "мишелин" (с опечаткой)
SELECT name, similarity(name, 'мишелин') AS sim
FROM products
WHERE name % 'мишелин'
ORDER BY sim DESC
LIMIT 10;

Настройка порога:

SET pg_trgm.similarity_threshold = 0.3;

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

# search/service.py — комбинированный поиск
async def search_products(query: str, db: AsyncSession) -> list[Product]:
    # Шаг 1: полнотекстовый поиск
    fts_query = func.plainto_tsquery("russian", query)
    stmt = (
        select(Product, func.ts_rank(Product.search_vector, fts_query).label("rank"))
        .where(Product.search_vector.op("@@")(fts_query))
        .order_by(text("rank DESC"))
        .limit(20)
    )
    result = await db.execute(stmt)
    products = result.all()

    if len(products) >= 5:
        return [p for p, _ in products]

    # Шаг 2: fallback на триграммы, если мало результатов
    stmt = (
        select(Product, func.similarity(Product.name, query).label("sim"))
        .where(func.similarity(Product.name, query) > 0.3)
        .order_by(text("sim DESC"))
        .limit(20)
    )
    result = await db.execute(stmt)
    return [p for p, _ in result.all()]

Шаг 3: фасетные фильтры

Помимо текстового поиска нужны фильтры: бренд, размер, сезонность, цена. Каждый фильтр должен показывать количество товаров — фасеты.

В PostgreSQL фасеты — это GROUP BY запросы. Проблема: если у вас 10 фильтров, это 10 дополнительных запросов. Я оптимизировал через один запрос с FILTER:

SELECT
    count(*) AS total,
    count(*) FILTER (WHERE season = 'winter') AS winter_count,
    count(*) FILTER (WHERE season = 'summer') AS summer_count,
    count(*) FILTER (WHERE season = 'allseason') AS allseason_count,
    jsonb_object_agg(brand, brand_count) AS brand_facets
FROM (
    SELECT brand, season, count(*) OVER (PARTITION BY brand) AS brand_count
    FROM products
    WHERE search_vector @@ plainto_tsquery('russian', 'шина 205/55')
) sub;

Это работает, но при росте каталога и десятках фильтров время ощутимо растёт. На наших ~250K строк было терпимо, но я видел, что при дальнейшем масштабировании это станет узким местом. Здесь я впервые задумался об Elasticsearch.

Когда PostgreSQL недостаточно: подключаем Elasticsearch

PostgreSQL справлялся до тех пор, пока не понадобились:

  • Автодополнение с подсветкой совпадений
  • Фасеты по 15+ параметрам за один запрос
  • Синонимы (R16 = 16 дюймов = 16”)
  • Weighted scoring с учётом популярности, наличия, рейтинга

Elasticsearch я добавил не вместо PostgreSQL, а рядом. PostgreSQL остаётся источником правды, Elasticsearch — поисковый индекс. Честно говоря, я до сих пор не уверен, что для нашего объёма данных Elasticsearch действительно необходим — возможно, PostgreSQL FTS с доработками закрыл бы все задачи. Но бизнес хотел автодополнение здесь и сейчас, и я решил не рисковать.

Архитектура синхронизации (используем elasticsearch-py):

# search/indexer.py — синхронизация PostgreSQL → Elasticsearch
from elasticsearch import AsyncElasticsearch

es = AsyncElasticsearch(["http://elasticsearch:9200"])

async def index_product(product: Product):
    doc = {
        "name": product.name,
        "brand": product.brand,
        "article": product.article,
        "price": float(product.price),
        "season": product.season,
        "in_stock": product.in_stock,
        "popularity": product.sales_count,
        "suggest": {
            "input": [product.name, product.brand, product.article],
            "weight": product.sales_count or 1,
        },
    }
    await es.index(index="products", id=product.id, document=doc)

async def full_reindex(db: AsyncSession):
    """Полная переиндексация — запускаем раз в сутки ночью."""
    result = await db.stream(select(Product))
    batch = []
    async for product in result.scalars():
        batch.append(product)
        if len(batch) >= 500:
            await bulk_index(batch)
            batch = []
    if batch:
        await bulk_index(batch)

Финальная архитектура

Пользователь → API Gateway
    ├── Автодополнение → Elasticsearch (suggest)
    ├── Поиск + фильтры → Elasticsearch (query + aggregations)
    └── Карточка товара → PostgreSQL (источник правды)

Синхронизация: PostgreSQL → (триггер/CDC) → Elasticsearch

Результаты

МетрикаILIKEPostgreSQL FTSElasticsearch
Время поиска2 147 мс12 мс8 мс
Автодополнениенетнет5 мс
Опечаткинеттриграммы ~25 мсfuzzy ~10 мс
Фасеты (15 фильтров)300 мс280 мс15 мс
Инфраструктура00+2 GB RAM (ES)

Мой совет

Начинайте с PostgreSQL. tsvector + pg_trgm закрывают большую часть потребностей в поиске. Добавляйте Elasticsearch только когда упрётесь в конкретное ограничение: фасеты, автодополнение, сложный scoring. Не тащите Elasticsearch «на всякий случай» — это лишняя инфраструктура, которую нужно мониторить, обновлять и кормить памятью.

В нашем случае PostgreSQL FTS работал полгода в продакшене, прежде чем бизнес потребовал автодополнение и сложные фасеты. Те полгода — время, которое я потратил бы на настройку Elasticsearch, если бы добавил его с самого начала. Правда, замеры производительности я делал не слишком систематично, так что точные цифры в таблице выше — это скорее порядок величин, а не результаты строгого бенчмарка.