mdashikjs/blog
All posts
Building Full-Text Search with Elasticsearch and Node.js
Backend

Building Full-Text Search with Elasticsearch and Node.js

Backend6 min

Building Full-Text Search with Elasticsearch and Node.js

PostgreSQL's full-text search is good enough until it is not. When we needed fuzzy matching, faceted results, and sub-50ms queries across 2M documents, Elasticsearch was the right tool.

ElasticsearchSearchNode.jsPerformance
Share:

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:

  1. Application writes to PostgreSQL (source of truth)
  2. A background job picks up changes and indexes them to Elasticsearch
  3. 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
MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.