· 6 min read ·

Search Engine Debt: What Elasticsearch and Meilisearch Look Like Six Months After Migration

Source: lobsters

The migration from Elasticsearch to Meilisearch post is a well-worn genre, and this one from Ani Safifi is a clear example of why people write them: the contrast between the two systems is stark, and Meilisearch reliably wins on initial setup complexity. But most of these posts evaluate both systems at the moment of first deployment, when requirements are simple and both systems cooperate.

The more informative comparison is at month six, when the product has changed, new data shapes have appeared, and someone wants a feature the system was not initially configured to support. This is where the architectural differences between Elasticsearch and Meilisearch translate into actual developer time.

The Schema Problem in Elasticsearch

Elasticsearch uses dynamic mapping by default: when you index a document for the first time, it infers field types from the data it sees. A JSON string becomes either text and keyword, a JSON number becomes long or float, a JSON boolean stays boolean. This is convenient at the start and expensive later.

The canonical problem is a field that starts as a keyword because the first documents contain short string identifiers, and then someone realizes it should be numeric for range filtering. There is no migration path. The mapping is set, and changing a field type requires creating a new index and reindexing every document through the _reindex API.

The standard zero-downtime reindex pattern uses aliases:

# Create the new index with the correct mapping
PUT /products_v2
{
  "mappings": {
    "properties": {
      "price": { "type": "float" }
    }
  }
}

# Copy documents from old index to new
POST /_reindex
{
  "source": { "index": "products_v1" },
  "dest": { "index": "products_v2" }
}

# Atomically swap the alias
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products" } }
  ]
}

This works. It is also an afternoon’s work for a small dataset and several hours for a large one, with operational risk at the alias cutover. Teams that do this once usually add "dynamic": "strict" to their mappings afterward, which rejects documents with unexpected fields and forces explicit schema management. That is the correct approach for production, and it means the initial convenience of dynamic mapping was actually a trap.

Elasticsearch also has the index.max_result_window setting, defaulting to 10,000. Deep pagination past this limit requires search_after with a sort value, which requires a consistent sort field, which requires knowing this limitation before you build the pagination UI. It is a common late surprise.

Settings Updates in Meilisearch

Meilisearch takes a different approach to schema. Documents are schema-free at ingest. Instead of field types, Meilisearch has three categories of settings that control what fields can do: searchableAttributes determines which fields are indexed for full-text search, filterableAttributes determines which fields can appear in filter expressions, and sortableAttributes determines which fields can be used in sort expressions.

Adding a new filterable field after documents are already indexed looks like this:

PATCH /indexes/products/settings
{
  "filterableAttributes": ["price", "category", "in_stock", "brand"]
}

Meilisearch responds immediately with a task object, enqueues the settings update, and rebuilds the relevant internal data structures in the background. Queries keep working during the update. When the task completes, the new field is available for filtering. No alias swap, no reindex command, no downtime.

Updating sortableAttributes behaves the same way. Updating searchableAttributes triggers a more substantial internal rebuild because Meilisearch indexes tokens from those fields, so adding or removing a field from that list requires processing every document again. It is still asynchronous and non-blocking, but for large indexes it runs for several minutes.

This is a meaningful operational difference. The cost of “we need to filter by a new field” in Meilisearch is one API call and a background task; in Elasticsearch it may require a new index and a reindex, depending on whether you planned the mapping correctly. For teams moving fast on product requirements, this matters.

Array Fields and the Nested Document Trap

Both systems have a gotcha around array fields that does not surface until you model real data.

In Elasticsearch, an array of objects stored under a field loses the relationship between sub-fields of the same object. Given this document:

{
  "variants": [
    { "color": "red", "size": "S" },
    { "color": "blue", "size": "L" }
  ]
}

A query for variants.color = red AND variants.size = L will match this document, even though no single variant is both red and large. The object array was flattened at index time. To preserve intra-object relationships, you need the nested type in your mapping and nested queries at search time, which are more expensive and require changes to both the mapping and every query that touches those fields.

Meilisearch handles nested objects more naturally for search, because it indexes nested field values for full-text matching without requiring special query syntax. But filtering on nested array fields has its own limitations for complex conditions. If your data model is a product catalog with variant objects, the correct filtering behavior requires careful testing against both systems before committing.

Relevance Tuning Over Time

Elasticsearch’s default BM25 similarity scores documents based on term frequency and inverse document frequency. This means relevance scores shift as the corpus grows, because IDF is a function of how rare a term is across all indexed documents. A search that returns good results at 10,000 documents may behave differently at 500,000. Custom similarity plugins let you replace BM25 with alternatives like LM Dirichlet or a custom scripted similarity, and function_score queries let you blend scoring with business signals like recency or popularity. This flexibility is real, and teams with dedicated search engineers use it.

Meilisearch uses a deterministic ranking pipeline with six default rules applied in order as tiebreakers: words, typo, proximity, attribute, sort, and exactness. These are configured through the rankingRules settings array. You can reorder them and add custom attribute-based rules, but you cannot replace the underlying algorithm with a statistical model. Relevance does not shift as the corpus grows because it is not statistically computed.

Since Meilisearch v1.0, you can request _rankingScore and _rankingScoreDetails in search results to see how documents were scored, which makes debugging relevance issues much more tractable than interpreting BM25 scores. Elasticsearch has explain=true for similar purposes, but the output is dense with mathematical notation rather than a readable breakdown of the ranking pipeline.

Where Meilisearch’s fixed pipeline becomes a constraint is when business requirements push against its limits. Boosting documents that were viewed or purchased recently, applying domain-specific frequency signals, or running A/B tests on different ranking strategies all require either a custom ranking rule based on an attribute you update server-side, or a wrapper service that re-ranks Meilisearch results. Elasticsearch handles all of these natively with function_score and script_score.

The Maintenance Tradeoff, Stated Plainly

Elasticsearch’s operational burden is front-loaded in some ways and distributed across the life of the application in others. The JVM heap sizing, shard count decisions, and initial mapping design all require upfront investment. If those are done correctly, later requirements around new fields, complex aggregations, or cluster scaling are well-supported. If they are done incorrectly, the cost accumulates as reindex operations and mapping workarounds.

Meilisearch’s operational model is lighter throughout. Settings updates are non-disruptive, the configuration surface is smaller, and the system recovers gracefully from most schema changes without ceremony. The cost arrives when requirements exceed the design scope: aggregations, high-cardinality statistical relevance, horizontal distribution, nested document semantics for complex filtering.

The source article is correct that migrating makes sense for a certain class of application. The missing part of most migration discussions is that the class of application matters not just at the point of migration, but over the full lifetime of the product. If your requirements stay in Meilisearch’s wheelhouse, the lower operational overhead compounds. If they drift outside it, you are either building workarounds or planning another migration.

Was this interesting?