Elasticsearch #

Elasticsearch is a Lucene-based search engine designed for fast, relevant, horizontally scalable full-text search. If you’ve ever searched for a product on an online store and gotten relevant results even when your keyword wasn’t an exact match, Elasticsearch was likely behind it. In Ruby, there are three main ways to interact with Elasticsearch: the elasticsearch-ruby gem as a low-level client that follows the Elasticsearch API 1:1, the searchkick gem that simplifies Rails ActiveRecord integration, and elasticsearch-model, which provides Rails model integration with more control. This article covers all these layers, from basic concepts to the search patterns used in production applications.

Elasticsearch Basic Concepts #

Before writing code, it’s important to understand Elasticsearch terminology and how it differs from relational databases:

Relational Database     Elasticsearch
────────────────────────────────────────
Database            →   Index
Table               →   (removed in ES 7+, formerly "type")
Row                 →   Document
Column              →   Field
Schema              →   Mapping
Primary Key         →   _id (auto-generated or custom)
SELECT              →   Search Query
GROUP BY            →   Aggregation
flowchart LR
    A[Ruby App] --> B[elasticsearch-ruby Client]
    B --> C[Elasticsearch Node]
    C --> D[Index: products]
    D --> E["Shard 1\n(documents 1-500)"]
    D --> F["Shard 2\n(documents 501-1000)"]
    D --> G["Replica Shard\n(backup)"]

Installation and Connection #

# Install the gem
gem install elasticsearch

# Or in the Gemfile
# Gemfile
gem 'elasticsearch', '~> 8.0'   # make sure the version matches the Elasticsearch server

# For Rails with searchkick
gem 'searchkick', '~> 5.3'
require 'elasticsearch'

# Connect to local Elasticsearch
client = Elasticsearch::Client.new(
  host: "http://localhost:9200",
  log:  false   # set true to debug queries
)

# Connect with authentication (Elasticsearch 8+ with security enabled)
client = Elasticsearch::Client.new(
  host:     "https://localhost:9200",
  user:     "elastic",
  password: ENV["ES_PASSWORD"],
  ca_fingerprint: ENV["ES_CA_FINGERPRINT"]  # certificate fingerprint
)

# Connect to Elastic Cloud
client = Elasticsearch::Client.new(
  cloud_id: ENV["ELASTIC_CLOUD_ID"],
  api_key:  ENV["ELASTIC_API_KEY"]
)

# Connect with multiple nodes (for high availability)
client = Elasticsearch::Client.new(
  hosts: [
    { host: "es-node-1", port: 9200 },
    { host: "es-node-2", port: 9200 },
    { host: "es-node-3", port: 9200 }
  ],
  retry_on_failure: 3,
  reload_connections: true
)

# Check the connection
puts client.info.dig("version", "number")
puts client.ping ? "Connected!" : "Failed!"

Index Management #

Creating an Index with a Mapping #

A mapping is Elasticsearch’s schema — it defines the type of every field:

# Create an index with a mapping
client.indices.create(
  index: "products",
  body: {
    settings: {
      number_of_shards:   1,
      number_of_replicas: 0,   # 0 for development, 1+ for production
      analysis: {
        analyzer: {
          # Custom analyzer for Indonesian
          indonesian_analyzer: {
            type:      "custom",
            tokenizer: "standard",
            filter:    ["lowercase", "stop", "asciifolding"]
          }
        }
      }
    },
    mappings: {
      properties: {
        name: {
          type:     "text",
          analyzer: "indonesian_analyzer",
          fields: {
            keyword: { type: "keyword" }  # for exact match and sort
          }
        },
        description: {
          type:     "text",
          analyzer: "indonesian_analyzer"
        },
        price:      { type: "double" },
        stock:      { type: "integer" },
        active:     { type: "boolean" },
        category:   { type: "keyword" },   # keyword = exact match, not analyzed
        tags:       { type: "keyword" },   # keyword array
        created_at: { type: "date" },
        location: {
          type: "geo_point"   # for geospatial search
        },
        specs: {
          type: "object",   # nested object
          properties: {
            processor: { type: "keyword" },
            ram:       { type: "keyword" },
            storage:   { type: "keyword" }
          }
        }
      }
    }
  }
)

