Skip to content

Elasticsearch

Mappings, analyzers, the Query DSL, aggregations, and running a production cluster — for when Meilisearch isn't enough.

Real-World Analogy

A university research library vs a bookstore: the bookstore (Meilisearch) is fast, well-organized for browsing, good for most searches. The research library (Elasticsearch) has specialized cataloguing, subject librarians, cross-referencing, complex aggregations, and handles the full academic corpus. More powerful, more complex to operate, more to configure correctly.

When to Use Elasticsearch

  • Full-text search on billions of documents
  • Complex aggregations (histograms, geo-distance, nested objects)
  • Log and event analytics (Elastic stack: ELK)
  • Multi-language search with custom analyzers
  • Complex relevance tuning (function scoring, scripted scoring)
  • Percolator (reverse search: match documents against stored queries)

Meilisearch for product search, Elasticsearch for analytics and petabyte-scale.

Running Elasticsearch

# docker-compose.yml — single node for development
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false # disable for dev; MUST enable in production
      - ES_JAVA_OPTS=-Xms2g -Xmx2g
    ports:
      - '9200:9200'
    volumes:
      - es_data:/usr/share/elasticsearch/data
    ulimits:
      memlock:
        soft: -1
        hard: -1
# Verify
curl http://localhost:9200/_cluster/health?pretty

Mappings (Schema)

Unlike Meilisearch, Elasticsearch requires explicit mappings for production use. Dynamic mappings (auto-detection) cause type conflicts and poor performance.

import { Client } from '@elastic/elasticsearch';

const es = new Client({ node: 'http://localhost:9200' });

// Create index with mappings
await es.indices.create({
	index: 'products',
	body: {
		settings: {
			number_of_shards: 3,
			number_of_replicas: 1,
			analysis: {
				analyzer: {
					product_analyzer: {
						type: 'custom',
						tokenizer: 'standard',
						filter: ['lowercase', 'asciifolding', 'english_stop', 'english_stemmer']
					},
					autocomplete_analyzer: {
						type: 'custom',
						tokenizer: 'standard',
						filter: ['lowercase', 'autocomplete_filter']
					},
					autocomplete_search_analyzer: {
						type: 'custom',
						tokenizer: 'standard',
						filter: ['lowercase']
					}
				},
				filter: {
					english_stop: { type: 'stop', stopwords: '_english_' },
					english_stemmer: { type: 'stemmer', language: 'english' },
					autocomplete_filter: {
						type: 'edge_ngram', // prefix n-grams for autocomplete
						min_gram: 2,
						max_gram: 20
					}
				}
			}
		},
		mappings: {
			properties: {
				id: { type: 'keyword' }, // exact match, no analysis
				name: {
					type: 'text',
					analyzer: 'product_analyzer',
					fields: {
						autocomplete: {
							// sub-field for autocomplete
							type: 'text',
							analyzer: 'autocomplete_analyzer',
							search_analyzer: 'autocomplete_search_analyzer'
						},
						keyword: { type: 'keyword' } // sub-field for exact/sort
					}
				},
				brand: { type: 'keyword' },
				category: { type: 'keyword' },
				description: { type: 'text', analyzer: 'product_analyzer' },
				tags: { type: 'keyword' },
				price_cents: { type: 'integer' },
				in_stock: { type: 'boolean' },
				popularity: { type: 'integer' },
				created_at: { type: 'date' },
				location: {
					type: 'geo_point' // geographic coordinates
				}
			}
		}
	}
});

Indexing Documents

// Single document
await es.index({
	index: 'products',
	id: 'prod-123',
	document: {
		id: 'prod-123',
		name: 'MacBook Pro 14"',
		brand: 'Apple',
		category: 'laptops',
		description: 'M3 chip, 18GB RAM, 512GB SSD',
		tags: ['laptop', 'apple', 'pro'],
		price_cents: 199900,
		in_stock: true,
		popularity: 9500,
		created_at: '2024-01-01T00:00:00Z'
	}
});

// Bulk indexing (preferred for large imports)
const operations = documents.flatMap((doc) => [
	{ index: { _index: 'products', _id: doc.id } },
	doc
]);

const { errors, items } = await es.bulk({ operations });
if (errors) {
	const failed = items.filter((i) => i.index?.error);
	console.error('Bulk index errors:', failed);
}

Query DSL

