Skip to content

Full-Text Search with Elasticsearch and Node.js

A step-by-step tutorial for full-text search with Elasticsearch: index design, analyzers, fuzzy matching, faceted search and performance tuning.

4 min read
Elasticsearch query pipeline showing analyzer, tokenizer, and scoring stages

Out-of-the-box database LIKE queries stop being viable the moment your dataset grows beyond a few thousand records or your users expect anything beyond exact substring matching. Typo tolerance, relevance scoring, synonym expansion, faceted filtering—these require a dedicated search engine.

Elasticsearch is the standard choice for full-text search in application development. This tutorial walks through building a production-quality search layer from index design through query optimization.

Setting Up the Elasticsearch Client

tstypescript
import { Client } from "@elastic/elasticsearch";
 
const client = new Client({
  node: process.env.ELASTICSEARCH_URL || "http://localhost:9200",
  auth: {
    username: process.env.ES_USERNAME || "elastic",
    password: process.env.ES_PASSWORD || "",
  },
  maxRetries: 3,
  requestTimeout: 30000,
});
 
// Verify connection
async function checkConnection(): Promise<boolean> {
  try {
    const health = await client.cluster.health();
    console.log(`Cluster: ${health.cluster_name}, Status: ${health.status}`);
    return health.status !== "red";
  } catch (error) {
    console.error("Elasticsearch connection failed:", error);
    return false;
  }
}

Designing the Index with Custom Analyzers

The index mapping determines how text is tokenized, normalized, and stored. A well-designed mapping is the difference between search results that feel magical and results that frustrate users.

tstypescript
async function createProductIndex(): Promise<void> {
  await client.indices.create({
    index: "products",
    body: {
      settings: {
        number_of_shards: 1,
        number_of_replicas: 1,
        analysis: {
          analyzer: {
            product_analyzer: {
              type: "custom",
              tokenizer: "standard",
              filter: [
                "lowercase",
                "product_synonyms",
                "product_stemmer",
                "edge_ngram_filter",
              ],
            },
            search_analyzer: {
              type: "custom",
              tokenizer: "standard",
              filter: ["lowercase", "product_synonyms", "product_stemmer"],
            },
          },
          filter: {
            product_synonyms: {
              type: "synonym",
              synonyms: [
                "laptop,notebook,macbook",
                "phone,mobile,smartphone,cellphone",
                "headphones,earbuds,earphones",
              ],
            },
            product_stemmer: {
              type: "stemmer",
              language: "english",
            },
            edge_ngram_filter: {
              type: "edge_ngram",
              min_gram: 2,
              max_gram: 15,
            },
          },
        },
      },
      mappings: {
        properties: {
          name: {
            type: "text",
            analyzer: "product_analyzer",
            search_analyzer: "search_analyzer",
            fields: {
              exact: { type: "keyword" },
              suggest: {
                type: "completion",
                analyzer: "simple",
              },
            },
          },
          description: {
            type: "text",
            analyzer: "product_analyzer",
            search_analyzer: "search_analyzer",
          },
          category: { type: "keyword" },
          brand: { type: "keyword" },
          price: { type: "float" },
          rating: { type: "float" },
          inStock: { type: "boolean" },
          tags: { type: "keyword" },
          createdAt: { type: "date" },
        },
      },
    },
  });
}

The index uses separate analyzers for indexing and searching. The index analyzer applies edge n-grams for prefix matching ("lapt" matches "laptop"), while the search analyzer skips n-grams to avoid over-matching.

Bulk Indexing for Performance

Indexing documents one at a time is inefficient. Bulk operations dramatically improve indexing throughput.

tstypescript
interface Product {
  id: string;
  name: string;
  description: string;
  category: string;
  brand: string;
  price: number;
  rating: number;
  inStock: boolean;
  tags: string[];
}
 
async function bulkIndex(products: Product[]): Promise<void> {
  const batchSize = 500;
 
  for (let i = 0; i < products.length; i += batchSize) {
    const batch = products.slice(i, i + batchSize);
 
    const operations = batch.flatMap((product) => [
      { index: { _index: "products", _id: product.id } },
      product,
    ]);
 
    const result = await client.bulk({ body: operations, refresh: false });
 
    if (result.errors) {
      const failedItems = result.items.filter(
        (item) => item.index?.error
      );
      console.error(
        `Batch ${i / batchSize}: ${failedItems.length} failures`
      );
      for (const item of failedItems) {
        console.error(item.index?.error);
      }
    } else {
      console.log(
        `Batch ${i / batchSize}: indexed ${batch.length} documents`
      );
    }
  }
 
  // Refresh index after bulk indexing is complete
  await client.indices.refresh({ index: "products" });
}

Building the Search Query

A production search query combines full-text matching, fuzzy tolerance, field boosting, and filtering into a single request.