# Check whether the index exists
puts client.indices.exists?(index: "products")

# Delete the index
client.indices.delete(index: "products")

# Alias — enables zero-downtime reindexing
client.indices.put_alias(index: "products_v2", name: "products")

Document CRUD #

# INDEX (insert/replace) a single document
client.index(
  index: "products",
  id:    "prod-001",   # optional — ES will generate one if absent
  body: {
    name:      "Pro Gaming Laptop 15",
    description: "A high-powered laptop for gaming and content creators",
    price:     22_000_000,
    stock:     10,
    active:    true,
    category:  "Electronics",
    tags:      ["laptop", "gaming", "high-performance"],
    specs: {
      processor: "Intel Core i9-13900H",
      ram:       "32GB DDR5",
      storage:   "1TB NVMe SSD"
    },
    created_at: Time.now.iso8601
  }
)

# GET a single document by ID
doc = client.get(index: "products", id: "prod-001")
puts doc["_source"]["name"]  # => "Pro Gaming Laptop 15"
puts doc["_id"]              # => "prod-001"

# Check whether a document exists
puts client.exists?(index: "products", id: "prod-001")

# UPDATE — partial update (only the mentioned fields)
client.update(
  index: "products",
  id:    "prod-001",
  body: {
    doc: {
      price:  21_500_000,
      stock:  9,
      updated_at: Time.now.iso8601
    }
  }
)

# UPDATE with a script (for operations like incrementing)
client.update(
  index: "products",
  id:    "prod-001",
  body: {
    script: {
      source: "ctx._source.stock -= params.amount",
      params: { amount: 1 }
    }
  }
)

# DELETE
client.delete(index: "products", id: "prod-001")

# INDEX many documents at once with the Bulk API
# (far more efficient than inserting one by one)
body = []
product_list.each do |p|
  body << { index: { _index: "products", _id: p[:id] } }
  body << {
    name:      p[:name],
    price:     p[:price],
    stock:     p[:stock],
    category:  p[:category],
    active:    p[:active],
    created_at: Time.now.iso8601
  }
end

response = client.bulk(body: body)
puts "Errors: #{response['errors']}"
puts "Items processed: #{response['items'].length}"

Search — Query DSL #

Elasticsearch’s Query DSL is a way to express queries as Ruby Hashes converted to JSON:

# match — analyzes the query and documents, suitable for full-text search
response = client.search(
  index: "products",
  body: {
    query: {
      match: {
        name: {
          query:    "laptop gaming",
          operator: "and"   # all words must be present (default: "or")
        }
      }
    }
  }
)

# Iterate the results
hits = response["hits"]["hits"]
hits.each do |hit|
  puts "#{hit['_score'].round(2)}: #{hit['_source']['name']}"
end

puts "Total: #{response.dig('hits', 'total', 'value')} documents"

# multi_match — search multiple fields at once
response = client.search(
  index: "products",
  body: {
    query: {
      multi_match: {
        query:  "cheap gaming laptop",
        fields: ["name^3", "description", "tags"],  # ^3 = name is 3x more important
        type:   "best_fields"
      }
    }
  }
)

term and terms — Exact Match #

# term — exact match, not analyzed (for keyword fields)
response = client.search(
  index: "products",
  body: {
    query: {
      term: { category: "Electronics" }
    }
  }
)

# terms — match any of an array of values
response = client.search(
  index: "products",
  body: {
    query: {
      terms: { category: ["Electronics", "Accessories", "Monitors"] }
    }
  }
)

bool — Combining Queries #

bool is the most commonly used query in production because it allows combining conditions:

# bool query — combine must, should, must_not, filter
response = client.search(
  index: "products",
  body: {
    query: {
      bool: {
        # must — MUST match, affects the relevance score
        must: [
          { match: { name: "laptop" } }
        ],
        # filter — MUST match, does NOT affect the score (faster, cached)
        filter: [
          { term:  { active: true } },
          { range: { price: { gte: 5_000_000, lte: 25_000_000 } } },
          { term:  { "tags" => "gaming" } }
        ],
        # should — MAY match, boosts the score if it matches
        should: [
          { term:  { category: "Electronics" } },
          { match: { description: "high performance" } }
        ],
        minimum_should_match: 1,  # at least 1 should must match
        # must_not — MUST NOT match
        must_not: [
          { term: { stock: 0 } }
        ]
      }
    }
  }
)

range, wildcard, prefix, fuzzy #

# range — value or date range search
client.search(
  index: "products",
  body: {
    query: {
      range: {
        price: { gte: 1_000_000, lte: 10_000_000 }
      }
    }
  }
)

# Date ranges
client.search(
  index: "products",
  body: {
    query: {
      range: {
        created_at: {
          gte: "2024-01-01",
          lte: "2024-12-31",
          format: "yyyy-MM-dd"
        }
      }
    }
  }
)

# wildcard — match a pattern (* = many characters, ? = one character)
client.search(
  index: "products",
  body: { query: { wildcard: { "name.keyword" => "Laptop*Pro*" } } }
)

# prefix — match a beginning
client.search(
  index: "products",
  body: { query: { prefix: { "name.keyword" => "Laptop" } } }
)

# fuzzy — tolerant of typos (edit distance)
client.search(
  index: "products",
  body: {
    query: {
      fuzzy: {
        name: { value: "laptoop", fuzziness: "AUTO" }
      }
    }
  }
)

Sorting, Pagination, and Source Filtering #

# Sort
client.search(
  index: "products",
  body: {
    query: { term: { active: true } },
    sort: [
      { price: { order: "asc" } },   # cheapest first
      { "name.keyword": { order: "asc" } }   # then name A-Z
    ]
  }
)

# Pagination with from/size (max from: 10,000)
page = 2
per_page = 20
client.search(
  index: "products",
  body: {
    query: { match_all: {} },
    from:  (page - 1) * per_page,
    size:  per_page
  }
)

# search_after — for deep pagination (> 10,000 documents)
# First: fetch the first page with sort + PIT (Point in Time)
pit = client.open_point_in_time(index: "products", keep_alive: "1m")
pit_id = pit["id"]

response = client.search(
  body: {
    size: 20,
    query: { match_all: {} },
    sort: [{ price: "asc" }, { _id: "asc" }],
    pit: { id: pit_id, keep_alive: "1m" }
  }
)

# Next: use the sort values from the last hit
last_hit    = response["hits"]["hits"].last
sort_values = last_hit["sort"]

response_2 = client.search(
  body: {
    size: 20,
    query: { match_all: {} },
    sort: [{ price: "asc" }, { _id: "asc" }],
    pit: { id: pit_id, keep_alive: "1m" },
    search_after: sort_values
  }
)

# Close the PIT when done
client.close_point_in_time(body: { id: pit_id })

# Source filtering — fetch only certain fields
client.search(
  index: "products",
  body: {
    query: { match_all: {} },
    _source: ["name", "price", "stock"]   # only fetch 3 fields
  }
)

# Or exclude certain fields
client.search(
  index: "products",
  body: {
    query: { match_all: {} },
    _source: { excludes: ["description", "specs"] }
  }
)

Highlight — Highlighting Matched Text #

response = client.search(
  index: "products",
  body: {
    query: { match: { description: "gaming high performance" } },
    highlight: {
      fields: {
        name:        { number_of_fragments: 0 },  # highlight the whole field
        description: {
          number_of_fragments: 3,
          fragment_size: 150,
          pre_tags:  ["<mark>"],
          post_tags: ["</mark>"]
        }
      }
    }
  }
)

response["hits"]["hits"].each do |hit|
  puts hit["_source"]["name"]
  puts hit.dig("highlight", "description")&.join("... ")
end
# Output: "Laptop for <mark>gaming</mark> with <mark>high performance</mark>"

Aggregations — Data Analysis #

Aggregations are Elasticsearch’s way to compute statistics and build faceted search:

response = client.search(
  index: "products",
  body: {
    query: { term: { active: true } },
    size: 0,   # 0 = only aggregations, no documents needed

    aggs: {
      # Count documents per category
      per_category: {
        terms: { field: "category", size: 20 }
      },

      # Price statistics
      price_stats: {
        stats: { field: "price" }
        # Produces: count, min, max, avg, sum
      },

      # Price ranges for faceted filters
      price_ranges: {
        range: {
          field: "price",
          ranges: [
            { to:   1_000_000,              key: "Under 1 Million" },
            { from: 1_000_000, to: 5_000_000, key: "1-5 Million" },
            { from: 5_000_000, to: 15_000_000, key: "5-15 Million" },
            { from: 15_000_000,             key: "Above 15 Million" }
          ]
        }
      },

      # Price histogram per 1 million
      price_histogram: {
        histogram: {
          field:    "price",
          interval: 1_000_000,
          min_doc_count: 1
        }
      },

      # Top tags
      top_tags: {
        terms: { field: "tags", size: 10 }
      },

      # Nested aggregation — average price per category
      per_category_with_avg_price: {
        terms: { field: "category", size: 20 },
        aggs: {
          avg_price: { avg: { field: "price" } },
          total_stock: { sum: { field: "stock" } }
        }
      }
    }
  }
)

# Access the aggregation results
puts "Number of products per category:"
response.dig("aggregations", "per_category", "buckets").each do |bucket|
  puts "  #{bucket['key']}: #{bucket['doc_count']} products"
end

stats = response.dig("aggregations", "price_stats")
puts "Price: min=#{stats['min']}, max=#{stats['max']}, avg=#{stats['avg'].round}"

Suggest — Autocomplete and Typo Correction #

# Completion suggester — fast autocomplete
# First, define the suggest field in the mapping
client.indices.put_mapping(
  index: "products",
  body: {
    properties: {
      name_suggest: {
        type: "completion",
        analyzer: "standard"
      }
    }
  }
)

# Index a document with the suggest field
client.index(
  index: "products",
  id:    "prod-001",
  body: {
    name:         "Pro Gaming Laptop",
    name_suggest: {
      input:  ["Pro Gaming Laptop", "Gaming Laptop", "Laptop"],
      weight: 10   # relevance weight
    }
  }
)

# Autocomplete query
response = client.search(
  index: "products",
  body: {
    suggest: {
      product_autocomplete: {
        prefix: "lap",    # text the user typed
        completion: {
          field: "name_suggest",
          size:  5,        # maximum of 5 suggestions
          skip_duplicates: true
        }
      }
    }
  }
)

suggestions = response.dig("suggest", "product_autocomplete", 0, "options")
suggestions.each { |s| puts s["text"] }
# => "Laptop", "Gaming Laptop", "Pro Gaming Laptop"

# Term suggester — typo correction ("laptoop" → "laptop")
response = client.search(
  index: "products",
  body: {
    suggest: {
      word_correction: {
        text: "laptoop gamng",
        term: {
          field: "name",
          suggest_mode: "missing"  # only suggest if the word is absent from the index
        }
      }
    }
  }
)

Searchkick — Easy Rails Integration #

searchkick simplifies Elasticsearch integration with ActiveRecord models:

# Gemfile
# gem 'searchkick', '~> 5.3'

# app/models/product.rb
class Product < ApplicationRecord
  searchkick(
    word_start:  [:name],              # per-word autocomplete
    text_start:  [:name],              # start-of-text autocomplete
    language:    "english",            # language analyzer
    index_name:  "products_#{Rails.env}",
    settings: {
      number_of_shards:   1,
      number_of_replicas: Rails.env.production? ? 1 : 0
    }
  )

  scope :search_import, -> { includes(:category).where(active: true) }

  # Customize the indexed data
  def search_data
    {
      name:        name,
      description: description,
      price:       price,
      stock:       stock,
      active:      active,
      category:    category&.name,
      tags:        tags,
      created_at:  created_at
    }
  end
end
# Indexing
Product.reindex          # reindex all products
Product.reindex(async: true)  # asynchronously (background job)

# Basic search
results = Product.search("laptop gaming")
results.each { |p| puts p.name }
puts "Total: #{results.total_count}"

# Search with filters, sort, and pagination
results = Product.search(
  "laptop",
  where: {
    active:   true,
    price:    { gte: 5_000_000, lte: 25_000_000 },
    category: ["Electronics", "Accessories"]
  },
  order: { price: :asc },
  page:    params[:page] || 1,
  per_page: 20,
  includes: [:category]   # eager load relations
)

