AACsearch
SDKРецепты

Фасетный поиск

Боковая панель с множественным выбором фильтров (бренд, категория, диапазон цен) на основе фасетных подсчётов AACsearch. Фильтры комбинируются через И; подсчёты обновляются реактивно.

Интерфейс фасетного поиска позволяет пользователям сужать результаты, нажимая на чекбоксы, ползунки и чипсы. AACsearch возвращает подсчёты по каждому значению фасета вместе с результатами — вы привязываете их к UI.

Что вы создаёте

Страницу поиска с:

  • Боковой панелью с чекбоксами брендов, чекбоксами категорий и ползунком диапазона цен.
  • Сеткой результатов, которая обновляется при переключении фильтров.
  • Фасетными подсчётами, которые отражают «сколько результатов я бы увидел, если бы добавил этот фильтр».
  • Состоянием в URL, чтобы фильтры можно было расшарить и они переживали перезагрузку страницы.

Требования к схеме индекса

Чтобы поле было доступно для фасетного поиска, пометьте его facet: true в схеме индекса:

await admin.createIndex({
  slug: "products",
  displayName: "Products",
  fields: [
    { name: "title", type: "string" },
    { name: "brand", type: "string", facet: true },
    { name: "categories", type: "string[]", facet: true },
    { name: "price", type: "float", facet: true },
    { name: "in_stock", type: "bool", facet: true },
  ],
  defaultSortingField: "price",
});

Серверная часть: построение поискового запроса из параметров URL

// app/search/page.tsx — Server Component
import { SearchClient } from "@aacsearch/client";

const client = new SearchClient({
  baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
  apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
  indexSlug: "products",
});

type SearchParams = {
  q?: string;
  brand?: string | string[];
  category?: string | string[];
  priceMin?: string;
  priceMax?: string;
  page?: string;
};

function buildFilter(params: SearchParams): string {
  const clauses: string[] = ["in_stock:=true"];

  const brands = Array.isArray(params.brand)
    ? params.brand
    : params.brand
      ? [params.brand]
      : [];
  if (brands.length) clauses.push(`brand:[${brands.join(",")}]`);

  const cats = Array.isArray(params.category)
    ? params.category
    : params.category
      ? [params.category]
      : [];
  if (cats.length) clauses.push(`categories:[${cats.join(",")}]`);

  if (params.priceMin) clauses.push(`price:>=${Number(params.priceMin)}`);
  if (params.priceMax) clauses.push(`price:<=${Number(params.priceMax)}`);

  return clauses.join(" && ");
}

export default async function SearchPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const result = await client.search({
    q: searchParams.q ?? "*",
    queryBy: "title,brand",
    filterBy: buildFilter(searchParams),
    facetBy: "brand,categories,in_stock",
    page: Number(searchParams.page ?? 1),
    perPage: 24,
  });

  return (
    <div className="search-page">
      <FacetSidebar facets={result.facetCounts} active={searchParams} />
      <ResultGrid hits={result.hits} found={result.found} />
    </div>
  );
}

Компонент боковой панели фасетов

"use client";
import { useRouter, useSearchParams } from "next/navigation";

type FacetCounts = Array<{
  field: string;
  counts: Array<{ value: string; count: number }>;
}>;

export function FacetSidebar({ facets }: { facets: FacetCounts }) {
  const router = useRouter();
  const params = useSearchParams();

  function toggle(field: string, value: string) {
    const next = new URLSearchParams(params);
    const current = next.getAll(field);
    if (current.includes(value)) {
      next.delete(field);
      current.filter((v) => v !== value).forEach((v) => next.append(field, v));
    } else {
      next.append(field, value);
    }
    next.delete("page"); // сброс пагинации при изменении фильтра
    router.push(`?${next}`);
  }

  const brand = facets.find((f) => f.field === "brand");
  const cats = facets.find((f) => f.field === "categories");

  return (
    <aside className="facet-sidebar">
      <section>
        <h3>Brand</h3>
        {brand?.counts.map((c) => (
          <label key={c.value}>
            <input
              type="checkbox"
              checked={params.getAll("brand").includes(c.value)}
              onChange={() => toggle("brand", c.value)}
            />
            {c.value} <span className="count">({c.count})</span>
          </label>
        ))}
      </section>
      <section>
        <h3>Category</h3>
        {cats?.counts.map((c) => (
          <label key={c.value}>
            <input
              type="checkbox"
              checked={params.getAll("category").includes(c.value)}
              onChange={() => toggle("category", c.value)}
            />
            {c.value} <span className="count">({c.count})</span>
          </label>
        ))}
      </section>
    </aside>
  );
}

Краткий обзор синтаксиса фильтров

ПотребностьВыражение фильтра
Равенство одному значениюbrand:=Nike
ИЛИ по нескольким значениямbrand:[Nike,Adidas] (использует инвертированный индекс — быстро)
Числовой диапазонprice:>=10 && price:<=50
Булево значениеin_stock:=true
Отрицаниеcategories:!=Discontinued
КомбинированиеИспользуйте && для И, || для ИЛИ (только одиночные литеральные значения — см. фильтрация-сортировка-пагинация)

Реактивные подсчёты

Когда пользователь добавляет фильтр brand, фасетный подсчёт для самого brand не уменьшается (иначе фильтр скрыл бы собственные опции). Подсчёты для categories сужаются, потому что они отражают набор результатов после применения фильтра.

Это поведение AACsearch по умолчанию. Чтобы переопределить (например, «показывать все опции независимо от фильтра»), используйте multi-search с одним запросом для результатов и вторым нефильтрованным запросом для фасетных подсчё тов.

Паттерн состояния в URL

Параметры URL — единственный источник истины. Серверный компонент читает их, FacetSidebar записывает их. Никакого useState — всё управляется через URL. Это:

  • Переживает перезагрузку.
  • Делает отфильтрованные представления доступными для расшаривания.
  • Корректно работает с кнопками браузера вперёд/назад.
  • Устраняет рассинхронизацию клиента и сервера.

Производительность: ограничение фасетных подсчётов

По умолчанию AACsearch возвращает до 100 уникальных значений на одно фасетное поле. Для полей с очень высокой кардинальностью (например, тег tag товара с 50 000 уникальных значений), ограничьте явно:

client.search({
  q: "*",
  facetBy: "brand,categories",
  maxFacetValues: 30, // топ-30 по количеству, остальные агрегируются как "Other"
});

При более чем ~10 фасетных полях на запрос задержка заметно возрастает. Если вам нужно много фильтров, отрисовывайте основные поля при первой отрисовке и лениво подгружайте остальные через multi-search, когда пользователь их раскрывает.

Связанные страницы

On this page