Text Search
Marketplace product and listing search uses OpenSearch with a 6-tier scoring hierarchy that prioritizes exact matches over fuzzy matches. This page documents how text search queries are constructed, how locale-aware field resolution works, and how results are ranked.
Overview
| Feature | Description |
|---|---|
| Query Engine | OpenSearch bool.should with boosted clauses |
| Locale Support | Translation field priority with English fallback |
| Scoring Model | 6-tier boost hierarchy (exact phrase ā fuzzy fallback) |
| Default Sort | _score desc for text queries, _id for browsing |
| Prefix Matching | search_as_you_type field with bool_prefix queries |
| Code Location | packages/backend/features/search/src/dimensions/clause-builders.ts |
Scoring Hierarchy
When a user types a search query, we generate multiple OpenSearch clauses per field, each with a different boost value. OpenSearch picks the highest-scoring clause that matches, so exact matches always rank above fuzzy matches.
| Priority | Query Type | OpenSearch DSL | Boost | When | Purpose |
|---|---|---|---|---|---|
| 1 | Exact phrase | match_phrase (slop: 0) | 5000 | Multi-word only | āBlack Lotusā matches exactly in order |
| 2 | Flexible phrase | match_phrase (slop: 2) | 2500 | Multi-word only | āLotus Blackā or āBlack the Lotusā near-matches |
| 3 | All words match | match (operator: and) | 1000 | Always | All query words present, any order |
| 4 | Prefix exact | multi_match (type: bool_prefix) | 100 | Always | āBlacā matches āBlack Lotusā as a prefix |
| 5 | Prefix fuzzy | multi_match (type: bool_prefix, fuzziness: 1) | 50 | Always | āBlakcā catches a typo while still prefix matching |
| 6 | Fuzzy fallback | match (fuzziness: 1, operator: or) | 10 | Always | Last resort ā any single word with 1 typo |
The boost values encode the relevance hierarchy: exact and phrase matches rank far above prefix and fuzzy fallbacks.
Query Flow
Locale-Aware Field Resolution
When a locale is provided (e.g., fr for French), the search targets locale-specific translation fields first, with a fallback to the base English name field.
Field Selection
| Locale | Text Fields | Suggest Fields (prefix) |
|---|---|---|
| None | product.name | product.name.suggest |
fr | product.translations.fr.name, product.name | product.translations.fr.name.suggest, product.name.suggest |
de | product.translations.de.name, product.name | product.translations.de.name.suggest, product.name.suggest |
Field Precedence Multiplier
Each subsequent field gets 95% of the previous fieldās boost (fieldPrecedence: 0.95). This means locale-specific results rank higher than base name matches:
French field boost: 1000 Ć 0.95ā° = 1000 (textMatchAll)
English field boost: 1000 à 0.95¹ = 950 (textMatchAll)The same multiplier applies to all 6 priority levels, so a French exact phrase match (5000) always beats an English exact phrase match (4750).
The base product.name field is stored in English. There is no separate product.translations.en.name fallback step needed ā the base field already serves that purpose. However, if locale=en is provided, it will search product.translations.en.name first (for products that have explicit English translations), then fall back to product.name.
Query Structure Example
For a query like "Black Lotus" with locale=fr, the generated OpenSearch DSL looks like:
{
bool: {
should: [
// --- French translation field (multiplier: 1.0) ---
// P1: Exact phrase
{ match_phrase: { "product.translations.fr.name": { query: "Black Lotus", slop: 0, boost: 5000 } } },
// P2: Flexible phrase
{ match_phrase: { "product.translations.fr.name": { query: "Black Lotus", slop: 2, boost: 2500 } } },
// P3: All words match
{ match: { "product.translations.fr.name": { query: "Black Lotus", operator: "and", boost: 1000 } } },
// P4: Prefix exact
{ multi_match: { query: "Black Lotus", type: "bool_prefix",
fields: ["product.translations.fr.name.suggest", "...._2gram", "...._3gram"], boost: 100 } },
// P5: Prefix fuzzy
{ multi_match: { query: "Black Lotus", type: "bool_prefix",
fields: ["product.translations.fr.name.suggest", "...._2gram", "...._3gram"],
fuzziness: 1, prefix_length: 2, boost: 50 } },
// P6: Fuzzy fallback
{ match: { "product.translations.fr.name": { query: "Black Lotus", fuzziness: 1,
prefix_length: 2, operator: "or", boost: 10 } } },
// --- Base name field (multiplier: 0.95) ---
// Same 6 clauses with boost Ć 0.95 ...
],
minimum_should_match: 1
}
}Sort Behavior
The sort strategy depends on whether the user is doing a text search and whether they specified an explicit sort:
| User Sort? | Text Search? | Sort Order |
|---|---|---|
| Yes | Any | User sort + sortNumber + _id tie-breakers |
| No | Yes | _score desc + sortNumber desc + _id desc (relevance) |
| No | No | sortNumber asc + _id asc (browse/filter mode) |
This is critical for search quality. Without _score as the default sort for text searches, results would come back in arbitrary _id order regardless of how well they match the query.
Index Schema Requirements
Text search relies on specific field mappings in the OpenSearch index:
product.name:textwithproduct_name_analyzer+.keyword+.suggest(search_as_you_type)product.translations.{locale}.name: Same mapping as product.name (viatranslationNameField())product.expansion.name:textwith keyword +.suggest(search_as_you_type)product.expansion.translations.{locale}.name: Same as above
The product_name_analyzer uses standard tokenizer with lowercase and asciifolding filters, so accented characters like āĆpĆ©eā match searches for āepeeā.
The search_as_you_type sub-field automatically creates internal ._2gram, ._3gram, and ._index_prefix sub-fields that enable efficient prefix matching.
Code Locations
| Component | Location |
|---|---|
| Clause builders (scoring logic) | packages/backend/features/search/src/dimensions/clause-builders.ts |
| Query builder (sort logic) | packages/backend/features/search/src/listings/listing-query-builder.service.ts |
| Index schema (field mappings) | packages/backend/features/search/src/listings/listing-schema.ts |
| Query mapper (locale resolution) | packages/backend/api/src/lib/orpc/handlers/listings/query.mapper.ts |