Imagine your online store grows to 500,000 products, and searching by name via ILIKE % in PostgreSQL stutters with every keystroke. Customers leave, conversion drops—each second of delay reduces it by 7%. We've faced this situation more than once. In one project, searching across 1 million products took 8 seconds; after migrating to Elasticsearch, it was 80 ms. Conversion grew by 15%. Over time, we've configured Elasticsearch for 30+ projects—from catalogs to marketplaces. Our experience shows that proper index and analyzer configuration reduces search time by 10x.
Why Elasticsearch Instead of Full-Text Search in PostgreSQL?
PostgreSQL can do full-text search via tsvector, but it struggles with heavy loads, complex morphology, and facets. Elasticsearch outperforms PostgreSQL by 10-20x in speed under real workloads. Compare:
| Criteria | PostgreSQL (ILIKE/tsvector) | Elasticsearch |
|---|---|---|
| Search speed for 1 million records | 200-500 ms | 10-50 ms |
| Russian morphology | Basic via dictionaries | Stemming, synonyms, custom analyzers |
| Faceted filtering | Limited | Powerful aggregations |
| Autocomplete | Hacks with trigrams | Edge n-gram, Suggester |
| Geo search | Via PostGIS, slow | Native, fast |
Plus, Elasticsearch's distributed search handles billions of documents.
How We Configure Elasticsearch for Your Task
We don't set up Elasticsearch "out of the box." We always analyze your data structure and typical queries. Here's a real example: for an electronics catalog with 200,000 items, we created an index with two analyzers: Russian (stemming + stop words) and autocomplete (edge n-gram). The setup took 4 days. In another project, we needed a custom char_filter to clean product names of special characters.
Installing Elasticsearch 8.x
# Add repository
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" > /etc/apt/sources.list.d/elastic-8.x.list
apt update && apt install -y elasticsearch
# Save superuser password from installation output
systemctl enable elasticsearch && systemctl start elasticsearch
Minimal config for single-node dev:
# /etc/elasticsearch/elasticsearch.yml
cluster.name: myapp-search
node.name: node-1
path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
network.host: 127.0.0.1
discovery.type: single-node
xpack.security.enabled: true
xpack.security.http.ssl.enabled: false # for dev; in prod — enable
We allocate heap as: no more than 50% RAM, no more than 32 GB (due to compressed OOPs). 4 GB is enough to start.
Index Mapping
Mapping defines the index schema. An incorrect mapping cannot be fixed without reindexing. Read more at Elasticsearch mapping:
PUT /products
{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"russian_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "russian_stop", "russian_stemmer"]
},
"autocomplete_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "edge_ngram_filter"]
},
"autocomplete_search": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase"]
}
},
"filter": {
"russian_stop": { "type": "stop", "stopwords": "_russian_" },
"russian_stemmer": { "type": "stemmer", "language": "russian" },
"edge_ngram_filter": { "type": "edge_ngram", "min_gram": 2, "max_gram": 20 }
}
}
},
"mappings": {
"properties": {
"id": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "russian_analyzer",
"fields": {
"autocomplete": { "type": "text", "analyzer": "autocomplete_analyzer", "search_analyzer": "autocomplete_search" },
"keyword": { "type": "keyword" }
}
},
"description": { "type": "text", "analyzer": "russian_analyzer" },
"category": { "type": "keyword" },
"brand": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"in_stock": { "type": "boolean" },
"attributes": { "type": "object", "dynamic": true },
"location": { "type": "geo_point" },
"created_at": { "type": "date" }
}
}
}
Search Query with Facets
POST /products/_search
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "wireless headphones",
"fields": ["name^3", "description", "name.autocomplete^2"],
"type": "best_fields",
"fuzziness": "AUTO"
}
}
],
"filter": [
{ "term": { "in_stock": true } },
{ "range": { "price": { "gte": 1000, "lte": 10000 } } },
{ "terms": { "category": ["audio", "headphones"] } }
]
}
},
"aggs": {
"categories": { "terms": { "field": "category", "size": 20 } },
"brands": { "terms": { "field": "brand", "size": 30 } },
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 1000 },
{ "from": 1000, "to": 5000 },
{ "from": 5000, "to": 15000 },
{ "from": 15000 }
]
}
}
},
"highlight": {
"fields": { "name": {}, "description": { "fragment_size": 150 } }
},
"from": 0,
"size": 24,
"sort": [{ "_score": "desc" }, { "created_at": "desc" }]
}
How to Configure Autocomplete?
Autocomplete is implemented via an edge n-gram analyzer, as shown in the mapping. Edge n-gram creates tokens from 2 to 20 characters. Users get suggestions after entering 2-3 characters—improving UX. The name.autocomplete field indexes the start of each word. In search queries, use match_phrase_prefix or multi_match on this field.
Estimated Timelines
Timelines depend on complexity. Basic setup with one index and integration takes 3-5 days. If you need autocomplete, facets, and PostgreSQL synchronization, add another 3-5 days. A 3-node cluster with monitoring takes 1-2 weeks. Costs are calculated individually. Below is an approximate timeline by stage:
| Stage | Duration |
|---|---|
| Analysis and design | 1-2 days |
| Installation and index setup | 1-2 days |
| Integration with application | 2-3 days |
| Testing and optimization | 1-2 days |
| Deployment and monitoring | 1 day |
Syncing Data from PostgreSQL
For synchronization, we use logical replication via Debezium + Kafka in production scenarios. For a start, periodic reindexing via cron is sufficient. Below is an example in TypeScript:
// sync/product-indexer.ts
import { Client } from '@elastic/elasticsearch'
import { Pool } from 'pg'
const es = new Client({ node: 'http://localhost:9200', auth: { username: 'elastic', password: process.env.ES_PASSWORD! } })
const pg = new Pool({ connectionString: process.env.DATABASE_URL })
export async function indexProduct(id: string) {
const { rows } = await pg.query(`
SELECT p.*, c.name AS category_name,
json_agg(json_build_object('key', a.key, 'value', a.value)) AS attributes
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN product_attributes a ON a.product_id = p.id
WHERE p.id = $1
GROUP BY p.id, c.name
`, [id])
if (!rows.length) {
await es.delete({ index: 'products', id })
return
}
const p = rows[0]
await es.index({
index: 'products',
id: p.id,
document: {
id: p.id,
name: p.name,
description: p.description,
category: p.category_name,
price: p.price,
in_stock: p.stock_quantity > 0,
attributes: Object.fromEntries(p.attributes?.map((a: any) => [a.key, a.value]) ?? []),
created_at: p.created_at
}
})
}
export async function reindexAll() {
const { rows } = await pg.query('SELECT id FROM products WHERE deleted_at IS NULL')
const chunks = chunk(rows.map(r => r.id), 100)
for (const ids of chunks) {
await Promise.all(ids.map(indexProduct))
console.log(`Indexed ${ids.length} products`)
}
}
Step-by-Step Setup Plan
- Analysis—study data structure, typical queries, speed requirements (1-2 days).
- Design—develop mapping, analyzers, sync scheme (1-2 days).
- Implementation—install Elasticsearch, configure index, write integration (3-5 days).
- Testing—check search relevance, facets, speed (1-2 days).
- Deployment—deploy to production, configure monitoring (1 day).
What's Included
After completion, you receive:
- Configured Elasticsearch cluster (single node or cluster) with access
- Indices with custom analyzers for Russian language
- Integration with your application via REST API
- Data synchronization scripts (e.g., from PostgreSQL)
- Operation documentation
- Training for your team
We provide a 3-month guarantee: if search doesn't work as expected, we fix it for free.
Cluster Monitoring
We recommend monitoring cluster health via _cluster/health and enabling slowlog for search queries. Use Elastic Metricbeat to collect metrics—this helps detect degradation in time.
Common Configuration Mistakes
- Incorrect mapping—dynamic mapping leads to unexpected field types, breaking facets. Always define schema explicitly.
- Too small heap—insufficient memory causes frequent GC pauses and performance drop. Allocate at least 50% RAM, but no more than 32 GB.
- No slowlog—without it, you won't see slow queries. Enable slowlog in config:
index.search.slowlog.threshold.query.warn: 2s. - Ignoring replicas—for fault tolerance, at least 1 replica is needed. Set
number_of_replicas: 1.
Start the Work
If your search is slow or can't handle the load, contact us. We'll evaluate your system and propose a solution. Order a turnkey Elasticsearch setup—get a free consultation. Don't delay: every second of delay costs you customers. Request an audit today.







