Фасетний пошук: реалізація фільтрів та агрегацій

Коли покупець обирає товар у каталозі, кожен клік по фільтру має миттєво показувати актуальну кількість результатів — інакше він губиться та йде. Ми реалізуємо фасетний пошук, який робить багаторівневу фільтрацію швидкою та зрозумілою для користувача. Наша команда бере на себе весь цикл: від вибору двигуна до впровадження та підтримки, забезпечуючи надійне рішення, яке масштабується разом із вашим бізнесом.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Фасетний пошук: реалізація фільтрів та агрегацій
Складний
~3-5 днів
Часті запитання

Користувач заходить до інтернет-магазину, обирає категорію «ноутбуки» і хоче відфільтрувати моделі з 16 ГБ RAM, ціною до $900–1.3kів і рейтингом від 4.5. Кожен клік по фільтру має показувати, скільки товарів відповідає умовам. Якщо лічильники не оновлюються — користувач губиться і йде з сайту. Фасетний пошук вирішує це завдання: він миттєво обчислює кількість результатів за кожним виміром (фасетом) з урахуванням усіх активних фільтрів. Ми впроваджуємо такі механізми для каталогів будь-якого розміру, від 1 000 до 500 000 товарів. Динамічні лічильники оновлюються миттєво при виборі фільтрів. Наша реалізація фільтрів підтримує всі типи фасетів. Економія на серверних ресурсах складає до 40% при переході з PostgreSQL на Elasticsearch для каталогів >100k товарів. Вартість реалізації фасетного пошуку починається від $1,500 за просту інтеграцію на PostgreSQL і до $8,000 за повноцінне рішення на Elasticsearch. Ми гарантуємо результат і надаємо сертифіковані рішення. Наша команда має 5+ років досвіду в розробці фасетного пошуку. Багаторівнева фільтрація з фасетним пошуком дозволяє користувачам швидко знаходити потрібні товари.

Як впровадити фасетний пошук?

  1. Визначити фасети та їх типи (категорія, ціна, бренд, рейтинг та ін.).
  2. Обрати двигун: PostgreSQL для малих каталогів, Elasticsearch/Typesense для великих.
  3. Налаштувати індексацію даних з урахуванням необхідних полів.
  4. Реалізувати API для пошуку з агрегаціями та пост-фільтрацією.
  5. Інтегрувати фронтенд-компоненти з синхронізацією URL.
  6. Провести SEO-настроювання: noindex, canonical, robots meta.
  7. Протестувати продуктивність та оптимізувати.

Чому агрегації в 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 проєктах. Досвід підтверджують десятки успішних запусків. Отримайте консультацію — оцінимо ваш каталог і запропонуємо оптимальне рішення. Замовте реалізацію фасетного пошуку вже сьогодні.

Наші компетенції:

Останні роботи

  • Розробка сайту компанії B2B ADVANCE
    Розробка сайту компанії B2B ADVANCE
    1542
  • Розробка веб-додатків для компанії FEEDME
    Розробка веб-додатків для компанії FEEDME
    1394
  • Розробка веб-сайту для компанії БЕЛФІНГРУП
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1081
  • Розробка інтернет магазину для компанії FURNORO
    Розробка інтернет магазину для компанії FURNORO
    1369
  • Розробка веб-додатків для компанії Enviok
    Розробка веб-додатків для компанії Enviok
    1142
  • Розробка веб-сайту для компанії ФІКСПЕР
    Розробка веб-сайту для компанії ФІКСПЕР
    1060