Site search on Core dna is an Elasticsearch index that the platform fills and queries, and a page you build. What reaches the index, search views, the search page, product facets, filtered listings that need no index, and when to go beyond the built-in index.
L2
Unit 05 · Relevance, mixed results and external engines
Mixed result sets
Relevance, mixed results and external enginesPage 17 of 20Updated
Returning pages, posts, FAQ answers and products in one result list so that it feels coherent rather than arbitrary: one shape for every type, a sensible order, products first when the site needs it, and letting the visitor narrow the list by type.
Who this is for. The theme developer whose site search returns several record types in one list. Afterwards every result has the same parts whatever its type, the order makes sense to a visitor, and the visitor can narrow the list to one type.
Give every type one shape
The site search index holds pages, blogs, blog posts, FAQ questions and product descriptions at once, and books, units and book pages on releases after 2026.8.1, so one view can return a guide, a product and a news post in a single list. A mixed list feels arbitrary when each result shows different things: a product with a price, a post with a date, a page with neither. Address it in the indexing, not the template. Map a small common set of fields across every type, so that each result has the same parts. A title and a link come free, as name and fullPath; add a one-line summary by pointing each type's plain-text summary area at one shared search field (Two record types, one field):
Shared field
Page
Blog post
FAQ question
Product description
summary
intro
excerpt
shortAnswer
shortDescription
An image cannot be put into the index, because image areas cannot be mapped. A result that needs a picture loads its record with getRecord() and reads the image from it, at one database query per result; on a page of 15 results that is affordable, on a search-as-you-type box that answers each keystroke it is not.
The template then renders every result the same way, and reads the record type only to add a small badge:
{% set typeLabels = {
'Pages\\Entities\\Page': 'Page',
'Blogs\\Entities\\Post': 'Article',
'Faq\\Entities\\Question': 'Answer',
'Products\\Entities\\ProductDescription': 'Product'
} %}
{% for item in response.getResultItems() %}
{% set type = item.getText('entityClass') %}
<article class="result">
<a href="{{ item.getText('fullPath')|e }}">{{ item.getText('name')|e }}</a>
<span class="result-type">{{ typeLabels[type]|default('Page') }}</span>
{% set summary = item.getText('fields.summary') %}
{% if summary %}<p>{{ summary|u.truncate(160, '...', false)|e }}</p>{% endif %}
</article>
{% endfor %}
entityClass is on every result: Pages\Entities\Page, Blogs\Entities\Blog, Blogs\Entities\Post, Faq\Entities\Question, Products\Entities\ProductDescription, and the three Books\Entities classes where books are indexed. The layout area names in the table are examples; use your layouts' own.
A sensible order for a mixed set
With a query and no sort, results come back by relevance (How results are ranked). Across types this is usually what you want, the best match first whatever it is, with two decisions to make:
Weight the title so that a direct hit wins. A query that is a product's exact name should return that product first, not a page that mentions it in passing. Weighting name well above the body does this, name^3,fields.summary^2,fields.content, and is what keeps a mixed set from feeling random.
Keep date sorts for one type. Every indexed type has a published date, but it means different things: an article's date of writing, a product's date of going on sale. A mixed list sorted by date interleaves them meaninglessly. Offer "newest first" on a view scoped to articles; on a mixed view, orderBy=_score,published:desc keeps relevance and only breaks ties by date. A result with no value for a sort field sorts last.
When one type should always lead regardless of score, products before content on a shop's search for example, that is two searches, not one sort. Run a product view and a content view through $_search and render them as two sections, each in its own relevance order; one view cannot group by type and rank within each group. Each section is one request to Elasticsearch.
Letting the visitor narrow by type
The record type is a filter field, entityClass, so a mixed view can offer the types as links that keep the current query. Keep the filter changeable on this view, since narrowing to any type is the point, and keep Ignore empty on, so that an empty entityClass=, which a form's "Everything" option sends, means no filter rather than nothing. The "Everything" link below sends no entityClass at all:
{% set types = {
'': 'Everything',
'Pages\\Entities\\Page': 'Pages',
'Blogs\\Entities\\Post': 'Articles',
'Products\\Entities\\ProductDescription': 'Products'
} %}
{% set active = _request.getParameter('entityClass')|default('') %}
<nav class="result-types" aria-label="Filter by type">
{% for class, label in types %}
{% set href = '/search/' ~ view.getSlug() ~ '?q=' ~ query|e('url') %}
{% if class %}{% set href = href ~ '&entityClass=' ~ class|e('url') %}{% endif %}
<a href="{{ href|e }}"{{ active == class ? ' aria-current="true"' : '' }}>{{ label|e }}</a>
{% endfor %}
</nav>
$query is set as on the minimal search page, and reading entityClass through $_request declares it to the page cache as well. The filter compares values ignoring case, so the class names match however they are typed. Unlike a product facet, a site search filter carries no count, so these links cannot show "(12)" beside each type without one more search per type: run $_search with the query and that type, and read only getTotalCount(). Most sites accept the links without counts.