Search
How to give a site a search page, connect Meilisearch, Elasticsearch or OpenSearch, what is indexed, the search.html template and search() function, browse mode without a backend, synonyms and the zero-result report.
Meridian’s search is two halves. The engine owns the query, the locale, what a visitor may see, the query log, zero-result detection, click tracking and the results a template reads. Retrieval is always an external service you run: Meilisearch, Elasticsearch or OpenSearch. With no service configured, the same search page answers in browse mode: results filtered from the published content in the database, not ranked by relevance.
Add a search page
Declare the search route in the site’s routes.yaml:
# config/sites/<site>/routes.yaml
special:
search: "/search" The path may differ per locale: search: { en: /search, cy: /chwilio }.
Write search.html in one of the site’s template roots (templates/<site>/ or templates/_shared/). engine-dmo check refuses a site that routes search without one.
{% extends "base.html" %}
{% block body %}
<h1>Search</h1>
<form method="get" action="{{ request.path|url }}" role="search">
<label for="q">What are you looking for?</label>
<input id="q" type="search" name="q" value="{{ results.query }}">
<button type="submit">Search</button>
</form>
{% if results.degraded %}<p role="status">Search is in browse mode: results are filtered, not ranked.</p>{% endif %}
{% if results.facets.type %}
<nav aria-label="Filter by type"><ul>
{% for f in results.facets.type %}<li><a href="{{ request.path|url }}?q={{ results.query|urlencode }}&type={{ f.value|urlencode }}">{{ f.label|default:f.value }} ({{ f.count }})</a></li>{% endfor %}
</ul></nav>
{% endif %}
{% if results.items %}
<p>{{ results.total }} results</p>
<ol>
{% for item in results.items %}
<li>
<h2><a href="{{ item.clickUrl|default:item.uri|url }}">{{ item.title|default:item.uri }}</a></h2>
{% if item.image %}{{ item.image.img("thumb") }}{% if item.image.credit %}<small>{{ item.image.credit }}</small>{% endif %}{% endif %}
{% if item.highlight %}<p>{{ item.highlight }}</p>{% elif item.summary %}<p>{{ item.summary }}</p>{% endif %}
</li>
{% endfor %}
</ol>
{% if results.pagination.next %}<a rel="next" href="{{ results.pagination.next|url }}">Next page</a>{% endif %}
{% elif results.query %}
<p>Nothing matched that. Try a shorter phrase.</p>
{% endif %}
{% endblock %} Put a search box on every page with searchForm(), which prints a GET form to the site’s search route (and nothing when the site routes no search):
<header>{{ searchForm() }}</header> Run engine-dmo check. Without a backend it warns that the route serves browse mode only. That is a working search page; add a backend when ranking matters.
The page is a plain GET form, so it works without JavaScript and every result page has a shareable address. Search pages are never cached, because each search is logged.
Connect a backend
# config/engine.yaml
search:
driver: meilisearch
meilisearch:
url: ${MEILISEARCH_URL}
api_key: ${MEILISEARCH_API_KEY} # a secret; set it empty for a server with no key
index_prefix: riverside # Elasticsearch, or OpenSearch with driver: opensearch and an opensearch: block
search:
driver: elasticsearch
elasticsearch:
addresses: ["${ES_ADDRESS}"] # only the first entry is used
username: ${ES_USERNAME:-}
password: ${ES_PASSWORD}
index_prefix: riverside Elasticsearch and OpenSearch authenticate with a username and password (HTTP basic authentication); an API key is not used by these drivers. addresses is a list, but the drivers send every request to its first entry only: point that entry at a load balancer or a coordinating node when the cluster has several.
Then build the index:
engine-dmo search status # the backend, its health, what is indexed
engine-dmo search rebuild # index everything published
engine-dmo search test "harbour walk" --site main The engine creates one index per site and locale, named from index_prefix, with analysis for the locale’s language. Meilisearch needs url; Elasticsearch and OpenSearch need at least one entry in addresses. engine-dmo search capabilities lists what the configured backend supports.
What is indexed
Every document that is published on a site in a locale and has an address there is indexed in that site and locale’s index. A type that is not on a site never reaches its index. There is no per-type switch: a type’s searchable key is accepted and has no effect, so to keep a type out of search, give it no route on that site. Each entry holds:
| Part | Source |
|---|---|
| Title | The document’s title field (a site’s override applied), read from the indexed projection. A type without a title field, such as a singleton declared without one, gives an empty title. |
| Summary | The document’s summary field, read from the indexed projection: a summary your project declares is empty here unless it has index: true (the core types’ summary is indexed). |
| Body | The searchable text of every field, as each field type defines it, walked into repeaters and blocks, up to 20,000 characters. A link’s address, an image’s asset, a relation’s id and container keys are never indexed. |
| Address | The document’s path on the site. |
| Type and tags | For filters and facets. |
| Location | The point in the geo field keyed location (the DMO listing’s field), with index: true, for distance search. A point in a field with another key is not used. |
| Organisation | The organisation the document belongs to (DMO bundle). |
| Card fields | Fields declared index: true holding a short string (up to 200 characters), a number, a boolean or a list of up to 20 short strings, returned as item.fields. |
| Image | The field the type’s structured data maps as image, else its first asset field, returned as item.image. |
Indexing never holds up a publish. Publishing, unpublishing, trashing, restoring, sharing and unsharing each queue an index job for the document, and the index catches up within seconds. Hits that are no longer published are dropped from results even if the index has not caught up yet. A nightly sweep compares each index with what is published, by id and content digest, and repairs any drift; engine-dmo search verify runs the same comparison on demand.
The search route
The search route reads these query parameters:
| Parameter | Meaning |
|---|---|
q | The words searched for. |
type | A type key to filter by; repeat for several. |
tag | A tag key to filter by; repeat for several. |
sort | relevance (the default with words), name, distance or recent. |
page | The page, from 1. Twenty results a page. |
lat, lon | A point to search near. |
km | The radius around lat and lon, default 10. |
A query shorter than search.min_query_length (2 by default, counted in characters as a reader sees them) is not sent to the backend and not logged: the page lists published documents in browse mode, which is what a search page with no q shows.
results
search.html receives results:
| Key | Meaning |
|---|---|
query | The words searched for. |
items | The hits on this page. |
total | How many hits in all. |
facets | Counts by type and by tags, each a list of value, count and label. label is empty for search facets, so print value or map it to your own words. |
degraded | True when the answer came from browse mode because the backend is not configured or not answering. Say so on the page. |
backend | meilisearch, elasticsearch, opensearch, or none. |
sort | The sort asked for. |
page, pages, hasMore | Where this page is. |
pagination | page, pages, total, per, next and prev (addresses, empty at the ends) and range (the page numbers). Also available as pagination. |
didYouMean | Always empty: no backend offers a correction yet. |
Each item in results.items:
| Key | Meaning |
|---|---|
id, type | The document’s id and type key. |
title, summary | As indexed. |
uri, url | The path on the site, and the absolute address. |
clickUrl | A first-party address that records which result position was chosen and then redirects to the result. Link to it when present to feed the click report; it is absent in browse mode. |
highlight | The matched passage with the words marked, when the backend highlights. Safe HTML. |
tags | Tag keys. |
fields | The card fields described above. |
image | The result’s main image as an image value: item.image.img("thumb"). |
distance | Kilometres from the point searched near, when there was one. |
position | The result’s position across all pages, from 1. |
search() in any template
The search() function runs the same service from any page: a “nearby” panel, a type’s own search, a results block on a landing page. It is a chain, run by .paginate(n) (reads the page from the request’s page parameter) or .limit(n) (the first n):
{% set found = search(request.query.q).types(["event", "article"]).facets(["type"]).paginate(12) %}
{% if found.degraded %}<p role="status">Showing filtered results.</p>{% endif %}
{% for item in found.items %}<a href="{{ item.uri|url }}">{{ item.title }}</a>{% endfor %} | Method | Meaning |
|---|---|
search(q) | Start a search for the words q (may be empty: browse). |
.types(list) | Only these type keys. |
.tags(list) | Only documents with these tags. |
.near(point, km) | Within km (default 10) of anything with lat and lon, such as a geo field. With no words, nearest first. |
.sort(order) | relevance, name, distance or recent. |
.facets(list) | Only these facets in facets. |
.paginate(n) | Run it, n a page (default 20, at most 100). |
.limit(n) | Run it, the first n (default 20, at most 100). |
It returns the same object as results.
Browse mode
Browse mode answers a search from the published projection in the database, through the same filter machinery as a type’s index page: type, tags and distance filters work, facet counts are computed, and results are ordered by the requested sort rather than by relevance.
It is used when:
search.driverisnone(the default);- the query is shorter than
min_query_length; - the backend fails, or has failed
circuit_breaker.failurestimes withincircuit_breaker.windowand is resting forcircuit_breaker.cooldown, whiledegraded_modeisbrowse.
This is the path one request takes:
flowchart TD
q["GET /search?q=..."] --> short{"q shorter than<br/>min_query_length?"}
short -->|yes| browse0["Browse mode<br/>not logged, degraded false"]
short -->|no| log["Log the query"]
log --> backend{"Backend configured<br/>and breaker closed?"}
backend -->|yes| ask["Ask the backend"]
ask -->|answers| live["Drop hits no longer published"]
live --> ranked["Ranked results<br/>degraded false"]
ask -->|fails| mode{"degraded_mode"}
backend -->|no| mode
mode -->|browse| browse1["Browse mode<br/>degraded true"]
mode -->|error| err["The site's error page"]results.degraded is true whenever words were searched and no backend answered, including with driver: none; a short or empty query is browse mode with degraded false, because nothing was asked. With degraded_mode: error, a failed backend answers the page with the site’s error page instead.
What browse mode does not do
Browse mode is a filter, not a search. Write search.html for it as well as for a backend, because a site without one serves it for every query:
- The words are not matched.
qis logged and echoed inresults.query, but every published document that passes the type, tag and distance filters is listed, in the requested sort (by title when none is given). Say so on the page whenresults.degradedis true. - No
highlightand noclickUrl. Link toitem.clickUrl|default:item.uri. - Empty titles and summaries show. As with a backend (see What is indexed), an unindexed
summaryand a type with notitlegive empty values, and browse mode lists such documents even though no words matched them. Printitem.title|default:item.uriso every link has a name. - Facet labels are empty, as they are with a backend: print
f.label|default:f.value, or map the value to your own interface string.
Distance filters read the same location field in both modes, and result images and card fields (item.image, item.fields) are filled the same way.
Synonyms
Synonyms are rules held in the database and pushed to each index’s settings; changing them reindexes nothing. A rule without from makes every term find every other; with from, a search for from also finds each term and not the reverse.
# config/search/synonyms.yaml: a seed, loaded once
synonyms:
- { locale: en, terms: [gig, concert, performance] }
- { locale: en, from: kids, terms: [family, children] }
- { locale: en, sites: [main], terms: [beach, seafront, shore] } engine-dmo seed apply and engine-dmo search synonyms import load the file; rules already held are left alone. After that, the admin’s Search synonyms screen owns them (search.synonyms). engine-dmo search synonyms list prints what the database holds. A rule without sites applies on every site that enables its locale. engine-dmo check warns when the file exists and no backend is configured.
The query log and zero results
Every search of at least min_query_length is logged before the backend is asked: the words as typed, a folded form for grouping (so “Café”, “CAFE” and “cafe” are one row), the backend, the result count and whether it was degraded. No visitor identity is stored. Searches made by engine-dmo render and the warm job are not logged.
engine-dmo search zero-results --site main --since 30d --limit 50 The admin’s Insight search screen (analytics.view) shows the commonest searches, the zero-result searches, and the click-through and average position from clickUrl. Degraded searches are left out of the zero-result report. The log is kept for analytics.search_log_retention_days (400 by default). search.log_queries cannot be false in production.
Running the index
| Command | What it does |
|---|---|
search status | The backend, its health and what each index holds. |
search capabilities | What the configured backend supports. |
search rebuild | Reindexes what is published, then deletes what is indexed and no longer published. Resumable: a rebuild that stopped resumes unless --fresh. --site limits it to one site. Recorded where the admin’s Search index screen shows it. |
search verify (alias check) | Compares each index with what is published and reports drift. |
search test <query> | Runs a query (--site, --locale) and shows what came back. |
search synonyms import, list | The synonym seed and the rules held. |
search zero-results | What visitors searched for and did not find (--site, --since, --limit). |
engine-dmo content reindex queues an index job for every published document, or one type’s. engine-dmo warm repairs the index after a deploy. All of these need the database.
The admin’s Search index screen (search.view) shows each index’s count against what is published, the last rebuild and check, and offers rebuild (search.rebuild) and purge (search.purge).
Configuration reference
Every key is under search: in config/engine.yaml.
| Key | Default | Meaning |
|---|---|---|
driver | none | meilisearch, elasticsearch, opensearch or none. |
log_queries | true | Log every search. Cannot be false in production. |
min_query_length | 2 | Shorter queries are browse, not retrieval, and are not logged. |
degraded_mode | browse | browse or error: what a failed backend does to the page. |
circuit_breaker.failures | 5 | Failures within the window that open the breaker. |
circuit_breaker.window | 30s | |
circuit_breaker.cooldown | 1m | How long the breaker stays open, answering in browse mode without asking the backend. |
runs_kept | 20 | Finished rebuild, purge and check runs kept per site and kind, 1 to 1000. |
meilisearch.url | Required with driver: meilisearch. | |
meilisearch.api_key | A secret. | |
meilisearch.index_prefix | meridian | Prefix of every index name. |
meilisearch.timeout | 2s | Per request. |
elasticsearch.addresses | Required with driver: elasticsearch. Only the first URL is used. | |
elasticsearch.username, password | HTTP basic authentication. password is a secret. | |
elasticsearch.index_prefix | meridian | |
elasticsearch.timeout | 3s | |
opensearch.* | as elasticsearch | Used with driver: opensearch. When opensearch.addresses is empty, the elasticsearch block is used instead. |
Each backend block also accepts url, addresses, api_key, username and password, but each driver reads only the keys above: Meilisearch reads url, api_key, index_prefix and timeout; Elasticsearch and OpenSearch read addresses, username, password, index_prefix and timeout, and authenticate with basic authentication only.
Headless sites
A headless frontend searches through the content API, which answers from the same service, log and browse fallback.
Media library
How uploads, alt text and captions per locale and per site, licence and rights records, duplicates, orphans and image derivatives work, with every media setting in engine.yaml.
Navigation
How menus work: navigation slots declared in site.yaml, navigation sets edited in the admin or seeded from files, the nav global and the nav tag in templates, and breadcrumbs, child pages and language switchers.