Drupal Search API with OpenSearch: from setup to relevance tuning

Most Drupal search articles stop at "and now it works." That is the easy half. The hard half is the phone call three weeks later where someone points out that searching for the company's own name returns a 2019 press release above the About page, and nobody can explain why.

This covers both — getting OpenSearch wired up behind Search API, and then actually understanding the scores it produces well enough to change them on purpose.

When the database backend stops being enough

Search API's database backend is genuinely fine for a while. It builds its own inverted index in SQL tables, it does basic relevance, and for a few thousand nodes on a site where search is a secondary feature, it is the right answer.

The signs it has stopped being the right answer are fairly specific. Search queries start showing up in your slow query log. Faceted search across several fields turns into a join that MySQL plans badly. You need stemming that actually understands your content language, or multilingual indexing, or "more like this," or partial-word matching that does not degrade into a leading-wildcard LIKE. And the ranking is crude enough that you cannot tune your way out of complaints, because there is not much to tune.

At that point you want a real search engine behind the same Search API abstraction — which is the good news, because your Views, facets and index configuration mostly carry over.

The architecture, briefly

Search API splits the world in two. A Server holds backend configuration: which engine, where it lives, how to authenticate. An Index holds content configuration: which entity types and bundles, which fields, which processors, and how it is tracked and queued.

The Search API OpenSearch module plugs in as a backend using the official OpenSearch PHP client. It is worth knowing where it came from: it is a fork of Elasticsearch Connector, made because the Elasticsearch PHP client refuses to talk to OpenSearch above version 7.10.2, which left the older module pinned to increasingly ancient versions. The fork was a rewrite around the newer opensearch-php client.

The design decision that matters day to day is that the module uses Search API's own Server and Index config entities rather than defining its own cluster and index entities the way Elasticsearch Connector did. Fewer moving parts, one config surface, and connection handling lives in a pluggable Connector type so hosted providers can add their own authentication.

Getting it running

Current stable is the 2.x line, supporting Drupal 10.3 and 11:

composer require 'drupal/search_api_opensearch:^2.5'
drush en search_api search_api_opensearch

For local development, a single-node OpenSearch is enough:

services:
  opensearch:
    image: opensearchproject/opensearch:2
    environment:
      - discovery.type=single-node
      - DISABLE_SECURITY_PLUGIN=true
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - "9200:9200"

Two things bite here and both look like the container being broken. On Linux hosts, OpenSearch will refuse to start unless vm.max_map_count is at least 262144 — set it with sudo sysctl -w vm.max_map_count=262144 and make it permanent in /etc/sysctl.conf. And from OpenSearch 2.12 onward, running with the security plugin enabled requires OPENSEARCH_INITIAL_ADMIN_PASSWORD to be set to something that passes its complexity check; disabling the plugin outright, as above, is fine locally and wrong everywhere else.

Then create a Server at /admin/config/search/search-api, choose OpenSearch as the backend, and point the connector at http://opensearch:9200. Create an Index, pick your content types, add fields, and run drush search-api:index.

That is the part every tutorial covers. Now the part they do not.

Field boosts are not volume knobs

Every Search API field has a boost, and the folk wisdom is to set the title to something high — 8.0, 13.0, 21.0 are all values you will find in real config — and move on.

What that number actually does is multiply that field's contribution to the BM25 score. It does not replace the scoring; it scales one input to it. Which means to predict what a boost will do, you have to know what the score already looked like — and for titles, it already looked very favourable.

Recall how BM25 handles document length: matches in short fields score higher, because the length normalisation term penalises documents longer than average. A title is five words. A body is eight hundred. A query term appearing once in each already scores dramatically higher against the title, before any boost is applied at all.

So an 8.0 title boost is not "make titles somewhat more important." It is a large multiplier stacked on top of an advantage BM25 has already granted. The practical result is a search where any title containing the query word wins, regardless of whether the document is about that word — which is precisely the 2019 press release problem.

Two habits that help:

  • Start at 1.0 everywhere and only raise a field once you have a failing query that raising it demonstrably fixes. Boosts inherited from someone's blog post are the most common cause of unexplainable ranking.
  • Think in ratios, not absolutes. A title at 3.0 with a body at 1.0 is a 3:1 relationship. Setting the title to 21.0 and the body to 7.0 is the same search engine with bigger numbers.

Let OpenSearch do the linguistics

Search API ships a set of processors that run in PHP before data leaves Drupal: tokenizer, stemmer, stopwords, ignore case, transliteration. OpenSearch then runs its own analysis chain on whatever it receives.

Enabling both is the single most common misconfiguration in this stack. You stem in PHP, ship pre-stemmed tokens, and OpenSearch stems them again — producing mangled terms and, worse, destroying its ability to apply a real language analyser, because the words it receives are no longer words. Query-time analysis and index-time analysis drift apart, and matching quietly degrades in ways that are hard to attribute to anything.

The division that works:

