Configuring Elasticsearch Faceted Aggregation for 1C-Bitrix
Filtering a 50,000-product catalog through CIBlockElement::GetList with grouping by properties takes minutes on MySQL. Elasticsearch solves the same problem in tens of milliseconds via aggregations API. But the standard Bitrix search module (search) doesn't support facets out of the box. Custom integration is needed.
How Facets Work in Elasticsearch
Aggregations — a query that simultaneously returns search results and field statistics: count of documents for each filter value. One Elasticsearch request replaces N MySQL requests to count each facet.
Example: notebooks catalog. One query returns:
- 240 products matching current filters
- By brand: ASUS (45), Dell (38), HP (31)...
- By RAM: 8 GB (89), 16 GB (104), 32 GB (47)
- By diagonal: 15.6" (130), 14" (65)...
These are facets.
Mapping for Faceted Fields
For faceted aggregation, fields must be either keyword (exact value) or integer/float for numeric ranges. Fields of type text don't aggregate (or aggregate by tokens, which is meaningless for facets).
Mapping on index creation:
curl -X PUT http://localhost:9200/bitrix_catalog_s1 \
-H "Content-Type: application/json" \
-d '{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "russian",
"fields": {
"keyword": {"type": "keyword"}
}
},
"brand": {"type": "keyword"},
"price": {"type": "float"},
"category_id": {"type": "integer"},
"properties": {
"type": "nested",
"properties": {
"code": {"type": "keyword"},
"value": {"type": "keyword"},
"value_num": {"type": "float"}
}
}
}
}
}'
Product properties are stored as nested objects — allows correct filtering by combinations of one property value.
Query with Aggregations from PHP
Class for working with Elasticsearch via official elasticsearch/elasticsearch client:
use Elasticsearch\ClientBuilder;
class CatalogElasticSearch
{
private $client;
private $index = 'bitrix_catalog_s1';
public function __construct()
{
$this->client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
}
public function getFacets(array $filters = [], string $query = ''): array
{
$must = [];
if ($query) {
$must[] = ['match' => ['title' => $query]];
}
// Apply selected filters
foreach ($filters as $code => $values) {
$must[] = [
'nested' => [
'path' => 'properties',
'query' => [
'bool' => [
'must' => [
['term' => ['properties.code' => $code]],
['terms' => ['properties.value' => (array)$values]]
]
]
]
]
];
}
$params = [
'index' => $this->index,
'body' => [
'query' => ['bool' => ['must' => $must]],
'aggs' => [
'brands' => [
'terms' => ['field' => 'brand', 'size' => 50]
],
'price_range' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 10000],
['from' => 10000, 'to' => 30000],
['from' => 30000, 'to' => 60000],
['from' => 60000]
]
]
],
'properties_facets' => [
'nested' => ['path' => 'properties'],
'aggs' => [
'prop_codes' => [
'terms' => ['field' => 'properties.code', 'size' => 20],
'aggs' => [
'prop_values' => [
'terms' => ['field' => 'properties.value', 'size' => 100]
]
]
]
]
]
],
'size' => 24,
'from' => 0
]
];
return $this->client->search($params);
}
}
Indexing Bitrix Products
Collect data for indexing via CIBlockElement::GetList and send to Elasticsearch in batches via Bulk API:
function indexCatalogToElastic(int $iblockId): void
{
$client = ClientBuilder::create()->setHosts(['localhost:9200'])->build();
$batchSize = 200;
$offset = 0;
do {
$res = CIBlockElement::GetList(
[],
['IBLOCK_ID' => $iblockId, 'ACTIVE' => 'Y'],
false,
['nTopCount' => $batchSize, 'nPageSize' => $batchSize, 'iNumPage' => ($offset / $batchSize) + 1],
['ID', 'NAME', 'DETAIL_TEXT', 'PROPERTY_BRAND', 'PROPERTY_*']
);
$body = [];
$count = 0;
while ($el = $res->GetNextElement()) {
$fields = $el->GetFields();
$props = $el->GetProperties();
$properties = [];
foreach ($props as $code => $prop) {
if (!empty($prop['VALUE'])) {
$properties[] = [
'code' => $code,
'value' => is_array($prop['VALUE']) ? implode(', ', $prop['VALUE']) : $prop['VALUE']
];
}
}
$body[] = ['index' => ['_index' => 'bitrix_catalog_s1', '_id' => $fields['ID']]];
$body[] = [
'title' => $fields['NAME'],
'brand' => $props['BRAND']['VALUE'] ?? '',
'properties' => $properties
];
$count++;
}
if (!empty($body)) {
$client->bulk(['body' => $body]);
}
$offset += $batchSize;
} while ($count === $batchSize);
}
Post-filter for Independent Facets
Standard problem: when selecting filter "Brand: ASUS", in brand aggregation all brands should remain with correct counts — otherwise user can't switch to Dell. For this, use post_filter: filtering applies to results, not aggregations.
{
"query": {"match_all": {}},
"post_filter": {"term": {"brand": "ASUS"}},
"aggs": {
"brands": {"terms": {"field": "brand"}}
}
}
Aggregation counts the entire base, results are filtered by ASUS. User sees complete brand list and can switch.







