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 mirror the MongoDB Atlas Search configuration used for inventory search (configureSearchOperator). This ensures consistent search behavior across both engines.
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.
Comparison with MongoDB Atlas Search
Inventory search still uses MongoDB Atlas Search via configureSearchOperator(). Both implementations follow the same conceptual hierarchy:
| Concept | MongoDB Atlas Search | OpenSearch |
|---|---|---|
| Exact phrase | phrase (slop: 0) | match_phrase (slop: 0) |
| Flexible phrase | phrase (slop: 2) | match_phrase (slop: 2) |
| All words | text (matchCriteria: all) | match (operator: and) |
| Prefix exact | autocomplete (sequential) | multi_match (bool_prefix) |
| Prefix fuzzy | autocomplete (fuzzy, sequential) | multi_match (bool_prefix, fuzziness: 1) |
| Fuzzy fallback | autocomplete (fuzzy) | match (fuzziness: 1) |
| Field precedence | fieldPrecedence: 0.95 | fieldPrecedence: 0.95 |
The boost values are identical across both engines to ensure consistent search behavior.
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 |
| MongoDB Atlas Search (reference) | packages/core/db/src/search/configure-search-operator.ts |
| Query mapper (locale resolution) | packages/backend/api/src/lib/orpc/handlers/listings/query.mapper.ts |