OriginChainDB docs
elasticsearch · by industry

Worked examples by industry

Four models, each starting with the mapping — because the mapping decides what you can ask later — then the queries that pay for it: a product catalog with facets, an application log stream, a payment ledger and a support ticket desk.

Retail: a product catalog with facets

A search box over product text, plus the filters down the side of the page: brand, price band, availability. The trap here is wanting to search a name and group by it, which needs both views of the same field.

The mapping

Name and description are prose, so they are text. Brand and colour are picked from a list, so they are keyword. Name also carries a .keyword twin so it can be grouped exactly.

mappings: { properties: {
  name:        { type: 'text', fields: { keyword: { type: 'keyword' } } },
  description: { type: 'text', analyzer: 'english' },
  brand:       { type: 'keyword' },
  colour:      { type: 'keyword' },
  price:       { type: 'long' },
  in_stock:    { type: 'boolean' },
  added:       { type: 'date' }
} }

Search plus the facet counts, in one request

The search ranks by text relevance while the filters narrow it, and the aggregations count what is left so the sidebar numbers always match the results.

await es.search({
  index: 'shop.products', size: 20,
  query: { bool: {
    must:   [{ match: { description: 'waterproof running' } }],
    filter: [{ term: { in_stock: true } }]
  } },
  aggs: {
    by_brand:  { terms: { field: 'brand' } },
    by_colour: { terms: { field: 'colour' } },
    avg_price: { avg: { field: 'price' } }
  }
})

Price bands, and a sample product per band

Bands are not a field, they are a question, so they are named filters. Send this one on its own: a filters aggregation is answered with one search per filter.

aggs: { bands: {
  filters: { other_bucket_key: 'mid', filters: {
    budget:  { range: { price: { lt: 50 } } },
    premium: { range: { price: { gte: 200 } } }
  } },
  aggs: { example: { top_hits: { size: 1, _source: ['name','price'] } } }
} }
when a shopper mistypes

Run the search and a suggester in the same request. If the search returns nothing, show the correction instead of an empty page.

Software: an application log stream

High write volume, mostly time-ranged reads, and one question repeated all day: what broke, where, and when did it start.

The mapping

The message is searched, so text. Service, level and host are grouped and filtered, so keyword. The timestamp drives every chart, so it is a date — an ISO string or epoch milliseconds, either works.

mappings: { properties: {
  message:   { type: 'text' },
  service:   { type: 'keyword' },
  level:     { type: 'keyword' },
  host:      { type: 'keyword' },
  latency_ms:{ type: 'long' },
  ts:        { type: 'date' }
} }

Errors per hour for one service

A date histogram over the filtered set. Buckets are UTC and calendar-aligned.

await es.search({
  index: 'ops.logs', size: 0,
  query: { bool: { filter: [
    { term: { service: 'checkout' } },
    { term: { level: 'error' } },
    { range: { ts: { gte: '2026-09-05T00:00:00Z' } } }
  ] } },
  aggs: { per_hour: { date_histogram: {
    field: 'ts', calendar_interval: 'hour', time_zone: 'UTC'
  } } }
})

The worst offenders, with an example line each

Group by service, then pull the slowest request in each group so the chart has something to click into.

aggs: { by_service: { terms: { field: 'service' },
  aggs: {
    p95:  { percentiles: { field: 'latency_ms' } },
    worst:{ top_hits: { size: 1, sort: [{ latency_ms: 'desc' }],
            _source: ['message','host','ts'] } }
  } } }
ingest shape

Send logs with _bulk in batches of a few hundred to a few thousand, and check errors on the response — a failed item inside a successful request is the classic way to lose data quietly. See bulk ingest.

Financial services: a payment ledger

Amounts and dates you summarise, counterparties you look up exactly, and a free-text reference people actually search. The distinguishing requirement is that not everyone may see every row.

The mapping

The reference is prose. Everything you group or compare is keyword, long or date. Money is stored in minor units as an integer, so totals never drift.

mappings: { properties: {
  reference:   { type: 'text' },
  counterparty:{ type: 'keyword' },
  status:      { type: 'keyword' },
  currency:    { type: 'keyword' },
  amount_minor:{ type: 'long' },   // 12345 = 123.45
  booked_at:   { type: 'date' }
} }

Daily totals by currency

A date histogram with a nested terms aggregation and a sum, which is one request rather than a report job.

await es.search({
  index: 'fin.payments', size: 0,
  query: { bool: { filter: [{ term: { status: 'settled' } }] } },
  aggs: { per_day: {
    date_histogram: { field: 'booked_at', calendar_interval: 'day' },
    aggs: { by_ccy: { terms: { field: 'currency' },
      aggs: { total: { sum: { field: 'amount_minor' } } } } }
  } }
})
who can see what is not your query's problem

Row-level security and column masking are enforced on the search itself, so a caller restricted to one desk finds only that desk's rows — through any query, including aggregations. A search against a masked column is refused rather than answered, because a match set could otherwise be used to reconstruct the value the mask hides. You do not add a filter for this, and you cannot forget to.

Support: a ticket desk

Agents search what customers wrote, managers want queue health, and both want it in the same shape.

The mapping

Subject and body are searched. Queue, status and priority are grouped. First response time is a number you average.

mappings: { properties: {
  subject:   { type: 'text' },
  body:      { type: 'text', analyzer: 'english' },
  queue:     { type: 'keyword' },
  status:    { type: 'keyword' },
  priority:  { type: 'keyword' },
  first_reply_mins: { type: 'long' },
  opened_at: { type: 'date' }
} }

Open tickets mentioning a problem, with queue health beside them

One request answers the agent's search and the manager's dashboard, over the same match set.

await es.search({
  index: 'support.tickets', size: 20,
  query: { bool: {
    must:   [{ match: { body: 'refund not received' } }],
    filter: [{ term: { status: 'open' } }]
  } },
  aggs: { by_queue: { terms: { field: 'queue' },
    aggs: { avg_first_reply: { avg: { field: 'first_reply_mins' } } } } }
})

Two agents editing one ticket

Read the ticket, then write it back conditionally. If someone else saved first, you get a conflict instead of silently overwriting their edit.

const cur = await es.get({ index: 'support.tickets', id })
await es.bulk({ operations: [
  { index: { _index: 'support.tickets', _id: id,
             if_seq_no: cur._version - 1, if_primary_term: 1 } },
  { ...cur._source, status: 'resolved' }
]})

// someone else saved first -> items[0].index.status === 409

What these four have in common

  • The mapping is the design. Every question you can ask later is decided by which fields are text and which are keyword. See indexing.
  • Search and summary in one request. Hits and aggregations come from the same match set, so a dashboard cannot disagree with the list beneath it.
  • No copy to keep in sync. These indexes are your data, not a projection of it, so a write is searchable when it commits.

Elasticsearch is a trademark of Elasticsearch B.V., registered in the U.S. and in other countries. OriginChainDB is not affiliated with, endorsed by, or sponsored by Elasticsearch B.V. OriginChainDB implements a compatible HTTP API so that existing Elasticsearch clients can talk to it; it does not distribute Elasticsearch software.