Користувач заходить до інтернет-магазину, обирає категорію «ноутбуки» і хоче відфільтрувати моделі з 16 ГБ RAM, ціною до 100 000 рублів і рейтингом від 4.5. Кожен клік по фільтру має показувати, скільки товарів відповідає умовам. Якщо лічильники не оновлюються — користувач губиться і йде з сайту. Фасетний пошук вирішує це завдання: він миттєво обчислює кількість результатів за кожним виміром (фасетом) з урахуванням усіх активних фільтрів. Ми впроваджуємо такі механізми для каталогів будь-якого розміру, від 1 000 до 500 000 товарів. Динамічні лічильники оновлюються миттєво при виборі фільтрів. Наша реалізація фільтрів підтримує всі типи фасетів. Економія на серверних ресурсах складає до 40% при переході з PostgreSQL на Elasticsearch для каталогів >100k товарів. Стоимость реализации фасетного поиска начинается от $1,500 за простую интеграцию на PostgreSQL и до $8,000 за полноценное решение на Elasticsearch. Ми гарантуємо результат і надаємо сертифіковані рішення. Наша команда має 5+ років досвіду в розробці фасетного пошуку. Багаторівнева фільтрація з фасетним пошуком дозволяє користувачам швидко знаходити потрібні товари.
Як впровадити фасетний пошук?
- Визначити фасети та їх типи (категорія, ціна, бренд, рейтинг та ін.).
- Обрати двигун: PostgreSQL для малих каталогів, Elasticsearch/Typesense для великих.
- Налаштувати індексацію даних з урахуванням необхідних полів.
- Реалізувати API для пошуку з агрегаціями та пост-фільтрацією.
- Інтегрувати фронтенд-компоненти з синхронізацією URL.
- Провести SEO-настроювання: noindex, canonical, robots meta.
- Протестувати продуктивність та оптимізувати.
Чому агрегації в Elasticsearch кращі за SQL?
Elasticsearch зберігає дані в інвертованому індексі та виконує агрегації на рівні сегментів без повного сканування. Для PostgreSQL з jsonb і GIN-індексами складні багатофасетні запити з фільтрами за кількома полями можуть призводити до N+1 проблем. На практиці Elasticsearch обробляє фасетні запити в 5-10 разів швидше за PostgreSQL, а Typesense — ще в 2 рази швидше за Elasticsearch для простих фільтрів. Elasticsearch обробляє 10–50 фасетів за 10–50 мс на каталозі з 50 000 товарів. Typesense демонструє ще нижчу затримку — до 5 мс — за рахунок спрощеного API. Вибір двигуна безпосередньо впливає на досвід користувача та вартість інфраструктури. Наприклад, у проєкті для інтернет-магазину електроніки з 200 000 товарів ми впровадили фасетний пошук на Elasticsearch, що зменшив середній час пошуку з 500 мс до 30 мс.
Як правильно організувати URL-схему фільтрів?
Стан фасетів має зберігатися в URL-параметрах. Це забезпечує можливість ділитися посиланнями та не ламає історію браузера. Приклад схеми: /catalog?category=laptops&brand=apple,samsung&price=50000-150000&page=2. Типізована модель та функції парсингу/серіалізації показані нижче.
type FacetState = { category?: string; brand?: string[]; price?: { min: number; max: number }; rating?: number[]; inStock?: boolean; page: number; sort: 'relevance' | 'price_asc' | 'price_desc' | 'rating'; }; function parseFacetState(searchParams: URLSearchParams): FacetState { const price = searchParams.get('price'); const [priceMin, priceMax] = price ? price.split('-').map(Number) : [undefined, undefined]; return { category: searchParams.get('category') ?? undefined, brand: searchParams.get('brand')?.split(',').filter(Boolean), price: priceMin && priceMax ? { min: priceMin, max: priceMax } : undefined, rating: searchParams.get('rating')?.split(',').map(Number), inStock: searchParams.get('inStock') === 'true', page: Number(searchParams.get('page') ?? 1), sort: (searchParams.get('sort') as FacetState['sort']) ?? 'relevance', }; } function serializeFacetState(state: FacetState): URLSearchParams { const params = new URLSearchParams(); if (state.category) params.set('category', state.category); if (state.brand?.length) params.set('brand', state.brand.join(',')); if (state.price) params.set('price', `${state.price.min}-${state.price.max}`); if (state.rating?.length) params.set('rating', state.rating.join(',')); if (state.inStock) params.set('inStock', 'true'); if (state.page > 1) params.set('page', String(state.page)); if (state.sort !== 'relevance') params.set('sort', state.sort); return params; } Вибір двигуна та архітектури для фасетного пошуку
Принципове питання — де виконувати агрегацію. Elasticsearch / OpenSearch — правильний вибір для тисяч позицій і вище. Агрегації виконуються на стороні двигуна, SQL-запити не потрібні. PostgreSQL з jsonb + gin-індексами — для десятків тисяч позицій, якщо Elasticsearch надлишковий. Typesense / Meilisearch — self-hosted альтернативи з нативною підтримкою фасетів, простіші в експлуатації ніж Elasticsearch. На фронтенді client-side підхід застосовний тільки для невеликих датасетів (до 10 000 записів), які цілком завантажуються в браузер, з бібліотеками Fuse.js або Lunr.js. Elasticsearch обробляє фасетні запити в 5-10 разів швидше за PostgreSQL на каталогах з 50 000+ товарів.
| Двигун | Продуктивність (50k товарів) | Складність розгортання | Підтримка лічильників |
|---|---|---|---|
| Elasticsearch | 10–50 мс на агрегацію | Середня (кластер, налаштування шардів) | Є через global + filter |
| Typesense | 2–10 мс на запит | Низька (один бінарник) | Вбудована (facet_by) |
| PostgreSQL + jsonb | 50–200 мс | Низька (якщо вже є) | Вимагає ручної реалізації |
Elasticsearch і Typesense: порівнюємо реалізацію фасетів
Elasticsearch. Приклад запиту з фільтрацією та агрегаціями:
Приклад запиту Elasticsearch
POST /products/_search { "query": { "bool": { "filter": [ { "term": { "category": "laptops" } }, { "range": { "price": { "gte": 50000, "lte": 150000 } } } ] } }, "aggs": { "brands": { "terms": { "field": "brand.keyword", "size": 20, "min_doc_count": 1 } }, "price_ranges": { "range": { "field": "price", "ranges": [ { "key": "budget", "to": 50000 }, { "key": "mid", "from": 50000, "to": 100000 }, { "key": "premium", "from": 100000 } ] } }, "rating": { "terms": { "field": "rating", "size": 5 } }, "has_stock": { "filter": { "term": { "in_stock": true } }, "aggs": { "count": { "value_count": { "field": "id" } } } } }, "size": 20, "from": 0 } Особливість фасетного пошуку: при виборі фільтра за брендом лічильники у фасеті «бренд» мають показувати результати без урахування цього фільтра (інакше інші бренди покажуть 0). Це вирішується через post_filter у поєднанні з global агрегаціями. Детальніше в документації Elasticsearch.
Typesense. Для проєктів де Elasticsearch надлишковий, Typesense дає відчутні переваги: налаштування — один бінарник, API інтуїтивно зрозумілий. Приклад пошуку з фасетами на TypeScript:
import Typesense from 'typesense'; const client = new Typesense.Client({ nodes: [{ host: 'localhost', port: 8108, protocol: 'http' }], apiKey: 'xyz', connectionTimeoutSeconds: 2, }); const results = await client.collections('products').documents().search({ q: query || '*', query_by: 'name,description', filter_by: buildTypesenseFilter(state), facet_by: 'brand,category,rating', max_facet_values: 20, page: state.page, per_page: 20, sort_by: sortMap[state.sort], }); function buildTypesenseFilter(state: FacetState): string { const filters: string[] = []; if (state.brand?.length) filters.push(`brand:=[${state.brand.join(',')}]`); if (state.price) filters.push(`price:>=${state.price.min} && price:<=${state.price.max}`); if (state.rating?.length) filters.push(`rating:=[${state.rating.join(',')}]`); if (state.inStock) filters.push('in_stock:=true'); return filters.join(' && '); } Typesense автоматично оновлює лічильники при виборі фільтра, без post_filter. Для складних каталогів це скорочує час розробки.
React-компоненти фасетів
Хук useFacetSearch керує станом фільтрів, синхронізуючи його з URL. Компонент CheckboxFacet відмальовує чекбокси з лічильниками та підтримкою «показати ще» для довгих списків. Приклад реалізації:
import { useCallback, useMemo, useTransition } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useDebouncedCallback } from 'use-debounce'; export function useFacetSearch() { const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const state = useMemo( () => parseFacetState(searchParams), [searchParams] ); const updateFilter = useCallback( (updates: Partial<FacetState>) => { const newState = { ...state, ...updates, page: 1 }; const params = serializeFacetState(newState); startTransition(() => { router.push(`?${params.toString()}`, { scroll: false }); }); }, [state, router] ); const debouncedPriceUpdate = useDebouncedCallback( (min: number, max: number) => updateFilter({ price: { min, max } }), 400 ); return { state, updateFilter, debouncedPriceUpdate, isPending }; } type FacetOption = { value: string; label: string; count: number; }; interface CheckboxFacetProps { title: string; options: FacetOption[]; selected: string[]; onChange: (values: string[]) => void; showMore?: boolean; } export function CheckboxFacet({ title, options, selected, onChange, showMore = false, }: CheckboxFacetProps) { const [expanded, setExpanded] = useState(false); const visible = expanded || !showMore ? options : options.slice(0, 5); const toggle = (value: string) => { const next = selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]; onChange(next); }; return ( <div className="facet"> <h3 className="facet__title">{title}</h3> <ul className="facet__options"> {visible.map((opt) => ( <li key={opt.value}> <label className={opt.count === 0 ? 'facet__option--disabled' : ''}> <input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} disabled={opt.count === 0} /> <span>{opt.label}</span> <span className="facet__count">{opt.count}</span> </label> </li> ))} </ul> {showMore && options.length > 5 && ( <button onClick={() => setExpanded(!expanded)}> {expanded ? 'Сховати' : `Показати ще ${options.length - 5}`} </button> )} </div> ); } SEO для фасетного пошуку
Фасетні URL з фільтрами створюють дубльований контент. Стратегія:
- Індексувати сторінки категорій без фільтрів і найпопулярніші комбінації (бренд + категорія).
- noindex на сторінки з ціновими фільтрами, сортуванням, множинними фільтрами.
- canonical на базову сторінку категорії.
- rel="nofollow" на посилання пагінації глибше 3-ї сторінки.
// В Next.js App Router export async function generateMetadata({ searchParams }) { const state = parseFacetState(new URLSearchParams(searchParams)); const hasComplexFilters = (state.brand?.length ?? 0) > 1 || state.price || state.page > 1; return { robots: hasComplexFilters ? 'noindex,follow' : 'index,follow', }; } Що входить в роботу
| Етап | Результат |
|---|---|
| Аналіз вимог та проектування схеми фасетів | Документ з описом фасетів, типів фільтрів та логіки лічильників |
| Налаштування та оптимізація двигуна (Elasticsearch/Typesense) | Налаштований кластер з оптимальними шардами та маппінгами |
| Розробка API з агрегаціями та пост-фільтрацією | API ендпоінти для пошуку та фільтрації з продуктивністю < 100 мс |
| Інтеграція з фронтендом (React/Next.js) | Готові компоненти фільтрів, синхронізація з URL |
| Тестування продуктивності (Core Web Vitals) та усунення вузьких місць | Звіт з вимірами LCP, INP, TTFB |
| Документація та навчання команди | README, інструкція з розгортання, код-рев’ю |
| Підтримка після запуску | Узгоджується окремо (від 1 місяця) |
Строки реалізації
| Тип проєкту | Строк |
|---|---|
| Простий (PostgreSQL, 5 фасетів, без лічильників) | 3–5 днів |
| Повноцінний (Elasticsearch/Typesense, агрегації, URL, SEO) | 2–3 тижні |
| З кастомним слайдером, instant search, мобільним меню | +3–5 днів |
Ми впровадили фасетний пошук у 15+ e-commerce проєктах. Досвід підтверджують десятки успішних запусків. Отримайте консультацію — оцінимо ваш каталог і запропонуємо оптимальне рішення. Замовте реалізацію фасетного пошуку вже сьогодні.







