Meilisearch on Docker: Typo Tolerance, Faceted Search, and Real-Time Sync

Database tutorial - IT technology blog
Database tutorial - IT technology blog

At 2:14 AM on Black Friday, our on-call PagerDuty alarm woke the entire infrastructure team. Our 16-core PostgreSQL primary was pinned at 100% CPU. PgBouncer had maxed out all 300 pooled connections, and median query latency surged from 22ms to past 9 seconds.

The culprit was immediately obvious in pg_stat_activity: thousands of concurrent shoppers were typing queries like samusng glalxy into the site header. Every typo triggered a wildcard ILIKE '%query%' scan across 1.4 million rows. We had to offload search traffic to a dedicated engine before the 8:00 AM marketing blast.

We spun up Meilisearch in Docker, fine-tuned typo tolerance and faceted filters, and set up an event-driven sync pipeline. By sunrise, search latency dropped below 6 milliseconds.

Quick start (5 min)

When production is burning down, simple setups win every time. Docker gives you an isolated, production-ready search node with persistent storage in less than five minutes.

1. Define the Docker Compose Service

Create a clean docker-compose.yml file. Pin the engine version to avoid unexpected index format upgrades, mount a named volume for the database files, and supply a 32-character master key:

version: '3.8'
services:
  meilisearch:
    image: getmeili/meilisearch:v1.6
    container_name: meilisearch_prod
    restart: always
    ports:
      - "7700:7700"
    environment:
      - MEILI_ENV=production
      - MEILI_MASTER_KEY=4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d
      - MEILI_NO_ANALYTICS=true
    volumes:
      - meili_data:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  meili_data:
    driver: local

2. Launch and Verify Health

Start the container in the background:

docker compose up -d
curl -X GET 'http://localhost:7700/health'

The endpoint returns {"status":"available"} within two seconds. The service is up and ready to ingest catalog data.

Deep dive

Relational databases demand exact matches or punish your disks with sequential table scans. Meilisearch takes the opposite approach by using an in-memory prefix tree and typo distances. However, out-of-the-box defaults can wreck search quality if you sell items with short model numbers.

Tuning Typo Tolerance

Meilisearch tolerates one typo on words with 5 or more characters, and two typos on words with 9 or more characters. That works well for standard prose. It falls apart completely on four-letter hardware identifiers or exact SKUs.

You can adjust these thresholds through the index settings endpoint. This configuration ensures queries like "headphnes" match seamlessly while technical terms like "RTX" or catalog SKUs remain strictly enforced:

curl -X PATCH 'http://localhost:7700/indexes/products/settings/typo-tolerance' \
  -H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "enabled": true,
    "minWordSizeForTypos": {
      "oneTypo": 4,
      "twoTypos": 8
    },
    "disableOnWords": ["amd", "rtx", "usb"],
    "disableOnAttributes": ["sku"]
  }'

Pay special attention to disableOnAttributes. Disabling typo correction on sku stops the engine from returning unrelated components when a shopper mistypes a single serial digit.

Configuring Faceted Search

Shoppers rarely stop at free text. They drill down by brand, availability, and budget. Meilisearch requires you to explicitly declare filterable and sortable fields beforehand so it can precompute roaring bitmap indexes:

curl -X PATCH 'http://localhost:7700/indexes/products/settings' \
  -H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filterableAttributes": [
      "category",
      "brand",
      "in_stock",
      "price"
    ],
    "sortableAttributes": [
      "price",
      "created_at"
    ]
  }'

With bitmaps generated, complex filtered searches evaluate directly on index structures without scanning raw documents:

curl -X POST 'http://localhost:7700/indexes/products/search' \
  -H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "q": "headphne",
    "filter": "brand = 'Sony' AND price <= 300 AND in_stock = true",
    "facets": ["category", "brand"]
  }'

In our tests under a load of 450 concurrent requests per second, this query returned in 4.2 milliseconds.

Advanced usage

Moving search outside your primary database introduces state synchronization overhead. If an item sells out in PostgreSQL while Meilisearch still shows it in stock, you end up with backorders and frustrated customers.

Application-Level Sync via Transaction Outbox

Never call external search APIs inside your primary HTTP request-response cycle. If the search cluster lags, your API threads stall. Instead, record state changes in a database outbox table or message broker, then let a background worker batch the updates:

import os
import time
from meilisearch import Client

MEILI_URL = os.getenv("MEILI_URL", "http://localhost:7700")
MEILI_KEY = os.getenv("MEILI_KEY", "4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d")

client = Client(MEILI_URL, MEILI_KEY)
index = client.index("products")

def sync_product_record(product_data):
    """
    Pushes single or batch product updates to Meilisearch.
    Meilisearch handles document replacement by 'id' automatically.
    """
    task = index.add_documents([{
        "id": product_data["id"],
        "name": product_data["name"],
        "sku": product_data["sku"],
        "brand": product_data["brand"],
        "category": product_data["category"],
        "price": float(product_data["price"]),
        "in_stock": product_data["stock_quantity"] > 0,
        "updated_at": int(time.time())
    }], primary_key="id")
    
    print(f"Enqueued sync task ID: {task.task_uid}")

# Sample event from an outbox consumer
new_item = {
    "id": 1042,
    "name": "Wireless Noise-Cancelling Headphones",
    "sku": "WH-1000XM5",
    "brand": "Sony",
    "category": "Audio",
    "price": 299.99,
    "stock_quantity": 12
}

sync_product_record(new_item)

Seeding and Migrations

During our emergency deployment, we had to backfill 52,000 legacy records into Meilisearch from raw database dumps. When converting exported CSV inventory files to JSON chunks for batch imports, I use toolcraft.app/en/tools/data/csv-to-json. The parsing runs client-side in the browser, so product data never leaves your workstation. That gave us clean JSON payloads to pipe into Meilisearch without building throwaway parsing scripts under pressure.

Practical tips

Here are three operational lessons we documented after surviving the Black Friday surge:

1. Never Put the Master Key in Frontend Code

The MEILI_MASTER_KEY grants administrative control, including index deletion. For client-side single-page applications and mobile builds, generate a scoped API key restricted to read operations:

curl -X POST 'http://localhost:7700/keys' \
  -H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "description": "Public Search Only Key",
    "actions": ["search"],
    "indexes": ["products"],
    "expiresAt": null
  }'

Store the returned key token in your frontend environment variables. Even if an attacker inspects your client network requests, they cannot modify settings or export raw collections.

2. Zero-Downtime Re-indexing with Index Swapping

Changing tokenizer rules or stop words forces Meilisearch to re-index all documents. Rebuilding a live index in place spikes query latency. A safer path is building a secondary index in the background, verifying it, and invoking an atomic swap:

# Step 1: Ingest records into products_v2
# Step 2: Atomically swap aliases without interrupting active searches
curl -X POST 'http://localhost:7700/swap-indexes' \
  -H 'Authorization: Bearer 4a8f9c2d1e0b3a7f8e5d2c1b9a0f3e6d' \
  -H 'Content-Type: application/json' \
  --data-binary '[
    {
      "indexes": ["products", "products_v2"]
    }
  ]'

The swap takes less than 10 milliseconds. In-flight requests finish on the old data, while all subsequent requests instantly hit products_v2.

3. Monitor LMDB Memory Mapping

Meilisearch stores index data in LMDB, which relies heavily on the Linux virtual memory page cache rather than custom internal memory allocators. In Docker, allocate memory limits equal to at least 2x the size of your raw dataset. For our 3.2 GB database file, we set a mem_limit: 8g constraint. Anything less triggers OOM kills when concurrent background indexing overlaps with search traffic spikes.

Share: