When to use Elasticsearch vs PostgreSQL
- PostgreSQL
tsvector: simple keyword search, fewer than 500K documents, no need for fuzzy matching or complex scoring - Elasticsearch: fuzzy matching, autocomplete, facets, relevance tuning, or millions of documents
If your search box is a nice-to-have, PostgreSQL is fine. If search is the product, use Elasticsearch.
Index mapping
Define the structure before indexing documents. This is like a database schema but with search-specific settings:
import { Client } from '@elastic/elasticsearch';
const client = new Client({ node: 'http://localhost:9200' });
await client.indices.create({
index: 'products',
body: {
settings: {
analysis: {
analyzer: {
product_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: ['lowercase', 'asciifolding', 'edge_ngram_filter'],
},
},
filter: {
edge_ngram_filter: {
type: 'edge_ngram',
min_gram: 2,
max_gram: 15,
},
},
},
},
mappings: {
properties: {
name: { type: 'text', analyzer: 'product_analyzer' },
description: { type: 'text' },
category: { type: 'keyword' },
price: { type: 'float' },
tags: { type: 'keyword' },
},
},
},
});
The edge_ngram_filter enables autocomplete — typing "lap" matches "laptop."
Indexing documents
For bulk indexing, use the bulk API. Indexing one document at a time is 10-50x slower:
const products = await db.product.findMany();
const operations = products.flatMap((p) => [
{ index: { _index: 'products', _id: p.id } },
{ name: p.name, description: p.description, category: p.category, price: p.price, tags: p.tags },
]);
const { errors } = await client.bulk({ body: operations });
if (errors) {
console.error('Bulk indexing had errors');
}
Search with fuzzy matching
async function searchProducts(query: string, filters?: { category?: string; minPrice?: number }) {
const must: any[] = [
{
multi_match: {
query,
fields: ['name^3', 'description', 'tags^2'],
fuzziness: 'AUTO',
},
},
];
const filter: any[] = [];
if (filters?.category) {
filter.push({ term: { category: filters.category } });
}
if (filters?.minPrice) {
filter.push({ range: { price: { gte: filters.minPrice } } });
}
const result = await client.search({
index: 'products',
body: {
query: { bool: { must, filter } },
highlight: { fields: { name: {}, description: {} } },
aggs: {
categories: { terms: { field: 'category', size: 20 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ to: 50 },
{ from: 50, to: 200 },
{ from: 200 },
],
},
},
},
},
});
return result;
}
The ^3 on name means a match in the product name is 3x more relevant than a match in the description. fuzziness: 'AUTO' handles typos — "laptpo" still matches "laptop."
Keeping Elasticsearch in sync
The hardest part of search is not the search itself — it is keeping the index up to date. I use a change-data-capture approach:
- Application writes to PostgreSQL (source of truth)
- A background job picks up changes and indexes them to Elasticsearch
- If Elasticsearch is down, changes queue up and replay when it recovers
Never make Elasticsearch your primary data store. It can lose data in edge cases. PostgreSQL is the source of truth; Elasticsearch is a read-optimized projection.
Performance results
- Search latency: 200ms (PostgreSQL) to 12ms (Elasticsearch)
- Autocomplete: not possible with PostgreSQL, 8ms with edge n-grams
- Faceted search: required client-side computation before, now server-side in the same query
