Facets

Faceted Search (Facets Index)

Functionality

Faceted search adds the ability to attach facets to your product catalog — the filterable, countable attributes (brand, color, size, price band, material, …) that power a "refine your results" sidebar. Where the master index indexes many entity types for general full-text discovery, the facets index is a separate, product-focused index built to return aggregated facet values alongside the search results.

The two indices are completely separate Elasticsearch indices. They are configured, reindexed, and queried independently.

A facets-index search returns three things in one response: the matching records, the total count, and a collection of facets — each facet carrying its values, the document count per value, and which values are currently selected.

Supported entities

The facets index supports a single entity:

Both plain product descriptions and variant descriptions are supported. Only published (and SEO-ready) descriptions are indexed. Whether products, variants, or both are indexed is controlled by the Indexing Scope setting.

Because the index is shaped around ProductDescription, the write side reads product / variant / language / category data straight off that entity. Therefore, for a single SKU product all its descriptions (all languages) will be indexed.

Core fields set

Every facets-index document carries this guaranteed set of core fields (declared in FacetsIndexField::CORE_FIELDS). They are populated automatically from the product description — you don't configure them:

Facets (facets index fields)

A facet is defined as a facets index field. Manage them in the admin at:

/cdna-admin/search/facets-index-fields

Each facet field has the following configuration:

Case sensitivity of Values and Labels. Whether a returned value matches an entry in either list follows the facet's Case Sensitive setting. When the facet is case-sensitive, both lists are matched as-is — only an exact-case value is flagged predefined or relabelled. When the facet is case-insensitive, casing is ignored: xl, XL, xL and Xl all match a single list entry, so each of them is flagged predefined and each receives the label you configured (e.g. the label you set for Xl).

  • Order — how Elasticsearch sorts the facet's value buckets: _count:desc (highest count first), _count:asc, _key:asc (A–Z / 0–9), _key:desc (Z–A / 9–0).
  • Output Order — a "post-return" sort applied to the values after they come back from Elasticsearch and before rendering. Same options as Order. This is what reorders predefined-but-unavailable values into place.
  • Unit of Measure — any string, e.g. mm, kg, in. Returned on the facet for display.
  • Properties — arbitrary JSON attached to the facet, exposed at query time on $facet->properties. Use it freely for rendering hints — a template name, a color palette, group labels, etc. Example:
    {"template": "slider.html", "colors": {"primary": "red", "secondary": "green"}}

    Access it in templates via chained magic properties: $facet->properties->template, $facet->properties->colors->primary. Missing keys (top-level or nested) safely resolve to null, so you don't need isset() guards.

How a field's type maps to what you can do with it

A facets index field's type decides whether it becomes a filter/facet, a full-text field, or both:

This mirrors the master index's type→capability routing, with one deliberate difference in the subfield convention. The master index makes every text field dual-use automatically, adding an exact-match .keyword subfield (full-text on fields.{name}, filtering/sorting on fields.{name}.keyword). The facets index is the other way round: its filter/facet types are exact by default, and you opt in to full-text on one with the Fulltext flag, which adds a .text subfield (fields.{identifier}.text). In short — master appends .keyword for filtering; facets appends .text for full-text.

Facets mapping

"Facets mapping" is the process of telling the facets index what to put into the fields object for each product. It has two layers.

1. The Elasticsearch mapping

The facets index mapping is strict ("dynamic": "strict") — fields are never created automatically; an unmapped field throws. The mapping is built from:

Type specifics:

2. The mapping template

The actual extraction is driven by a Smarty mapping template, configured per index at:

/cdna-admin/search/configure-index/facets

The template runs once per product description at index time. It receives two variables:

Variable What it is
$productDescription The product description being indexed (the Frontend ProductDescription model surface — the same one your frontend product templates use).
$index The facets data builder/collector. Use it to add values to the index or to skip the record.

The collector exposes:

Example mapping template:

{$index->set('brand', $productDescription->getProduct()->getBrandName())}
{$index->set('color', $productDescription->getCustomFieldValue('color'))}
{$index->set('price', $productDescription->getProduct()->getPrice())}

{* Skip discontinued products entirely *}
{if $productDescription->getProduct()->isDiscontinued()}
    {$index->skip('Discontinued product')}
{/if}

Type coercion notes when calling set():

Indexing Scope

On the same configure page, Indexing Scope controls which records the facets index covers:

A record outside the configured scope is not indexed.

Preview

The configure page has a Preview feature. Pick a product description, click Preview, and you'll see, for the unsaved template currently on screen:

Important: saving the mapping template does not automatically reindex existing documents. Open the indices page and run Reindex when you're ready to apply the new template to the data.

Indexing process

Every time you CRUD a product description it is indexed into the facets index — subject to three gates, applied in order:

  1. Publish / SEO policy — only published, SEO-ready descriptions are indexed.
  2. Indexing scope — products only / variants only / both.
  3. Template rules — the mapping template may skip() the record (and if the template crashes or produces no data, the record is excluded defensively).

