Setting up Elasticsearch faceted aggregation in 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1175
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    811
  • image_bitrix-bitrix-24-1c_development_of_an_online_appointment_booking_widget_for_a_medical_center_594_0.webp
    Development based on Bitrix, Bitrix24, 1C for the company Development of an Online Appointment Booking Widget for a Medical Center
    564
  • image_bitrix-bitrix-24-1c_mirsanbel_458_0.webp
    Development based on 1C Enterprise for MIRSANBEL
    747
  • image_crm_dolbimby_434_0.webp
    Website development on CRM Bitrix24 for DOLBIMBY
    655
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

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.