Реалізація фасетного пошуку для веб-програм
Фасетний пошук — це інтерфейс фільтрації де користувач послідовно звужує вибірку за декількома вимірами (фасетами): категорія, цінний діапазон, виробник, рейтинг, атрибути. Кожен вибраний фільтр оновлює лічильники в інших фасетах, показуючи скільки результатів залишиться при подальшому уточненні. Саме ця динаміка лічильників відрізняє фасетний пошук від звичайної фільтрації.
Архітектура: де обчислювати фасети
Принципове питання — де відбувається агрегація:
Elasticsearch / OpenSearch — правильний вибір для тисяч позицій і більше. Агрегації виконуються на стороні рушія, SQL-запити не потрібні.
PostgreSQL з jsonb + gin-індексами — для десятків тисяч позицій, якщо Elasticsearch надмірний. Повільніше, але дозволяє обійтися без додаткової інфраструктури.
Typesense / Meilisearch — self-hosted альтернативи з нативною підтримкою фасетів, простіше у експлуатації ніж Elasticsearch.
На фронтенді (client-side) — тільки для невеликих датасетів (до 10 000 записів) які цілком завантажуються в браузер. Використовуйте Fuse.js або Lunr.js бібліотеки.
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 aggregation:
{
"query": { "match_all": {} },
"post_filter": {
"term": { "brand.keyword": "Apple" }
},
"aggs": {
"all_brands": {
"global": {},
"aggs": {
"brands": {
"terms": { "field": "brand.keyword", "size": 20 }
}
}
},
"filtered_price": {
"filter": { "term": { "brand.keyword": "Apple" } },
"aggs": {
"price_stats": { "stats": { "field": "price" } }
}
}
}
}
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;
}
React: компоненти фасетного пошуку
Хук для управління станом з debounce:
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>
);
}
Ціновий слайдер
import * as Slider from '@radix-ui/react-slider';
interface PriceRangeProps {
min: number;
max: number;
value: [number, number];
onChange: (value: [number, number]) => void;
}
export function PriceRange({ min, max, value, onChange }: PriceRangeProps) {
return (
<div className="price-range">
<div className="price-range__inputs">
<input
type="number"
value={value[0]}
min={min}
max={value[1]}
onChange={(e) => onChange([Number(e.target.value), value[1]])}
/>
<span>—</span>
<input
type="number"
value={value[1]}
min={value[0]}
max={max}
onChange={(e) => onChange([value[0], Number(e.target.value)])}
/>
</div>
<Slider.Root
min={min}
max={max}
value={value}
onValueChange={onChange as (v: number[]) => void}
step={1000}
>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb />
<Slider.Thumb />
</Slider.Root>
</div>
);
}
Typesense: альтернатива Elasticsearch
Для проектів де Elasticsearch надмірний:
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(' && ');
}
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',
};
}
Часові рамки
- Простий фасетний пошук (PostgreSQL, до 5 фасетів, без лічильників): 3–5 днів
- Повнофункціональний з Elasticsearch/Typesense (агрегації, лічильники, post_filter, URL-стан, SEO): 2–3 тижні
- З власним цінним слайдером, instant search, мобільним висувним меню: ще 3–5 днів