You can reindex on demand from the indices page:

/cdna-admin/search/indices

From there you can Reindex (reindex all records batch by batch without dropping the index — unavailable records are removed when the run completes) or Rebuild (drop the index and its data, then reindex from scratch — data appears partially during the run). Both are available for the current site or for all sites.

"Rebuild required" — mapping-breaking facet field changes

Three facet-field changes invalidate the existing Elasticsearch mapping, so the index is flagged Rebuild required (a banner appears on the indices page; click Rebuild to recreate the mapping and reindex). They share one trait: a plain Reindex cannot apply them — only a Rebuild (which recreates the index from the current mapping) can.

  1. Changing a facet field's Type — rewrites the field definition outright.
  2. Toggling Case Sensitive on a keyword field — adds or removes the case_insensitive normalizer, which Elasticsearch refuses to change on an existing field. (On non-keyword types the toggle has no mapping effect, so it does not flag a rebuild.)
  3. Toggling Fulltext on a non-text field — adds or removes the .text subfield. Elasticsearch will not back-fill a newly added subfield onto existing documents, and a Reindex does not re-apply the mapping, so the change takes effect only after a Rebuild. (On a text field the flag is a no-op, so it does not flag a rebuild.)

Notably — and unlike the master indexother changes to a facet field (adding, removing, renaming a field, editing its labels/values/order, etc.) do not trigger an automatic reindex. This is by design: facet data extraction is governed by the mapping template, not by the field row, and the template does not auto-re-run when a field row changes. After editing facet fields or the template, run a Reindex when you want the change reflected in the data.

Views

As with the master index, a View specifies how you search the indexed data — page size, full-text settings, filter fields, sorting, highlighting — and which parameters are (un)changeable. The difference is the index a view targets.

Create a facets-index view at:

/cdna-admin/search/add-view/facets

(A master-index view is created at /cdna-admin/search/add-view/master.)

Everything described in the master-index documentation — pagination, full-text search, search fields, sort fields, highlight — applies to facets-index views too. The facets-specific behaviour lives in the filter fields, which are your facets:

For each filter field (facet) in the view you set:

Only a facets-index view builds aggregations. For each renderable filter field, the view adds an Elasticsearch terms aggregation using that facet field's configured Limit (size) and Order.

Searching

Search a facets view exactly as you search a master view:

Use q for the full-text phrase. Use limit / page for pagination. Use searchFields, orderBy, highlightFields, minimumShouldMatch, tieBreaker, fuzzy exactly as documented for the master index — they are shared query mechanics.

q is optional. When you omit q, no full-text query is applied, but filters and aggregations still run — so a category or landing page can request just filtered results plus facet counts, e.g. /search/products?fields.brand=nike with no search phrase at all.

Sortable fields. orderBy can target _score plus the keyword facet fields. Other facet types (which are filterable but not sortable) and the core fields are not sortable in a facets view. (_score defaults to descending; every other field defaults to ascending.)

Filtering by facet values

Apply a facet filter by sending the facet's filter name (fields.{identifier}) as a query parameter. As with all filter fields, you can pass multiple values comma-separated or as an array, and the field must be a changeable filter field in the view:

/search/products?q=shoes&fields.brand=nike,adidas&fields.color=black

Array syntax (fields.brand[]=nike&fields.brand[]=adidas) works too. Unknown or non-changeable fields are ignored.

How filters combine: multiple values for the same field are OR-ed together (a single Elasticsearch terms query), while different fields are AND-ed. There is no cross-field OR — every filtered field must match. (minimumShouldMatch affects only the full-text multi_match, not filters.)

The search response

A facets search returns a SearchResponse, serialised as:

{
  "totalCount": 42,
  "resultItems": [ ... ],
  "facets": [ ... ]
}

(facets is always empty for a master-index search.)

Facet shape

Each entry in facets looks like:

{
  "identifier": "brand",
  "filter": "fields.brand",
  "name": "Brand Name",
  "type": "keyword",
  "unit": null,
  "disabled": false,
  "properties": { "template": "checkboxes.html" },
  "values":            [ ... ],
  "availableValues":   [ ... ],
  "predefinedValues":  [ ... ]
}

In templates you'd typically use getAvailableValues() to render only the values that still have results, or getValues() to render everything (showing zero-count predefined values greyed out).

Facet value shape

Each value:

{
  "value": "nike",
  "label": "Nike",
  "count": 12,
  "selected": true,
  "predefined": false,
  "available": true
}

A value can be both selected and unavailable (count = 0) — that's the normal "the active filter has narrowed the result to zero" UX, where the filter pill stays visible.

Conclusion

The facets index complements the master index with a product-focused, aggregation-driven search. You define facets as facets index fields, decide what gets indexed into them through a per-product mapping template (with live preview and an indexing-scope switch), and search them through facets-index views that turn renderable filter fields into Elasticsearch aggregations. The response hands you fully-formed facet objects — values, counts, selection state, labels, units, and your own arbitrary rendering properties — ready to drive a refine-results UI.