// Multi-field text search
const results = await es.search({
	index: 'products',
	query: {
		multi_match: {
			query: 'macbook pro',
			fields: ['name^3', 'brand^2', 'description'], // ^N = boost
			type: 'best_fields',
			fuzziness: 'AUTO' // typo tolerance
		}
	},
	from: 0,
	size: 20
});

// Boolean query with filters
const results = await es.search({
	index: 'products',
	query: {
		bool: {
			must: [
				{
					multi_match: {
						query: 'laptop',
						fields: ['name^3', 'description']
					}
				}
			],
			filter: [
				{ term: { category: 'laptops' } },
				{ term: { in_stock: true } },
				{ range: { price_cents: { lte: 200000 } } }
			],
			should: [
				{ term: { tags: 'pro' } } // boost if "pro" tag matches
			],
			boost: 1.0
		}
	},
	sort: [{ _score: 'desc' }, { popularity: 'desc' }]
});

console.log(results.hits.hits.map((h) => h._source));
console.log(results.hits.total.value); // total matching docs

Aggregations

The killer feature over Meilisearch — complex analytics on search results:

const results = await es.search({
	index: 'products',
	query: {
		bool: {
			must: [{ match: { category: 'laptops' } }],
			filter: [{ term: { in_stock: true } }]
		}
	},
	aggs: {
		// Facet counts
		brands: {
			terms: { field: 'brand', size: 20 }
		},

		// Price histogram
		price_ranges: {
			range: {
				field: 'price_cents',
				ranges: [
					{ to: 50000, key: 'under_500' },
					{ from: 50000, to: 100000, key: '500_to_1000' },
					{ from: 100000, to: 200000, key: '1000_to_2000' },
					{ from: 200000, key: 'over_2000' }
				]
			}
		},

		// Statistics
		price_stats: {
			stats: { field: 'price_cents' }
		},

		// Date histogram for charts
		sales_over_time: {
			date_histogram: {
				field: 'created_at',
				calendar_interval: 'month'
			}
		}
	},
	size: 20 // still return documents too
});

console.log(results.aggregations?.brands);
// { buckets: [{ key: 'Apple', doc_count: 45 }, { key: 'Dell', doc_count: 23 }] }

Autocomplete

// Using the edge_ngram field configured in mappings
const suggestions = await es.search({
	index: 'products',
	query: {
		match: {
			'name.autocomplete': {
				query: 'macb', // prefix
				operator: 'and'
			}
		}
	},
	_source: ['name', 'brand'],
	size: 10
});

// Or using completion suggester (faster, purpose-built for autocomplete)
// Requires 'completion' field type in mapping

Production Cluster

# 3-node cluster configuration (each node's elasticsearch.yml)
cluster.name: production-search

# Node 1:
node.name: es-node-1
node.roles: [master, data]
network.host: 10.0.0.10
discovery.seed_hosts: ['10.0.0.10', '10.0.0.11', '10.0.0.12']
cluster.initial_master_nodes: ['es-node-1', 'es-node-2', 'es-node-3']

# Enable security
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true

Sizing:

  • Each data node: 32GB RAM minimum for production (ES uses 50% for JVM heap by default)
  • JVM heap: -Xms16g -Xmx16g (max 32GB — beyond that G1GC pauses increase)
  • SSD storage: ES is I/O intensive

Index lifecycle management (ILM) for log indices:

// Automatically move indices through hot → warm → cold → delete
await es.ilm.putLifecycle({
	name: 'logs-policy',
	policy: {
		phases: {
			hot: {
				actions: {
					rollover: { max_size: '50gb', max_age: '7d' }
				}
			},
			warm: {
				min_age: '7d',
				actions: {
					shrink: { number_of_shards: 1 },
					forcemerge: { max_num_segments: 1 },
					allocate: { require: { box_type: 'warm' } }
				}
			},
			cold: {
				min_age: '30d',
				actions: { freeze: {} }
			},
			delete: {
				min_age: '90d',
				actions: { delete: {} }
			}
		}
	}
});

Choosing Between Meilisearch and Elasticsearch

Use Meilisearch when:
  ✓ Product/content search with typo tolerance
  ✓ Faceted navigation
  ✓ < 100M documents
  ✓ Simple ops (single binary)
  ✓ Fast setup

Use Elasticsearch when:
  ✓ Log analytics (ELK stack)
  ✓ Billions of documents
  ✓ Complex aggregations (geo, date histograms, nested)
  ✓ Custom analyzers per language
  ✓ Percolator / reverse search
  ✓ Multi-index searches