Розробка сортування товарів для інтернет-магазину
Сортування визначає, що користувач бачить першим. Дефолтна сортування за датою додавання — не стратегія. Правильна сортування за замовчуванням збільшує конверсію: товари з високим рейтингом, добрими запасами та підходящою ціною виходять вперед. Кожен варіант сортування — запит з іншим ORDER BY, кожен вимагає правильного індексу.
Стандартний набір варіантів
| Варіант | SQL | Коментар |
|---|---|---|
| Популярність | ORDER BY sales_count DESC |
Потрібен окремий лічильник |
| Рейтинг | ORDER BY rating DESC, reviews_count DESC |
Подвійна сортування: рейтинг + вага |
| Ціна: по зростанню | ORDER BY price ASC |
Базовий |
| Ціна: по спаданню | ORDER BY price DESC |
Базовий |
| Новинки | ORDER BY created_at DESC |
За датою додавання |
| Акції | ORDER BY discount_percent DESC |
Першi найбільш вигідні |
| Релевантність | За score поисковика | Тільки в режимі пошуку |
Дефолтна сортування — як правило, «Популярність» або кастомний «Рейтинг магазину» (ручна сортування merchandise-менеджером).
Зважений рейтинг
Наївна сортування за середнім рейтингом некоректна: товар з одним відгуком на 5 звізд окажется вище товара з 200 відгуками на 4.8. Використовуємо Bayesian average або формулу Wilson score:
-- Bayesian average: (C * m + sum_ratings) / (C + reviews_count)
-- C = prior weight (зазвичай середня кількість відгуків по каталогу)
-- m = prior mean (очікуваний рейтинг без даних, зазвичай 3.0–3.5)
UPDATE products SET
bayesian_rating = (50 * 3.5 + rating_sum) / (50 + reviews_count)
WHERE id = :id;
Це вичисляємое поле оновлюється при кожному новому відгуку. Індекс по bayesian_rating для швидкої сортування.
Лічильник продаж та популярності
sales_count — кумулятивний лічильник всіх продаж. Проблема: старий популярний товар завжди буде вище нового, який зараз активно купуються.
Рішення — time-decayed popularity score:
-- Оновлюється щодня через cron
UPDATE products SET
popularity_score = (
SELECT SUM(quantity * EXP(-0.1 * EXTRACT(DAY FROM NOW() - o.created_at)))
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE oi.product_id = products.id
AND o.created_at >= NOW() - INTERVAL '90 days'
)
Коефіцієнт 0.1 настоюється: більше — сильніше обесцінюються старі продажи, менше — довше «пам'ятає» історію. Для швидкозмінного асортименту (мода, сезонні товари) — більше. Для стабільних категорій (інструменти, побутова техніка) — менше.
Ручна сортування (merchandising)
Менеджер магазину хоче управляти тим, що бачить користувач на початку категорії: просувати новинки, спонсоровані товари, залежавший товар. Для цього потрібен sort_order — ручне числове поле.
Інтерфейс: drag-and-drop список товарів в адміністративному розділі категорії. Технічно — зберігаємо масив product_id у упорядкованому вигляді або sort_order: integer на кожному товарі.
Гібридна сортування: перші N позицій — ручні, решта — за алгоритмом.
ORDER BY
CASE WHEN sort_order IS NOT NULL THEN 0 ELSE 1 END,
sort_order ASC NULLS LAST,
popularity_score DESC
Сортування з Elasticsearch
При використанні Elasticsearch сортування задається в параметрі sort:
{
"sort": [
{ "popularity_score": { "order": "desc" } },
{ "bayesian_rating": { "order": "desc" } },
{ "_score": { "order": "desc" } }
]
}
Для ручної сортування: поле pinned_position з null для неприкріплених. У ES є спеціальний тип запиту pinned — він піднімає конкретні ID в початок результатів без порушення релевантності остальних.
Персоналізована сортування
Продвинутий рівень — показувати кожному користувачу різний порядок на основі його історії. Користувач часто купує Apple — для нього Apple-товари піднімаються вище. Користувач дивився товари в певній ціновій категорії — враховуємо це.
Реалізується через користувальницькі boost-фактори в Elasticsearch:
{
"query": {
"function_score": {
"query": { "term": { "category_id": 14 } },
"functions": [
{
"filter": { "term": { "brand": "apple" } },
"weight": 2.0 // персональний boost для цього користувача
}
]
}
}
}
Boost-фактори вичисляються offline (батч-процес на основі історії) та кешуються в Redis за user_id.
UI компонент сортування
Стандартний select-дропдаун з варіантами. На мобайле — bottom sheet. Поточний варіант сортування відображається в URL (?sort=price_asc) та синхронізується зі станом компонента.
При змінені сортування — запит до API без перезагрузки сторінки, скролл вверх до першого товару. Skeleton-плейсхолдери поки список оновлюється.
Індекси для продуктивності
-- Сортування за ціною
CREATE INDEX ON products (category_id, price ASC) WHERE status = 'active';
CREATE INDEX ON products (category_id, price DESC) WHERE status = 'active';
-- Сортування за датою
CREATE INDEX ON products (category_id, created_at DESC) WHERE status = 'active';
-- Сортування за рейтингом
CREATE INDEX ON products (category_id, bayesian_rating DESC) WHERE status = 'active';
-- Ручна сортування + популярність (гібрид)
CREATE INDEX ON products (category_id, sort_order ASC NULLS LAST, popularity_score DESC);
Кожен додатковий варіант сортування — потенційний окремий індекс. При 8–10 варіантах сортування це суттєво впливає на розмір індексів та швидкість INSERT/UPDATE. Правильне рішення: використовувати ES для складних сортувань, залишити у PostgreSQL тільки простий ORDER BY.
Терміни
- Базові сортування (ціна, дата, рейтинг, вибір в select): 2–4 робочих дні
- З зваженим рейтингом та time-decayed popularity: 1 тиждень
- Ручна merchandising-сортування з drag-and-drop інтерфейсом: +1 тиждень
- Персоналізація на основі історії користувача: 2–3 тижні (вимагає історії поведінки та офлайн-розрахунків)







