03 — Basic search

Elasticsearch was built for search, and this chapter is where that comes alive. You will learn how to express queries in the JSON-based Query DSL, understand the response envelope, and combine the fundamental building blocks — match, term, range, and bool — to answer real business questions against your data.


Goals for this chapter

By the end of this chapter you should be able to:

  1. Send a well-formed search request via Kibana Dev Tools and read every important field in the response.
  2. Explain the difference between full-text (text) fields and exact-match (keyword) fields and choose the right query type accordingly.
  3. Use match, term, terms, range, and bool queries on the shop-orders-demo dataset.
  4. Understand how Elasticsearch calculates a relevance score and why queries inside a filter clause have no score.
  5. Control which documents are returned, how many, and in what order.

3.1 Query DSL — request structure

The anatomy of a search request

Elasticsearch exposes its search functionality via a REST endpoint. The most important pattern you will use every day is:

GET /shop-orders-demo/_search

That alone is a valid request — it returns up to 10 documents with no filtering. But real searches carry a JSON body:

GET /shop-orders-demo/_search
{
  "query": {
    "match_all": {}
  }
}

match_all matches every document in the index. It is the simplest possible query and the right starting point when you want to explore the data.

You may occasionally see the shorthand URI search:

GET /shop-orders-demo/_search?q=order_status:delivered

This uses Lucene query syntax in the URL. It is fine for one-off exploration in the terminal, but it is not recommended for application code — it is harder to express complex logic, escaping is tricky, and it is easy to make mistakes. Use the JSON Query DSL for everything else.

Reading the response envelope

Run the match_all query above and look at the response carefully. Here is an annotated version:

{
  "took": 3,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": 1.0,
    "hits": [
      {
        "_index": "shop-orders-demo",
        "_id": "order-00001",
        "_score": 1.0,
        "_source": {
          "order_id": "order-00001",
          "order_status": "delivered",
          "total_amount": 1250.00,
          "customer": {
            "name": "Jana Nováková"
          }
        }
      }
    ]
  }
}
Field What it means
took How many milliseconds the search took on the server side (network latency not included).
_shards.total How many shards were queried.
_shards.successful How many responded successfully. If this is less than total, some results may be missing.
hits.total.value The count of matching documents.
hits.total.relation "eq" means the count is exact; "gte" means there are at least that many (Elasticsearch stops counting at 10 000 by default for performance).
hits.max_score The highest relevance score among all matched documents.
hits.hits[] The actual documents returned (up to size, default 10).
hits.hits[]._id The document’s unique identifier in this index.
hits.hits[]._score How relevant this document is to the query (higher is better).
hits.hits[]._source The original JSON you indexed, returned verbatim.

Tip: hits.total.value tells you how many documents matched, not how many were returned. The returned count is controlled by size — see section 3.8.


3.2 Full-text search vs exact match — the core concept

This is the single most important concept to internalize before writing queries.

Elasticsearch stores fields in two fundamentally different ways depending on their mapping type:

Field type What happens at index time What you use to query it
text The value is analyzed — broken into tokens, lowercased, stop words may be removed, etc. match, match_phrase
keyword The value is stored as-is, byte for byte. term, terms

Example with the dataset:

Why does this matter right now? Using the wrong query type for the field type is the most common beginner mistake. term on a text field almost always returns nothing (section 3.4 explains why in detail). Choosing correctly is straightforward once you know the field’s type.

The full story — how analyzers work, what tokenization does, and how to check your index mapping — is covered in Chapter 04 — Mapping and analyzers. For now, just remember the rule: textmatch; keywordterm.

In our dataset, fields like customer.name and notes are text. Fields like order_status, channel, payment_method, delivery.country_code, and order_id are keyword.


3.3 match query — full-text search on analyzed fields

Use match when searching fields that hold human-readable text and are mapped as text.

Basic example

Search for orders where the notes field mentions “urgent”:

GET /shop-orders-demo/_search
{
  "query": {
    "match": {
      "notes": "urgent"
    }
  }
}

Elasticsearch analyzes the query string the same way it analyzed the documents when they were indexed. So this will match notes containing “urgent”, “Urgent”, or “URGENT” — because both the stored tokens and the query token end up as urgent.

Multi-word queries and the OR default

If you provide multiple words, match applies OR logic by default:

GET /shop-orders-demo/_search
{
  "query": {
    "match": {
      "notes": "urgent delivery problem"
    }
  }
}

This returns documents where the notes field contains any of those three words. Documents containing all three will score higher, but documents containing only one will still appear.

Requiring all words with operator: and

To require that every word is present, set operator to and:

GET /shop-orders-demo/_search
{
  "query": {
    "match": {
      "notes": {
        "query": "urgent delivery",
        "operator": "and"
      }
    }
  }
}

Now only documents containing both “urgent” and “delivery” (in any order, anywhere in the field) are returned.

match_phrase — words must appear in order

When the order of words matters (for example, a customer’s full name), use match_phrase:

GET /shop-orders-demo/_search
{
  "query": {
    "match_phrase": {
      "customer.name": "Jana Nováková"
    }
  }
}

This requires both tokens to appear next to each other, in that order. "Nováková Jana" would not match.

Tip: customer.name is a text field, so match and match_phrase are the right tools. If you need an exact case-sensitive match on the full name as written, use customer.name.keyword (a sub-field) with a term query — covered in section 3.4.


3.4 term query — exact match on keyword, numeric, and boolean fields

Use term when you need an exact, case-sensitive match on a keyword, numeric, date, or boolean field.

Basic example

Find all orders with status delivered:

GET /shop-orders-demo/_search
{
  "query": {
    "term": {
      "order_status": "delivered"
    }
  }
}

The value "delivered" must match exactly. "Delivered" or "DELIVERED" would return zero results.

More examples

Find all orders placed via the web channel:

GET /shop-orders-demo/_search
{
  "query": {
    "term": {
      "channel": "web"
    }
  }
}

Find orders that have been returned (returned is a boolean field):

GET /shop-orders-demo/_search
{
  "query": {
    "term": {
      "returned": true
    }
  }
}

The term on a text field pitfall

Pitfall: If you run term on a text field, you will almost always get zero results — even if the value exists. Why? Because the stored tokens are lowercase (e.g., jana), but you are querying the original string (e.g., "Jana Nováková"). The comparison is exact, byte for byte, and they do not match. Always use match for text fields.

Matching multiple values with terms

When you want to match any one of several values, use the terms query (plural):

GET /shop-orders-demo/_search
{
  "query": {
    "terms": {
      "order_status": ["delivered", "shipped"]
    }
  }
}

This is equivalent to order_status = 'delivered' OR order_status = 'shipped' in SQL. You can include as many values as needed in the array.

Another example — orders paid by card or bank transfer:

GET /shop-orders-demo/_search
{
  "query": {
    "terms": {
      "payment_method": ["card", "bank_transfer"]
    }
  }
}

3.5 range query — numeric and date ranges

Use range when you want to filter by a numeric value or date interval.

Operators

Operator Meaning
gte greater than or equal to
gt greater than (strictly)
lte less than or equal to
lt less than (strictly)

Numeric range

Find orders where the total amount is between 500 and 2000 CZK:

GET /shop-orders-demo/_search
{
  "query": {
    "range": {
      "total_amount": {
        "gte": 500,
        "lte": 2000
      }
    }
  }
}

Find high-value orders (total over 5000):

GET /shop-orders-demo/_search
{
  "query": {
    "range": {
      "total_amount": {
        "gt": 5000
      }
    }
  }
}

Date range with date math

Elasticsearch supports date math — a compact syntax for expressing relative dates. The anchor now means “this instant”.

Expression Meaning
now Current timestamp
now-30d 30 days ago
now-1h 1 hour ago
now/d Start of today (rounds down)
2024-01-01 Fixed absolute date

Find orders created in the last 30 days:

GET /shop-orders-demo/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "now-30d",
        "lte": "now"
      }
    }
  }
}

Find orders created in 2024:

GET /shop-orders-demo/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "2024-01-01",
        "lt": "2025-01-01"
      }
    }
  }
}

Tip: When you use range on a date field inside a bool filter clause (see section 3.6), Elasticsearch can cache the result efficiently. This is the recommended pattern for time-based filtering — always prefer filter over must for ranges. The reason is explained in section 3.6.


3.6 bool query — combining conditions

The bool query is the workhorse of Elasticsearch. Almost every real-world query is a bool with multiple clauses. It lets you combine any number of conditions using four types of clauses:

Clause Documents must match? Affects _score? Use for
must Yes Yes Conditions that are required AND should influence relevance ranking
filter Yes No Conditions that are required but do NOT need to affect score — faster, cacheable
should No (by default) Yes Conditions that boost score when they match; optional
must_not Must NOT match No Exclusions

Scoring rule of thumb

Use filter for exact values, ranges, date windows, booleans, and any condition where you just want to narrow results down — Elasticsearch caches filter results and skips scoring, so it is faster.

Use must for full-text queries where the quality of the match should influence how results are ranked.

A realistic example

Find orders that: - Were shipped to the Czech Republic OR Slovakia (filter) - Have status delivered or shipped (filter) - Have a total amount above 1000 (filter) - Mention “poškozeno” (damaged) in the notes — and this should influence ranking (must) - Are NOT returned (must_not)

GET /shop-orders-demo/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "terms": {
            "delivery.country_code": ["CZ", "SK"]
          }
        },
        {
          "terms": {
            "order_status": ["delivered", "shipped"]
          }
        },
        {
          "range": {
            "total_amount": {
              "gte": 1000
            }
          }
        }
      ],
      "must": [
        {
          "match": {
            "notes": "poškozeno"
          }
        }
      ],
      "must_not": [
        {
          "term": {
            "returned": true
          }
        }
      ]
    }
  }
}

Documents that contain “poškozeno” more prominently or in a shorter notes field will score higher and appear first. Documents that merely pass the filter conditions contribute no score.

Using should — optional boosts

should clauses are optional by default: they are not required for a document to match, but if they do match, the document’s score is boosted. This is useful for relevance tuning — for example, preferring gold-tier customers:

GET /shop-orders-demo/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "order_status": "processing" } }
      ],
      "should": [
        { "term": { "customer.loyalty_tier": "gold" } },
        { "term": { "shipping_method": "express" } }
      ]
    }
  }
}

All processing orders are returned, but those belonging to gold-tier customers or using express shipping appear first.

minimum_should_match

By default, inside a bool that also has must or filter clauses, none of the should clauses need to match. If the bool has only should clauses, at least one must match.

You can override this with minimum_should_match:

GET /shop-orders-demo/_search
{
  "query": {
    "bool": {
      "should": [
        { "term": { "channel": "web" } },
        { "term": { "payment_method": "card" } },
        { "term": { "customer.loyalty_tier": "gold" } }
      ],
      "minimum_should_match": 2
    }
  }
}

This requires at least two of the three should conditions to match.

Tip: Think of must as SQL AND with scoring, filter as SQL AND without scoring, should as “nice to have / boost”, and must_not as SQL NOT. When in doubt, start with filter for constraints and must only for text fields where ranking matters.


3.7 Relevance and scoring

What is _score?

Every document in the hits.hits[] array has a _score. This is a floating-point number that represents how well the document matches the query. Results are sorted by _score descending by default — the most relevant document appears first.

When you search for “express delivery” in the notes field, a document that mentions it five times in a short note will score higher than a document that mentions it once in a long note. Elasticsearch uses an algorithm called BM25 to compute this.

BM25 at a glance

BM25 (Best Match 25) considers three things:

  1. Term frequency (TF): How often does the search term appear in this field? More occurrences → higher score.
  2. Inverse document frequency (IDF): How rare is this term across all documents in the index? Rare terms are more meaningful — they contribute more to the score. A term like “the” appears everywhere and contributes little; a term like “reklamace” (complaint) is more specific.
  3. Field length normalization: A term found in a short notes value is more significant than the same term buried in a very long paragraph.

You do not need to tune BM25 for now. The default works well. What matters is understanding that score reflects quality of match, not just presence or absence.

Filter context has no score

Queries placed inside a filter or must_not clause run in filter context — they evaluate to yes/no and contribute 0 to the score. This is both a performance optimization (Elasticsearch can cache filter results) and a feature: you do not want your exact-match conditions to distort ranking.

Queries inside must and should run in query context — they compute a score and influence ranking.

Debugging with explain

When you are puzzled about why a document scored the way it did, use explain: true on a single-document lookup:

GET /shop-orders-demo/_explain/order-00001
{
  "query": {
    "match": {
      "notes": "poškozeno"
    }
  }
}

The response shows the full calculation tree — TF, IDF, field length normalization — for that specific document and query. It is verbose, so use it as a learning or debugging tool, not in production queries.

Tip: For most CRUD-style lookups where you do not care about ranking (e.g., “give me all orders for customer X”), put all conditions in filter. You save both CPU and memory because scores are not computed and results are cached.


3.8 Results: hits, pagination, field selection, and sorting

Controlling how many documents are returned (size)

By default, Elasticsearch returns 10 documents. Use size to change this:

GET /shop-orders-demo/_search
{
  "size": 25,
  "query": {
    "term": { "order_status": "delivered" }
  }
}

Pagination with from and size

from sets the offset (zero-based). This is analogous to SQL’s LIMIT / OFFSET:

GET /shop-orders-demo/_search
{
  "from": 20,
  "size": 10,
  "query": {
    "term": { "order_status": "delivered" }
  }
}

This returns documents 21–30 (page 3 of 10).

Pitfall: deep pagination is expensive. Elasticsearch must collect and sort from + size documents on every shard and then merge them. A request with from: 10000, size: 10 is much more costly than from: 0, size: 10. For deep pagination (scrolling through thousands of results), use the search_after parameter instead — it is efficient because it uses the sort value of the last seen document as a cursor. search_after is covered in Chapter 05 — Advanced querying.

Selecting which fields are returned (_source)

By default, the full _source document is returned. For large documents or bandwidth-sensitive applications, limit what comes back:

GET /shop-orders-demo/_search
{
  "size": 5,
  "_source": ["order_id", "order_status", "total_amount", "customer.name"],
  "query": {
    "term": { "channel": "web" }
  }
}

You can also exclude fields while including everything else:

GET /shop-orders-demo/_search
{
  "_source": {
    "excludes": ["notes", "tags"]
  },
  "query": {
    "match_all": {}
  }
}

Sorting results

By default, results are sorted by _score descending. You can override this with explicit sort:

GET /shop-orders-demo/_search
{
  "size": 10,
  "sort": [
    { "created_at": "desc" },
    { "total_amount": "desc" }
  ],
  "query": {
    "term": { "order_status": "processing" }
  }
}

This returns the most recently created processing orders, breaking ties by highest total amount.

Important: You can only sort on fields that are not analyzed — meaning keyword, numeric (double, integer), date, and boolean fields. You cannot sort on a text field because the value has been broken into tokens and the original string is not directly sortable. If you need to sort by customer name, use customer.name.keyword (the un-analyzed sub-field). The full explanation of keyword sub-fields is in Chapter 04 — Mapping and analyzers.

Sorting and _score together

When you specify an explicit sort, _score is no longer computed by default (to save CPU). If you need both, include _score in your sort list:

GET /shop-orders-demo/_search
{
  "sort": [
    { "_score": "desc" },
    { "created_at": "desc" }
  ],
  "query": {
    "match": { "notes": "reklamace" }
  }
}

Labs

Lab 1 — Explore the response envelope

Goal: Become comfortable reading the Elasticsearch response before writing complex queries.

  1. Run GET /shop-orders-demo/_search with match_all and no other parameters.
  2. Note the values of took, hits.total.value, hits.total.relation, and hits.max_score.
  3. Expand one document in hits.hits[] and identify _id, _score, and _source.
  4. Add "size": 1 to the request and re-run. Observe that hits.total.value does not change — only the number of returned documents changes.
  5. Add "_source": ["order_id", "total_amount", "order_status"] and verify the response only includes those fields.

Takeaway: hits.total.value is always the total number of matching documents; hits.hits[] is only the current page.


Lab 2 — match vs term in practice

Goal: Observe the difference between full-text and exact-match queries.

  1. Run a term query on order_status with value "delivered" — note the result count.
  2. Run the same term query with "Delivered" (capital D) — observe zero results.
  3. Run a match query on customer.name with a partial first name (e.g., "Jana") — observe matching documents.
  4. Now try a term query on customer.name with "Jana Nováková" — observe that you likely get zero results (because the field is analyzed and stored as lowercase tokens).
  5. Switch to customer.name.keyword with term and provide the exact full name as stored — observe results.

Takeaway: text fields → match; keyword fields → term. Mixing them up is the most common beginner mistake.


Lab 3 — Build a bool query step by step

Goal: Practice combining multiple conditions into a single bool query.

Build a query that finds:

  1. Orders delivered to Germany (delivery.country_code: DE) — use filter

  2. With total amount over 2000 — use filter with range

  3. That are NOT cancelled — use must_not

  4. Where the notes mention “package” — use must with match

Start with just one filter clause, run it, check the count. Add the next clause, run again. Continue until you have the full query.

Then add a should clause that boosts orders with shipping_method: express and observe whether the ordering of results changes.

Takeaway: Build bool queries incrementally — it is much easier to debug one clause at a time.


Lab 4 — Pagination and sorting

Goal: Practice controlling result size, offset, and order.

  1. Write a query that returns delivered orders sorted by total_amount descending, page size 5.
  2. Add from: 5 to get the second page. Confirm the first document on page 2 has a lower total_amount than the last document on page 1.
  3. Add _source filtering to return only order_id, total_amount, customer.name, and delivery.city.
  4. Try setting from: 500 and note that the query still works but is noticeably slower — this is the deep pagination cost.

Takeaway: Use from/size for shallow pagination only. For large offsets, search_after (Chapter 05) is the right tool.


Summary