# Search recipes — query DSL for app developers
The summary is in `SKILL.md` (§ Build a search feature). This file owns the recipes — copy-paste DSL for every common search pattern.
## Index design 101
Always define a mapping before first ingest. Dynamic mapping creates bloated `text` + `keyword` multi-fields you'll regret.
### Standard mapping for a search-driven app
```json
PUT my-app
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"default": { "type": "standard" },
"english_with_synonyms": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop", "english_stemmer", "synonyms_filter"]
}
},
"filter": {
"english_stemmer": { "type": "stemmer", "language": "english" },
"synonyms_filter": {
"type": "synonym",
"synonyms": [
"tv, television",
"couch, sofa, settee"
]
}
}
}
},
"mappings": {
"properties": {
"id": { "type": "keyword" },
"title": { "type": "text", "analyzer": "english_with_synonyms", "fields": { "keyword": { "type": "keyword" }, "completion": { "type": "search_as_you_type" } } },
"description": { "type": "text", "analyzer": "english_with_synonyms" },
"tags": { "type": "keyword" },
"category": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"in_stock": { "type": "boolean" },
"released_at": { "type": "date" },
"rating": { "type": "half_float" }
}
}
}
```
Key choices:
- `text` for fields you search; `keyword` for facets/sort/exact-match
- Multi-fields `"title": {"type":"text", "fields": {"keyword": {"type":"keyword"}}}` to support both
- `search_as_you_type` for autocomplete
- `scaled_float` for currency (better than `float` for known precision)
- Avoid `nested` unless you actually need it — it's expensive
## Full-text search
### Single-field match
```json
GET my-app/_search
{
"query": { "match": { "title": "wireless headphones" } }
}
```
### Multi-field with field boosting
```json
GET my-app/_search
{
"query": {
"multi_match": {
"query": "wireless headphones",
"type": "best_fields",
"fields": ["title^3", "description^1", "tags^2"]
}
}
}
```
`type` options:
- `best_fields` (default) — score = highest single-field score (good for unique-content queries)
- `most_fields` — score = sum of all matching fields (good when same content in multiple fields)
- `cross_fields` — treats fields as one big field (good for entity searches like "first_name last_name")
- `phrase` — must match as phrase
- `phrase_prefix` — phrase + last token can be a prefix
### Boolean (combine queries)
```json
GET my-app/_search
{
"query": {
"bool": {
"must": [{ "match": { "title": "headphones" } }],
"should": [{ "match": { "tags": "noise-cancelling" } }],
"filter": [{ "term": { "in_stock": true } }, { "range": { "price": { "lte": 200 } } }],
"must_not": [{ "term": { "category": "discontinued" } }]
}
}
}
```
`filter` doesn't affect score and is cached — use for non-relevance constraints (in-stock, price range, category).
### Phrase queries
```json
{ "match_phrase": { "title": { "query": "machine learning", "slop": 1 } } }
```
`slop=N` allows N word movements within the phrase.
### Operator override (Solr `q.op=AND` equivalent)
OpenSearch defaults to OR. To replicate Solr's `q.op=AND`:
```json
{
"query": {
"match": {
"title": {
"query": "wireless headphones bluetooth",
"operator": "AND"
}
}
}
}
```
Or for `query_string`:
```json
{ "query_string": { "query": "wireless AND headphones", "default_operator": "AND" } }
```
This is the **most common cause of result divergence** when migrating from Solr.
## Faceted search (aggregations)
```json
GET my-app/_search
{
"size": 20,
"query": { "match": { "title": "headphones" } },
"aggs": {
"by_category": { "terms": { "field": "category", "size": 10 } },
"by_brand": { "terms": { "field": "tags", "size": 10 } },
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 200 },
{ "from": 200 }
]
}
},
"avg_rating": { "avg": { "field": "rating" } }
}
}
```
**Multi-select facets** (a user clicked one filter but should still see counts for other facets):
- Use `post_filter` for the clicked facet so other aggs still see all matches
- Use a `filter` aggregation per facet to apply ALL filters EXCEPT this one
## Autocomplete / search-as-you-type
### Option 1: `search_as_you_type` field
```json
{
"query": {
"multi_match": {
"query": "wirele",
"type": "bool_prefix",
"fields": ["title.completion", "title.completion._2gram", "title.completion._3gram"]
}
}
}
```
Best for general autocomplete on existing fields.
### Option 2: completion suggester
```json
PUT my-app/_doc/1
{
"title": "Sony WH-1000XM5 Wireless Headphones",
"title_completion": {
"input": ["Sony WH-1000XM5", "Sony Wireless Headphones", "Noise Cancelling"]
}
}
POST my-app/_search
{
"suggest": {
"title_suggest": {
"prefix": "wirele",
"completion": { "field": "title_completion", "size": 5 }
}
}
}
```
Best for product/entity name autocomplete with curated alternatives.
### Option 3: edge_ngram (legacy / rarely needed)
For non-native scripts where prefix matters character-by-character.
## Spell correction (Did You Mean)
```json
{
"suggest": {
"spell_check": {
"text": "wirless headfones",
"phrase": {
"field": "title",
"size": 1,
"gram_size": 3,
"direct_generator": [{
"field": "title",
"suggest_mode": "always"
}],
"highlight": { "pre_tag": "", "post_tag": "" }
}
}
}
}
```
## Fuzzy search (typo tolerance)
```json
{
"query": {
"match": {
"title": {
"query": "wireles",
"fuzziness": "AUTO"
}
}
}
}
```
`fuzziness: AUTO` (recommended): 0 edits for ≤2 char terms, 1 edit for 3–5 chars, 2 edits for ≥6 chars.
## "More like this" / similar items
```json
{
"query": {
"more_like_this": {
"fields": ["title", "description"],
"like": [{ "_index": "my-app", "_id": "12345" }],
"min_term_freq": 1,
"max_query_terms": 12
}
}
}
```
## Function score (custom relevance)
Boost recent items, popular items, in-stock items:
```json
{
"query": {
"function_score": {
"query": { "match": { "title": "headphones" } },
"functions": [
{
"filter": { "term": { "in_stock": true } },
"weight": 1.5
},
{
"field_value_factor": {
"field": "rating",
"factor": 0.5,
"modifier": "log1p",
"missing": 0
}
},
{
"gauss": {
"released_at": {
"origin": "now",
"scale": "30d",
"decay": 0.5
}
}
}
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}
```
## Sorting
```json
{
"query": { "match": { "title": "headphones" } },
"sort": [
{ "rating": { "order": "desc" } },
{ "price": { "order": "asc" } },
"_score"
]
}
```
To sort on a `text` field, sort on its `.keyword` subfield.
## Highlighting
```json
{
"query": { "match": { "title": "headphones" } },
"highlight": {
"fields": {
"title": { "pre_tags": [""], "post_tags": [""] },
"description": { "fragment_size": 150, "number_of_fragments": 3 }
}
}
}
```
## Pagination
### Standard `from`/`size` (works up to ~10K results)
```json
{ "from": 100, "size": 20, "query": { "match_all": {} } }
```
### `search_after` for deep pagination
```json
{
"size": 20,
"query": { "match_all": {} },
"sort": [{ "_id": "asc" }],
"search_after": ["last_doc_id_from_previous_page"]
}
```
### `point_in_time` (PIT) for consistent paging across long sessions
```bash
POST my-app/_search/point_in_time?keep_alive=1m
# returns "pit_id"
POST _search
{
"size": 20,
"pit": { "id": "", "keep_alive": "1m" },
"sort": [{ "_id": "asc" }],
"search_after": [...]
}
```
## Synonyms
### Index-time synonyms (slower indexing, faster queries, larger index)
Define in mapping `analysis.filter.synonyms_filter` and apply analyzer to text fields.
### Search-time synonyms (faster indexing, slower queries, smaller index)
```json
{
"settings": {
"analysis": {
"filter": {
"search_synonyms": {
"type": "synonym_graph",
"synonyms": ["tv, television", "couch, sofa"]
}
},
"analyzer": {
"search_with_synonyms": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "search_synonyms"]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"search_analyzer": "search_with_synonyms"
}
}
}
}
```
**Recommendation**: Start with search-time synonyms — easier to update without reindexing.
## Boost recent items in relevance
Use `function_score` with `gauss` decay (above) — natural log decay over time.
## Geo search
```json
{
"query": {
"bool": {
"must": { "match": { "name": "coffee" } },
"filter": {
"geo_distance": {
"distance": "5km",
"location": { "lat": 47.6062, "lon": -122.3321 }
}
}
}
}
}
```
Field type: `geo_point` or `geo_shape`.
## Solr → OpenSearch query translation reference
| Solr | OpenSearch DSL |
|---|---|
| `q=headphones` | `{"multi_match": {"query": "headphones", "fields": ["title", "description"]}}` (no `_all` in OpenSearch — list fields explicitly) |
| `q=title:headphones` | `{"match": {"title": "headphones"}}` |
| `q.op=AND` | `"default_operator": "AND"` on `query_string` OR `"operator": "AND"` on `match` |
| `qf=title^3 description` (eDisMax) | `multi_match` `type: best_fields` with `fields: ["title^3", "description"]` |
| `pf=title^5` (phrase boost) | `should` clause with `multi_match type:phrase` (approximation only) |
| `tie=0.3` (eDisMax) | `tie_breaker: 0.3` on `multi_match` `type: best_fields` |
| `mm=2<-25%` | `minimum_should_match: "2<-25%"` (passes UNCHANGED) |
| `fq=in_stock:true` | `filter` clause in `bool` query: `{"term": {"in_stock": true}}` |
| `sort=rating desc, price asc` | `sort: [{"rating": "desc"}, {"price": "asc"}]` |
| `start=20&rows=20` | `from: 20, size: 20` |
| `facet=true&facet.field=category` | `aggs: {"by_category": {"terms": {"field": "category"}}}` |
| `hl=true&hl.fl=title` | `highlight: {"fields": {"title": {}}}` |
| `mlt=true` | `more_like_this` query |
| `bf=recip(...)` (boost function) | `function_score` with `field_value_factor` |
| `defType=edismax` | `multi_match` (closest equivalent) |
| `wt=json` | `Accept: application/json` header (OpenSearch defaults JSON) |
## Common gotchas
1. **Dynamic mapping** — first doc creates field types. A field like `"id": "12345"` becomes `text` (not `keyword`) and `text` can't be used for sort/facet without `fielddata: true` (OOM-prone). **Always pre-define mappings.**
2. **Cannot change field type** without reindex. Add new field, dual-write, switch reads, drop old.
3. **`text` vs `keyword`** — text is analyzed (lowercased, tokenized, stemmed). Keyword is stored as-is. For an ID field that should be exact-match, use `keyword`.
4. **`refresh_interval`** is 1s default. New documents not searchable for up to 1s. Force with `?refresh=true` (slow — use sparingly).
5. **`_id` is automatic by default** (random UUID). Set explicit `_id` in `_bulk` to ensure idempotent writes.
6. **`max_result_window`** defaults to 10,000. To page beyond, use `search_after` or `point_in_time`. Don't blindly raise the setting.
7. **Aggregations on `text` fields require `fielddata: true`** (OOM risk). Use `keyword` subfields for aggs/sort.
8. **`null` ≠ missing** — explicitly handle null with `"null_value"` in mapping or use `exists` query.
9. **Reserved field names** like `_id`, `_source`, `_index`, `_doc`. Don't try to redefine them.
10. **`copy_to`** is the OpenSearch native equivalent of Solr `copyField`. Don't replicate via external pipeline.
## Performance tuning for queries
- **Cache `filter` clauses** — they're cached by default, faster than `must`.
- **`doc_values: true`** is default for keyword/numeric/date — required for sort/agg.
- **Use `_source` filtering** to return only needed fields: `"_source": ["title", "id"]`.
- **Avoid `term` on analyzed `text` fields** — use `match` instead.
- **Avoid `keyword` mapping for very high-cardinality string fields** if you don't need exact match (slower aggs).
- **Use `index: false`** on fields you store but never search.
- **Profile slow queries** with the `_search?profile=true` flag.