Chapter 45 · Amazon Opensearch Service
Subchapter 45.34
references/search-recipes.mdMarkdown13 KBView on GitHub
The summary is in SKILL.md (§ Build a search feature). This file owns the recipes — copy-paste DSL for every common search pattern.
Always define a mapping before first ingest. Dynamic mapping creates bloated text + keyword multi-fields you’ll regret.
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"title": {"type":"text", "fields": {"keyword": {"type":"keyword"}}} to support bothsearch_as_you_type for autocompletescaled_float for currency (better than float for known precision)nested unless you actually need it — it’s expensiveGET my-app/_search
{
"query": { "match": { "title": "wireless headphones" } }
}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 phrasephrase_prefix — phrase + last token can be a prefixGET 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).
{ "match_phrase": { "title": { "query": "machine learning", "slop": 1 } } }slop=N allows N word movements within the phrase.
OpenSearch defaults to OR. To replicate Solr’s q.op=AND:
{
"query": {
"match": {
"title": {
"query": "wireless headphones bluetooth",
"operator": "AND"
}
}
}
}Or for query_string:
{ "query_string": { "query": "wireless AND headphones", "default_operator": "AND" } }This is the most common cause of result divergence when migrating from Solr.
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):
post_filter for the clicked facet so other aggs still see all matchesfilter aggregation per facet to apply ALL filters EXCEPT this one{
"query": {
"multi_match": {
"query": "wirele",
"type": "bool_prefix",
"fields": ["title.completion", "title.completion._2gram", "title.completion._3gram"]
}
}
}Best for general autocomplete on existing fields.
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.
For non-native scripts where prefix matters character-by-character.
{
"suggest": {
"spell_check": {
"text": "wirless headfones",
"phrase": {
"field": "title",
"size": 1,
"gram_size": 3,
"direct_generator": [{
"field": "title",
"suggest_mode": "always"
}],
"highlight": { "pre_tag": "<em>", "post_tag": "</em>" }
}
}
}
}{
"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.
{
"query": {
"more_like_this": {
"fields": ["title", "description"],
"like": [{ "_index": "my-app", "_id": "12345" }],
"min_term_freq": 1,
"max_query_terms": 12
}
}
}Boost recent items, popular items, in-stock items:
{
"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"
}
}
}{
"query": { "match": { "title": "headphones" } },
"sort": [
{ "rating": { "order": "desc" } },
{ "price": { "order": "asc" } },
"_score"
]
}To sort on a text field, sort on its .keyword subfield.
{
"query": { "match": { "title": "headphones" } },
"highlight": {
"fields": {
"title": { "pre_tags": ["<em>"], "post_tags": ["</em>"] },
"description": { "fragment_size": 150, "number_of_fragments": 3 }
}
}
}{ "from": 100, "size": 20, "query": { "match_all": {} } }{
"size": 20,
"query": { "match_all": {} },
"sort": [{ "_id": "asc" }],
"search_after": ["last_doc_id_from_previous_page"]
}POST my-app/_search/point_in_time?keep_alive=1m
# returns "pit_id"
POST _search
{
"size": 20,
"pit": { "id": "<pit_id>", "keep_alive": "1m" },
"sort": [{ "_id": "asc" }],
"search_after": [...]
}Define in mapping analysis.filter.synonyms_filter and apply analyzer to text fields.
{
"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.
Use function_score with gauss decay (above) — natural log decay over time.
{
"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 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) |
"id": "12345" becomes text (not keyword) and text can’t be used for sort/facet without fielddata: true (OOM-prone). Always pre-define mappings.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.refresh_interval is 1s default. New documents not searchable for up to 1s. Force with ?refresh=true (slow — use sparingly)._id is automatic by default (random UUID). Set explicit _id in _bulk to ensure idempotent writes.max_result_window defaults to 10,000. To page beyond, use search_after or point_in_time. Don’t blindly raise the setting.text fields require fielddata: true (OOM risk). Use keyword subfields for aggs/sort.null ≠ missing — explicitly handle null with "null_value" in mapping or use exists query._id, _source, _index, _doc. Don’t try to redefine them.copy_to is the OpenSearch native equivalent of Solr copyField. Don’t replicate via external pipeline.filter clauses — they’re cached by default, faster than must.doc_values: true is default for keyword/numeric/date — required for sort/agg._source filtering to return only needed fields: "_source": ["title", "id"].term on analyzed text fields — use match instead.keyword mapping for very high-cardinality string fields if you don’t need exact match (slower aggs).index: false on fields you store but never search._search?profile=true flag.