Filtering a catalog with tens of thousands of products via MySQL takes minutes. Elasticsearch with facet aggregation reduces the time to tens of milliseconds. But the standard Bitrix search (search) cannot return aggregated data for filters. We perform a custom integration via the official elasticsearch/elasticsearch client. Configuring Elasticsearch facet aggregation for 1C-Bitrix is a way to speed up catalog filtering. Experience shows: this approach accelerates filter page loading by 95% and allows instantly seeing product counts for each filter value.
The problem: with 50,000 products, MySQL performs grouping by properties in 2–5 seconds, and each filter requires a separate query. Elasticsearch returns both the products themselves and aggregations by brands, prices, characteristics in a single request. This is called facet aggregation.
How facet aggregation speeds up filtering
Aggregations — a query that simultaneously returns search results and statistics by fields: the count of documents for each filter value. One query to Elasticsearch replaces N queries to MySQL for counting each facet.
Example: a laptop catalog. One query returns:
- 240 products matching the current filter
- 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.
Why Elasticsearch is faster than MySQL for facets
MySQL with grouping by multiple properties generates heavy queries with GROUP BY and multiple JOIN. With 50,000 products, such a query takes 2–5 seconds. Elasticsearch processes the same aggregation in 50–300 ms.
| Parameter | MySQL (CIBlockElement::GetList with grouping) |
Elasticsearch (aggregations) |
|---|---|---|
| Query time for 50,000 products | 2–5 seconds | 50–300 ms |
| Number of queries per page | 1 main + N per facet | 1 |
| Scaling to 1 million products | Degradation to 30+ seconds | 500 ms – 2 s |
| Support for combined filters | Complex HAVING | post_filter and nested |
The savings in server resources are significant.
How to configure mapping for facet fields
For facet aggregation, fields must be either keyword (exact value) or integer/float for numeric ranges. Fields of type text cannot be aggregated (or are aggregated by tokens, which is meaningless for facets).
Mapping when creating an index:
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 — this allows correct filtering by combinations of values of the same property. More on mapping in the Elasticsearch documentation.
How to implement post-filter for independent facets
Standard problem: when selecting the «Brand: ASUS» filter, the aggregation by brands should still show all brands with current counts — otherwise the user cannot switch to Dell. This uses post_filter: filtering is applied to results, but not to aggregations.
{
"query": {"match_all": {}},
"post_filter": {"term": {"brand": "ASUS"}},
"aggs": {
"brands": {"terms": {"field": "brand"}}
}
}
Aggregation is calculated over the entire base, results are filtered by ASUS. The user sees the full list of brands and can switch.
Query with aggregations from PHP
Class for working with Elasticsearch via the 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]];
}
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
Data for indexing is collected via CIBlockElement::GetList and sent to Elasticsearch in batches using the 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);
}
How to update indices: comparing approaches
| Method | Speed | Database load | Suitable for |
|---|---|---|---|
| Full reindexing | Slow (hours) | High | Initial run |
| Incremental update | Fast (minutes) | Low | Ongoing changes |
It is recommended to combine both: full reindexing once a day, incremental via agents.
Example of setting up an agent for incremental update:
In the file bitrix/php_interface/init.php add:
CAgent::AddAgent(
"CatalogElasticSearch::incrementalUpdate();",
"elastic",
"N",
60,
date('Y-m-d H:i:s'),
"Y",
date('Y-m-d H:i:s'),
30
);
The incrementalUpdate function checks the b_iblock_element table for changes in the last minute and sends updated documents.
How we do it: setup process
- Audit — analyze the current catalog structure, properties, product count, load.
- Design — define mapping, shard settings, replicas, indexing policy.
- Development — write indexing class, integration with Bitrix (agents, events), implement filter with post-filter.
- Testing — compare MySQL and Elasticsearch speed, verify aggregation correctness under different combinations.
- Deployment — configure monitoring, backups, documentation.
What is included in the work
- Setting up and optimizing Elasticsearch index for the catalog structure
- Indexing code (Bulk API) with integration via Bitrix agents
- Implementation of a filter component with facets and post-filter
- Performance testing on your data
- Documentation and administrator training
- Guarantee on indexing operation and aggregation correctness
Timeline and guarantees
Estimated timelines — from 3 to 7 business days depending on catalog complexity. Cost is calculated individually. We have years of experience and have completed 50+ projects integrating Elasticsearch with 1C-Bitrix. We provide a guarantee on indexing operation and facet correctness.
Get a consultation on configuring Elasticsearch for your catalog. Tell us about your catalog — we will prepare an integration plan and quote.







