Skip to Content

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

FeatureDescription
Query EngineOpenSearch bool.should with boosted clauses
Locale SupportTranslation field priority with English fallback
Scoring Model6-tier boost hierarchy (exact phrase → fuzzy fallback)
Default Sort_score desc for text queries, _id for browsing
Prefix Matchingsearch_as_you_type field with bool_prefix queries
Code Locationpackages/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.

PriorityQuery TypeOpenSearch DSLBoostWhenPurpose
1Exact phrasematch_phrase (slop: 0)5000Multi-word onlyā€Black Lotusā€ matches exactly in order
2Flexible phrasematch_phrase (slop: 2)2500Multi-word onlyā€Lotus Blackā€ or ā€œBlack the Lotusā€ near-matches
3All words matchmatch (operator: and)1000AlwaysAll query words present, any order
4Prefix exactmulti_match (type: bool_prefix)100Alwaysā€Blacā€ matches ā€œBlack Lotusā€ as a prefix
5Prefix fuzzymulti_match (type: bool_prefix, fuzziness: 1)50Alwaysā€Blakcā€ catches a typo while still prefix matching
6Fuzzy fallbackmatch (fuzziness: 1, operator: or)10AlwaysLast 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

LocaleText FieldsSuggest Fields (prefix)
Noneproduct.nameproduct.name.suggest
frproduct.translations.fr.name, product.nameproduct.translations.fr.name.suggest, product.name.suggest
deproduct.translations.de.name, product.nameproduct.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
YesAnyUser sort + sortNumber + _id tie-breakers
NoYes_score desc + sortNumber desc + _id desc (relevance)
NoNosortNumber 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.

Inventory search still uses MongoDB Atlas Search via configureSearchOperator(). Both implementations follow the same conceptual hierarchy:

ConceptMongoDB Atlas SearchOpenSearch
Exact phrasephrase (slop: 0)match_phrase (slop: 0)
Flexible phrasephrase (slop: 2)match_phrase (slop: 2)
All wordstext (matchCriteria: all)match (operator: and)
Prefix exactautocomplete (sequential)multi_match (bool_prefix)
Prefix fuzzyautocomplete (fuzzy, sequential)multi_match (bool_prefix, fuzziness: 1)
Fuzzy fallbackautocomplete (fuzzy)match (fuzziness: 1)
Field precedencefieldPrecedence: 0.95fieldPrecedence: 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: text with product_name_analyzer + .keyword + .suggest (search_as_you_type)
  • product.translations.{locale}.name: Same mapping as product.name (via translationNameField())
  • product.expansion.name: text with 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

ComponentLocation
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
Last updated on