tstypescript
// ❌ Naive search — no relevance tuning, no fuzzy matching
async function naiveSearch(query: string) {
  return client.search({
    index: "products",
    body: {
      query: {
        match: { name: query },
      },
    },
  });
}
 
// ✅ Production search with fuzzy matching, boosting, and filters
interface SearchParams {
  query: string;
  category?: string;
  priceMin?: number;
  priceMax?: number;
  inStockOnly?: boolean;
  page?: number;
  pageSize?: number;
  sortBy?: "relevance" | "price_asc" | "price_desc" | "rating";
}
 
async function searchProducts(params: SearchParams) {
  const {
    query,
    category,
    priceMin,
    priceMax,
    inStockOnly = false,
    page = 1,
    pageSize = 20,
    sortBy = "relevance",
  } = params;
 
  const filters: object[] = [];
 
  if (category) {
    filters.push({ term: { category } });
  }
  if (inStockOnly) {
    filters.push({ term: { inStock: true } });
  }
  if (priceMin !== undefined || priceMax !== undefined) {
    const range: Record<string, number> = {};
    if (priceMin !== undefined) range.gte = priceMin;
    if (priceMax !== undefined) range.lte = priceMax;
    filters.push({ range: { price: range } });
  }
 
  const sort =
    sortBy === "price_asc"
      ? [{ price: "asc" }]
      : sortBy === "price_desc"
        ? [{ price: "desc" }]
        : sortBy === "rating"
          ? [{ rating: "desc" }]
          : [{ _score: "desc" }];
 
  return client.search({
    index: "products",
    body: {
      from: (page - 1) * pageSize,
      size: pageSize,
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query,
                fields: ["name^3", "description", "brand^2", "tags"],
                type: "best_fields",
                fuzziness: "AUTO",
                prefix_length: 2,
              },
            },
          ],
          filter: filters,
        },
      },
      sort,
      highlight: {
        fields: {
          name: { number_of_fragments: 0 },
          description: { fragment_size: 150, number_of_fragments: 2 },
        },
        pre_tags: ["<mark>"],
        post_tags: ["</mark>"],
      },
      aggs: {
        categories: { terms: { field: "category", size: 20 } },
        brands: { terms: { field: "brand", size: 20 } },
        price_ranges: {
          range: {
            field: "price",
            ranges: [
              { to: 50 },
              { from: 50, to: 100 },
              { from: 100, to: 500 },
              { from: 500 },
            ],
          },
        },
        avg_rating: { avg: { field: "rating" } },
      },
    },
  });
}

The name^3 syntax boosts name matches to three times the score of description matches. fuzziness: "AUTO" allows single character edits for shorter terms and two edits for longer terms, handling most typos naturally.

Processing Search Results

Transform raw Elasticsearch results into a clean API response with pagination metadata, highlighted snippets, and facet counts.

tstypescript
interface SearchResult {
  products: Array<{
    id: string;
    name: string;
    description: string;
    price: number;
    rating: number;
    highlights: {
      name?: string;
      description?: string[];
    };
    score: number;
  }>;
  facets: {
    categories: Array<{ key: string; count: number }>;
    brands: Array<{ key: string; count: number }>;
    priceRanges: Array<{ label: string; count: number }>;
  };
  pagination: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
  };
}
 
function transformResults(
  esResponse: any,
  page: number,
  pageSize: number
): SearchResult {
  const hits = esResponse.hits;
 
  return {
    products: hits.hits.map((hit: any) => ({
      id: hit._id,
      ...hit._source,
      highlights: {
        name: hit.highlight?.name?.[0],
        description: hit.highlight?.description,
      },
      score: hit._score,
    })),
    facets: {
      categories: esResponse.aggregations.categories.buckets.map(
        (b: any) => ({ key: b.key, count: b.doc_count })
      ),
      brands: esResponse.aggregations.brands.buckets.map(
        (b: any) => ({ key: b.key, count: b.doc_count })
      ),
      priceRanges: esResponse.aggregations.price_ranges.buckets.map(
        (b: any) => ({ label: b.key, count: b.doc_count })
      ),
    },
    pagination: {
      page,
      pageSize,
      total: hits.total.value,
      totalPages: Math.ceil(hits.total.value / pageSize),
    },
  };
}

Key Takeaways

Building effective full-text search requires understanding the indexing pipeline: analyzers tokenize and normalize text, mappings define field types and behaviors, and queries combine matching with filtering and boosting to produce relevant results.

Use separate index and search analyzers—edge n-grams at index time enable prefix matching without over-matching at search time. Bulk index in batches for performance. Build boolean queries that separate scoring (must) from filtering (filter) to leverage Elasticsearch's query cache.

Aggregations power faceted navigation, giving users the ability to filter by category, brand, price range, and other dimensions. These run alongside the main query in a single request, making the search experience responsive without additional round trips.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX