Мгновенный поиск по 50 000+ товарам: устройство OpenSearch
Почему PostgreSQL ILIKE не справляется с большим каталогом, как устроен OpenSearch и сколько стоит держать его в продакшене.
Когда у магазина 200 товаров, поиск по WHERE name ILIKE '%query%' работает мгновенно. Когда товаров 50 000 — каждый запрос выполняется секунду. Когда 500 000 — поиск превращается в проблему. В этой статье разберу, как мы решаем поиск в больших каталогах с помощью OpenSearch.
Почему PostgreSQL не справляется с поиском
PostgreSQL — отличная реляционная БД. Но полнотекстовый поиск по большим объёмам не его задача:
ILIKEбез индекса: full table scan, O(n) на каждый запросtsvector(встроенный FTS): лучше, но без морфологии и нечёткого поиска- Нет фасетов из коробки: «найти все ноутбуки в категории X с RAM > 16 ГБ от бренда Lenovo» — это сложный SQL с GROUP BY
- Релевантность примитивная: только TF-IDF, без BM25 или семантики
При 50k+ записей и сложных фильтрах PostgreSQL выдаёт 800-1500 мс на запрос. Для UX это смерть — пользователь уходит.
Что такое OpenSearch
OpenSearch — это форк Elasticsearch, который Amazon выделил после изменения лицензии в 2021 году. Под капотом — Apache Lucene, индексирующая библиотека из Java-мира с 25-летней историей.
Принцип работы:
- Каждый документ (товар, объявление, статья) индексируется как набор полей
- Каждое поле проходит анализаторы: токенизация, нормализация регистра, морфология (для русского — стемминг и лемматизация)
- Индекс хранится в специальной структуре — inverted index — где ключ это токен, значение это список документов с этим токеном
- Поиск находит документы по токенам и ранжирует по релевантности (BM25 по умолчанию)
Результат: поиск по 50k+ записям с фильтрами — 10-50 мс. По 1М+ записей — 100-200 мс.
Реальный пример: Diamond Pharm
В моём проекте Diamond Pharm 1700+ товаров профессиональной косметики. На PostgreSQL поиск работал, но фильтры тормозили: «биоревитализация по гиалуроновой кислоте от 22мг/мл объёмом 2мл от бренда Aquashine» — это 8 фильтров, JOIN с таблицей атрибутов, скан большой выборки. 800-1200 мс.
Перенёс товары в OpenSearch. Тот же запрос — 25 мс. Без оптимизаций. Из коробки.
# Запрос к OpenSearch на Python
from opensearchpy import OpenSearch
client = OpenSearch(...)
result = client.search(
index="products",
body={
"query": {
"bool": {
"must": [{"match": {"name": "биоревитализация"}}],
"filter": [
{"term": {"category.slug": "biorevitalization"}},
{"term": {"brand": "Aquashine"}},
{"range": {"hyaluronic_concentration": {"gte": 22}}},
{"term": {"volume_ml": 2}},
]
}
},
"aggs": {
"by_brand": {"terms": {"field": "brand"}},
"price_range": {"stats": {"field": "price"}}
}
}
)
Один запрос возвращает и подходящие товары, и фасеты для боковой фильтрации.
Архитектура индексации
Главный вопрос — как держать OpenSearch синхронизированным с PostgreSQL. Три варианта:
1. Синхронная индексация при сохранении
# Django signal
@receiver(post_save, sender=Product)
def index_product(sender, instance, **kwargs):
es_client.index(index="products", id=instance.id, body=instance.to_es())
Плюс: всегда актуально. Минус: каждое сохранение — лишний сетевой запрос. При большом импорте (10k товаров за раз) — медленно.
2. Асинхронная очередь через Celery
@receiver(post_save, sender=Product)
def queue_indexing(sender, instance, **kwargs):
index_product_task.delay(instance.id)
@app.task
def index_product_task(product_id):
product = Product.objects.get(id=product_id)
es_client.index(...)
Плюс: не блокирует основной поток. Минус: задержка 1-5 секунд между сохранением и видимостью в поиске.
3. CDC через Debezium
Для серьёзного продакшена с большим объёмом изменений: Debezium слушает WAL PostgreSQL и автоматически индексирует все изменения в OpenSearch. Самый сложный, но и самый надёжный вариант.
В Diamond Pharm я остановился на варианте 2 (Celery) — задержка приемлемая, инфраструктура простая.
Морфология русского языка
Главная сложность поиска по-русски — морфология. «Купить» и «покупаю» — это разные строки, но один смысл. PostgreSQL FTS этого не понимает. OpenSearch с правильным анализатором — понимает.
{
"settings": {
"analysis": {
"analyzer": {
"ru_analyzer": {
"tokenizer": "standard",
"filter": ["lowercase", "russian_morphology", "english_morphology"]
}
}
}
}
}
Russian morphology plugin делает стемминг (купить → купи) и лемматизацию (купил → купить). После этого «купить ботокс» находит и «куплю ботокс», и «купите ботокс», и «купи ботокс».
Сколько это стоит
Реальные расходы для проекта 1700-50k товаров:
Вариант 1: AWS OpenSearch Service (managed)
- t3.small.search × 1 (single-AZ, для production-ready минимум 2)
- $35-45 в месяц
Вариант 2: Self-hosted на VPS
- 2 vCPU + 4 GB RAM VPS = $15-20/мес
- Time on maintenance: ~2 часа в месяц
Для каталога до 100k товаров я обычно беру self-hosted — экономия в 2-3 раза, минимальные требования к поддержке.
Стоит ли подключать OpenSearch с самого начала
Нет, если:
- Каталог < 5000 товаров
- Простой поиск по name + 1-2 фильтра
- Нет планов на скорое масштабирование
Да, если:
- 10k+ товаров
- Сложные фильтры с фасетами
- Морфология русского критична
- Аналитика по запросам нужна (что искали, что нашли)
Альтернативы OpenSearch
Не всем нужен Elasticsearch-class инструмент:
- Meilisearch — лёгкая альтернатива, проще в установке, отличный для каталогов до 10М документов
- Typesense — open-source, единый бинарник, очень быстрый
- Algolia — managed-сервис, отличный UX, дорогой при росте
- PostgreSQL pg_trgm + tsvector — для каталогов до 50k и простого поиска бывает достаточно
В Diamond Pharm я выбрал OpenSearch потому что планировался рост до 50k+, нужны были сложные фасеты и хотелось контроля над инфраструктурой. Для меньших каталогов Meilisearch часто более рациональный выбор.
Итог
OpenSearch — мощный инструмент для каталогов от 10k записей. Решает проблему медленного поиска и сложных фильтров за вечер интеграции. Стоит $20-50/мес инфры. Для серьёзного e-commerce, B2B-маркетплейса или контент-портала — почти всегда правильный выбор.
Не подключайте на ранних этапах: сначала проверьте, что pg_trgm + tsvector справляются. Когда упрётесь — переходите без сожалений.
Хотите такое же?

