Реализация фасетного поиска для веб-приложения

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

Разработка и обслуживание любых видов сайтов:

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

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Услуги, которые мы предлагаем
Показано 1 из 1Все 2062 услуг
Реализация фасетного поиска для веб-приложения
Сложный
~3-5 дней
Часто задаваемые вопросы

Пользователь приходит в интернет-магазин, выбирает категорию «ноутбуки» и хочет отфильтровать модели с 16 ГБ RAM, ценой до $900–1.3k и рейтингом от 4.5. Каждый клик по фильтру должен показывать, сколько товаров соответствует условиям. Если счётчики не обновляются — пользователь теряется и уходит с сайта. Фасетный поиск решает эту задачу: он мгновенно вычисляет количество результатов по каждому измерению (фасету) с учётом всех активных фильтров. Мы внедряем такие механизмы для каталогов любого размера, от 1 000 до 500 000 товаров.

Почему агрегации в Elasticsearch быстрее SQL?

Elasticsearch хранит данные в инвертированном индексе и выполняет агрегации на уровне сегментов без полного сканирования. Для PostgreSQL с jsonb и GIN-индексами сложные многофасетные запросы с фильтрами по нескольким полям могут приводить к N+1 проблемам. Elasticsearch обрабатывает 10–50 фасетов за 10–50 мс на каталоге из 50 000 товаров. Typesense демонстрирует ещё более низкую задержку — до 5 мс — за счёт упрощённого API. Выбор движка напрямую влияет на опыт пользователя и стоимость инфраструктуры.

Как правильно организовать 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.

Движок Производительность (50k товаров) Сложность развёртывания Поддержка счётчиков
Elasticsearch 10–50 мс на агрегацию Средняя (кластер, настройка шардов) Есть через global + filter
Typesense 2–10 мс на запрос Низкая (один бинарник) Встроена (facet_by)
PostgreSQL + jsonb 50–200 мс Низкая (если уже есть) Требует ручной реализации

Elasticsearch и Typesense: сравниваем реализацию фасетов

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 с агрегациями и post-фильтрацией 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