Do in Search APIDo in OpenSearch
HTML filter (strip markup, tag weighting)Tokenization
Entity status / access checksStemming
Rendered item and aggregated fieldsStopwords
Content exclusionsSynonyms
Highlighting for excerptsCharacter folding

Search API processors are for shaping what gets indexed. OpenSearch analysers are for shaping how it gets interpreted. Keep the line clean and both stay debuggable.

Danish, and other non-English content

This matters more outside English than most documentation admits. OpenSearch's default analyser handles English adequately and Danish poorly — compound words in particular, where arbejdsmarkedspolitik should be findable by someone searching arbejdsmarked, and simply is not with a standard tokenizer.

OpenSearch ships a danish analyser with Snowball stemming and a Danish stopword list, which is a substantial improvement for roughly zero effort. The reliable way to apply it is an index template on the OpenSearch side, matched against the index names the module creates, so the settings are in place before Drupal ever creates the index:

PUT _index_template/drupal-danish
{
  "index_patterns": ["elasticsearch_index_*"],
  "template": {
    "settings": {
      "analysis": {
        "analyzer": {
          "default": { "type": "danish" },
          "default_search": { "type": "danish" }
        }
      }
    }
  }
}

Check the actual index names your site produces with GET _cat/indices and adjust the pattern — do not trust the one above. Setting both default and default_search matters: if index-time and query-time analysis differ, your queries stem differently from your documents and matching falls apart.

For compound splitting beyond what the Snowball stemmer manages, you are into dictionary_decompounder territory, which needs a word list and is a genuine project rather than a config tweak. Worth knowing it exists before you conclude Danish search is just bad.

Debugging relevance instead of guessing at it

This is the part that turns ranking from superstition into engineering, and almost nobody in the Drupal world uses it.

OpenSearch will tell you exactly how it scored a document. Add "explain": true to a search, or hit the _explain endpoint for a single document:

GET /elasticsearch_index_drupal/_explain/12345
{
  "query": {
    "multi_match": {
      "query": "arbejdsmarked",
      "fields": ["title^3", "body"]
    }
  }
}

What comes back is the BM25 calculation, decomposed. You get the boost applied, then idf with the document frequency and total document count that produced it, then tf with the term frequency, the values of k1 and b, the length of that field in this document, and the average field length across the index.

Every one of those is a term from the formula. Read that output next to the BM25 article and the ranking stops being mysterious: you can see whether a document won on rarity, on repetition, on being short, or purely on a boost you set eighteen months ago. Two or three _explain calls will usually tell you more than a week of adjusting numbers and refreshing the search page.

To see the query Search API is actually generating — which is not always what you assume — temporarily drop the search slowlog threshold to zero on the index and read them out of the OpenSearch logs.

If you really do need to change k1 and b

Rarely necessary, but occasionally correct — a site of uniformly short documents has little use for aggressive length normalisation:

PUT /my-index/_settings
{
  "index": {
    "similarity": {
      "default": { "type": "BM25", "k1": 1.2, "b": 0.4 }
    }
  }
}

Similarity is a static index setting, so the index has to be closed to change it on an existing index — which in practice means putting it in your index template and reindexing. Lowering b toward 0.4 reduces the length penalty; raising k1 makes repeated terms count for longer before saturating.

Two deployment traps

Environment-specific connection config. Your Server config entity contains the OpenSearch URL, and that URL differs between local, staging and production — so exporting config from one environment and importing it into another points production at a container that does not exist. Override it in settings.php rather than committing it. Find the exact key path for your setup with drush config:get search_api.server.YOUR_SERVER_ID and mirror that structure in a $config override; guessing at the nesting is how you end up with an override that silently does nothing.

Mapping changes need a reindex. OpenSearch field mappings are largely immutable once created. Changing a field's type, adding an analyser, altering the template — none of it applies to documents already indexed. The sequence is delete the index, let the module recreate it, then reindex:

drush search-api:status
drush search-api:clear     # drop indexed data
drush search-api:reset-tracker
drush search-api:index

On a large site, do this behind a second index and swap an alias rather than serving empty results for two hours.

Where this goes next

Everything above is keyword search, and it has the limitation every bag-of-words model has: a page about configuration management will not match a query for infrastructure automation, however well tuned your boosts are.

The Drupal ecosystem has caught up here faster than you might expect. OpenSearch 3.1 added a semantic field type, and there is a contrib extension to Search API OpenSearch that uses it. Separately, the OpenSearch NLP module wires up ML Commons for semantic, hybrid and vector retrieval — supporting both models hosted in OpenSearch and external embedding providers — and there is an OpenSearch vector database provider for Drupal's AI module.

All of them are young, and the semantic extension in particular is explicitly experimental with no stable release. But the direction is the same one the search world settled on generally: keyword and vector retrieval running in parallel, with the results merged. BM25 keeps catching the exact matches — product codes, names, error strings — that embeddings are unreliable on. Getting your boosts and analysers right is not work you throw away when you add semantic search. It is half of what the hybrid system does.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.