# Aggregations (facets)
results = Product.search(
  "laptop",
  aggs: [:category, :tags],
  where: { active: true }
)

# Access facets
puts results.aggs.dig("category", "buckets")

# Highlight
results = Product.search(
  "laptop gaming",
  highlight: { fields: [:name, :description] }
)
results.with_highlights.each do |product, highlight|
  puts highlight[:name] || product.name
end

# Autocomplete / Suggest
Product.search("lap", fields: ["name^5", "description"], match: :word_start, limit: 5)

# Automatic sync when data changes
product = Product.find(1)
product.update!(price: 20_000_000)   # automatically updates the ES index
product.destroy                      # automatically removed from ES

# Or disable auto-sync and do it manually
Product.searchkick_callbacks(false) do
  Product.update_all(price: 15_000_000)  # doesn't trigger ES updates
end
Product.reindex   # manual sync after a bulk update

Data Synchronization — Strategies #

# Strategy 1: Automatic sync (searchkick default)
# Every save/update/destroy → updates ES synchronously
# Suitable for small applications, but can slow down HTTP requests

# Strategy 2: Async with a background job
# config/initializers/searchkick.rb
Searchkick.callbacks = :async   # use ActiveJob

# Strategy 3: Scheduled batch reindex
# config/schedule.rb (with the whenever gem)
every 1.hour do
  runner "Product.reindex"
end

# Strategy 4: Delta sync — only sync what changed
# app/models/product.rb
after_commit :reindex, if: :saved_change_to_relevant_field?

def saved_change_to_relevant_field?
  saved_changes.keys.any? { |k| %w[name description price stock active].include?(k) }
end

Zero-Downtime Reindexing #

When you need to change a mapping or reindex all data without downtime:

# Searchkick handles this automatically with aliases
Product.reindex

# Behind the scenes:
# 1. Create a new index: products_20240815120000
# 2. Index all data into the new index
# 3. Swap the "products" alias to the new index
# 4. Delete the old index

# Manual with elasticsearch-ruby:
client = client

# 1. Create the new index
client.indices.create(index: "products_v2", body: new_mapping)

# 2. Reindex from the old index to the new one
client.reindex(
  body: {
    source: { index: "products_v1" },
    dest:   { index: "products_v2" }
  },
  wait_for_completion: false  # async for large datasets
)

# 3. Monitor progress
# client.tasks.get(task_id: task_id)

# 4. Swap the alias
client.indices.update_aliases(
  body: {
    actions: [
      { remove: { index: "products_v1", alias: "products" } },
      { add:    { index: "products_v2", alias: "products" } }
    ]
  }
)

# 5. Delete the old index
client.indices.delete(index: "products_v1")

Summary #

  • Elasticsearch for search, the database as the source of truth — keep master data in PostgreSQL/MySQL, index it in Elasticsearch for search; always sync when data changes.
  • filter is faster than must — conditions that don’t affect the relevance score (active = true, category filters) go into filter because they’re cached and not scored.
  • Use .keyword for sorting and exact matchtext fields are analyzed (can’t be sorted); define fields.keyword: { type: "keyword" } in the mapping for both needs.
  • The Bulk API for mass indexingclient.bulk is far more efficient than indexing one by one; use batch sizes of 500-1000 documents per request.
  • size: 0 for pure aggregations — if you only need statistics or facets, set size: 0 so ES doesn’t return unneeded documents.
  • search_after not from for deep paginationfrom has a 10,000 document limit by default; search_after can paginate millions of documents without limits.
  • Indonesian language analyzers — use a standard analyzer with lowercase and asciifolding filters to handle Indonesian letter variations.
  • Zero-downtime reindexing with aliases — never delete and recreate an index that’s in use; always create a new index, fill it, then swap the alias.
  • Searchkick for Rails — simplifies 90% of Elasticsearch use cases in Rails; use the raw client only when you need very specific query control.
  • Monitoring with the _cat APIclient.cat.indices(v: true), client.cat.health, and client.cat.shards to monitor the health of the Elasticsearch cluster.

← Previous: MongoDB   Next: Kafka →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact