В 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
Результаты
| Метрика | ILIKE | PostgreSQL FTS | Elasticsearch |
|---|---|---|---|
| Время поиска | 2 147 мс | 12 мс | 8 мс |
| Автодополнение | нет | нет | 5 мс |
| Опечатки | нет | триграммы ~25 мс | fuzzy ~10 мс |
| Фасеты (15 фильтров) | 300 мс | 280 мс | 15 мс |
| Инфраструктура | 0 | 0 | +2 GB RAM (ES) |
Мой совет
Начинайте с PostgreSQL. tsvector + pg_trgm закрывают большую часть потребностей в поиске. Добавляйте Elasticsearch только когда упрётесь в конкретное ограничение: фасеты, автодополнение, сложный scoring. Не тащите Elasticsearch «на всякий случай» — это лишняя инфраструктура, которую нужно мониторить, обновлять и кормить памятью.
В нашем случае PostgreSQL FTS работал полгода в продакшене, прежде чем бизнес потребовал автодополнение и сложные фасеты. Те полгода — время, которое я потратил бы на настройку Elasticsearch, если бы добавил его с самого начала. Правда, замеры производительности я делал не слишком систематично, так что точные цифры в таблице выше — это скорее порядок величин, а не результаты строгого бенчмарка.