Odyssey Search Query Logic

A deep-dive into how Driveway.com's vehicle search pipeline is built, from GraphQL entry to MongoDB Atlas Search execution.

MongoDB Atlas Search Kotlin DSL GraphQL Kotlin Reactive Pipelines

High-Level Architecture Overview

Odyssey uses MongoDB Atlas Search as its search engine. There are no .graphqls schema files — the schema is generated from annotated Kotlin classes. Search "rules" are not configured in a rules-engine UI; instead, they are expressed through two complementary layers:

  1. Index Mapping (Terraform/Atlas) — defines which fields are searchable and how they are analyzed (tokenized, lowercased, autocomplete, keyword, etc.). Index: shopSearch on vehicleV3, shopSuggestions on searchSuggestion.
  2. Query Logic (Kotlin code) — defines what operators, filters, and scoring functions are sent inside the $search aggregation stage at runtime.

This document focuses on Layer 2: the query logic.

  GraphQL Request                 Builder Layer                    MongoDB
  ─────────────────           ──────────────────────           ─────────────
  search(commonInputs) ──>  PipelineBuilder                   vehicleV3 collection
                              ├── SearchBuilder                  (Atlas Search index:
                              │     └── SearchOperatorBuilder      shopSearch)
                              │           ├── FilterBuilder
                              │           ├── TextBuilder
                              │           └── SearchBlockTemplateBuilder
                              ├── PageBuilder      ($skip + $limit)
                              ├── ShippingBuilder   ($addFields)
                              ├── LeasingBuilder    ($addFields)
                              └── ProjectBuilder    ($project)
                                      │
                                      ▼
                              Pipeline<Vehicle>
                              [  $search,  $skip,  $limit,  $addFields,  $project  ]
                                      │
                                      ▼
                              MongoAggregationDao.runAggregation(pipeline)
                                      │
                                      ▼
                              Ranked Vehicle Results

1 GraphQL Entry Point & Input Parameters

All search traffic enters through SearchPageQueries.search():

search/.../queries/SearchPageQueries.kt
@GraphQLDescription("Search Driveway.com searchable vehicles")
suspend fun search(
    environment: DataFetchingEnvironment,
    commonInputs: VehicleCommonInputs? = null
): VehicleResults {
    commonInputs?.validate()  // Input validation (pagination bounds, illegal chars, etc.)

    val overrideScoreWeight = context.getOverrideScoreWeightVersion()
    val aggregationDao = daoRouter.getDaoByCollectionName(...)

    val (vehicles, count) = coroutineScope {
        val deferredVehicles = async {
            aggregationDao.runAggregation(
                pipelineBuilder.buildVehicleSearchPipeline(commonInputs, environment, overrideScoreWeight)
            )
        }
        val deferredCount = async {
            // Count pipeline only runs if GraphQL query requested totalItems
            pipelineBuilder.buildVehicleSearchCountPipeline(commonInputs, environment, overrideScoreWeight)
        }
        deferredVehicles.await() to deferredCount.await()
    }
    // ... map to VehicleResults
}

VehicleCommonInputs — What the Frontend Sends

graphql-shared/.../models/VehicleCommonInputs.kt
FieldTypeDescription
searchTermString?Free-text query (e.g. "Honda Civic red")
sortCriteriaSortCriteria?RELEVANCE, PRICE_LOWEST, PRICE_HIGHEST, SHIPPING_FEE_LOWEST, LEASING_LOWEST, MILEAGE_LOWEST, YEAR_NEWEST, YEAR_OLDEST, INVENTORY_NEWEST
filterInputFilters?Structured filters (see table below)
paginationInputPaginationInput?skip (default 0, max 9900) & items (default 24, max 500)
userLocationUserLocation?state + postalCode (needed for shipping & leasing)

Available Filters

FilterTypeVehicle PathAtlas Operator
vehicleConditionsList<VehicleCondition>vehicleConditionQueryStringBlock
makeModelTrimsList<MakeModelTrimFilter>ymmt.make / model / trimCompoundBlock + TextBlock(keyword)
priceRangeDoubleRangepriceRangeBlock (gte/lte)
mileageRangeIntRangemileageRangeBlock
yearRangeIntRangeymmt.yearRangeBlock
mpgHwyRangeIntRangempg.highwayRangeBlock
leaseRangeIntRangeleasing.regionalPrices.amountInCentsEmbeddedDocument + IntRangeBlock
exteriorColorsList<String>exteriorColor.nameCombined into single QueryStringBlock with AND/OR logic
interiorColorsList<String>interiorColor.name
bodyStylesList<String>bodyStyle
fuelTypesList<String>fuel
driveTypesList<String>driveType
transmissionsList<String>transmission
enginesList<String>engine
leasableOnlyBooleanleasing.regionalPricesEmbeddedDocument + CompoundBlock
freeShippingBooleandealer.zoneZipsQueryStringBlock

2 Pipeline Construction (PipelineBuilder)

search/.../builder/PipelineBuilder.kt

The PipelineBuilder orchestrates all builders to create a Pipeline<Vehicle> — a type-safe list of MongoDB aggregation stages:

suspend fun buildVehicleSearchPipeline(
    commonInputs: VehicleCommonInputs?,
    environment: DataFetchingEnvironment?,
    scoreWeightVersionOverride: Int? = null
): Pipeline<Vehicle> =
    buildVehiclePipeline(
        pipeline = Pipeline<Vehicle>(
            searchBuilder.buildSearchBlock(commonInputs, scoreWeightVersionOverride)  // $search
        ).addStages(pageBuilder.buildPageBlocks(commonInputs?.paginationInput)),   // $skip + $limit
        projectProvider = projectBuilder::buildVehicleSearchProjectBlock,
        environment = environment,
        commonInputs = commonInputs
    )

Inside buildVehiclePipeline(), optional stages are conditionally added:

internal suspend fun buildVehiclePipeline(...): Pipeline<Vehicle> {
    if (environment != null) {
        val project = projectProvider(environment)

        // Only add shipping calc if the GraphQL query requested "shippingFee"
        if (project.fields.any { it.field == Vehicle::shippingFee.path() }) {
            pipeline.addStage(shippingBuilder.buildShippingBlock(userLocation))
        }

        // Only add leasing projection if "leasing.defaultPrice" was requested
        if (project.fields.any { it.field == (Vehicle::leasing / Leasing::defaultPrice).path() }) {
            pipeline.addStages(leasingBuilder.buildAddFields(commonInputs))
        }

        pipeline.addStage(project)  // $project: only return requested fields
    }
    return pipeline
}

Final Pipeline Shape

[
  { $search: { index: "shopSearch", compound: { filter: [...], must: [...], should: [...] } } },
  { $skip:   0 },
  { $limit:  24 },
  { $addFields: { shippingFee: { $switch: { ... } } } },          // conditional
  { $addFields: { "leasing.regionalPrices": { $filter: ... } } },  // conditional
  { $addFields: { "leasing.defaultPrice": { $last: ... } } },      // conditional
  { $project:  { vin: 1, price: 1, ymmt: 1, score: { $meta: "searchScore" }, ... } }
]

3 The $search CompoundBlock (Heart of Search)

search/.../builder/SearchOperatorBuilder.kt

The $search stage uses a CompoundBlock — MongoDB Atlas Search's boolean logic operator. This single block contains ALL the search logic:

suspend fun buildCompoundBlockForInputsWithScore(
    inputs: VehicleCommonInputs?,
    scoreWeightVersionOverride: Int? = null
): CompoundBlock {
    val filters = inputs?.filterInput
    val userLocation = inputs?.userLocation
    val scoreWeights = getSearchScoreWeights(inputs?.sortCriteria, filters, scoreWeightVersionOverride)
    val textBlock = buildTextBlock(inputs?.searchTerm, scoreWeights)
    val filterBlocks = filterBuilder.buildSearchFilterBlocks(filters, userLocation)

    return CompoundBlock(
        filter  = filterBlocks,                           // Hard constraints, NO scoring
        must    = listOfNotNull(textBlock),               // Text search, CONTRIBUTES to score
        should  = scoreWeights.sortFactors.staticFactors  // Static score boosts
            .plus(scoreWeights.sortFactors.placeholders    // + Dynamic template boosts
                .mapNotNull { request ->
                    searchBlockTemplateBuilder.resolveSearchBlockFor(request, userLocation, filters)
                })
    )
}

The Four Sections of CompoundBlock

SectionPurposeAffects Score?Logic
filterHard constraints — must match or document is excludedNoAND
mustRequired match — text search terms go hereYesAND
shouldOptional boosting — scoring factors go hereYesOR (additive)
mustNotExclusion — documents matching are removedNoNOT
Key Insight filter is where hard business rules live (active status, suppression, user-selected filters). should is where ranking/scoring rules live. This separation keeps filtering fast (no score computation) while still allowing flexible ranking.

Generated MongoDB JSON

{
  "$search": {
    "index": "shopSearch",
    "compound": {
      "filter": [
        { "queryString": { "defaultPath": "status", "query": "ACTIVE" } },
        { "equals": { "path": "manuallySuppressed", "value": false } },
        { "queryString": { "defaultPath": "vehicleCondition", "query": "USED OR CPO" } },
        // ... user-selected filters (price range, make/model, colors, etc.)
      ],
      "must": [
        { "compound": { "should": [/* TextBlocks for each weighted field */], "minimumShouldMatch": 1 } }
      ],
      "should": [
        // staticFactors: pre-built scoring operators from SearchScoreWeights
        // + resolved templates: FREE_SHIPPING, BASE_SHIPPING_LOWEST, LEASING_LOWEST
      ]
    }
  }
}

4 Hard Filters (FilterBuilder)

search/.../builder/FilterBuilder.kt

Filters go into compound.filter. They are AND-ed together — every document must match all of them. They do not affect the relevance score.

4.1 — Always-Applied Base Filters

These two filters are always added, regardless of user input:

private fun buildSearchableVehicleFilter(): MutableList<SearchOperatorBlock> =
    mutableListOf(
        QueryStringBlock(path = Vehicle::status, query = "ACTIVE"),
        EqualsBlock(path = Vehicle::manuallySuppressed, value = false)
    )
Rule: Hide Soft-Deleted & Suppressed Vehicles Every search query automatically excludes vehicles with status != ACTIVE or manuallySuppressed == true. This is a hard-coded business rule that cannot be overridden by the frontend.

4.2 — Vehicle Condition Filter (Always Applied)

private fun buildVehicleConditionFilter(conditions: List<VehicleCondition>?): QueryStringBlock {
    val safeConditions = when {
        !conditions.isNullOrEmpty() -> conditions
        else -> DEFAULT_VEHICLE_CONDITIONS  // [USED, CPO]
    }
    return QueryStringBlock(
        path = Vehicle::vehicleCondition,
        query = safeConditions.joinToString(" OR ")  // "USED OR CPO"
    )
}
// MongoDB: { "queryString": { "defaultPath": "vehicleCondition", "query": "USED OR CPO" } }

4.3 — Make / Model / Trim Filter (Hierarchical)

Creates a nested CompoundBlock(should=[...], minimumShouldMatch=1) for OR logic across make/model/trim combinations. Each leaf uses TextBlock with analyzer = "keyword" for exact matching:

// Example input: [{ make: "Toyota", models: [{ model: "Camry", trims: ["LE", "SE"] }] }]

CompoundBlock(
    should = listOf(
        CompoundBlock(filter = [
            TextBlock(path = ymmt.make, query = "Toyota", analyzer = "keyword"),
            TextBlock(path = ymmt.model, query = "Camry", analyzer = "keyword"),
            TextBlock(path = ymmt.trim, query = "LE", analyzer = "keyword")
        ]),
        CompoundBlock(filter = [
            TextBlock(path = ymmt.make, query = "Toyota", analyzer = "keyword"),
            TextBlock(path = ymmt.model, query = "Camry", analyzer = "keyword"),
            TextBlock(path = ymmt.trim, query = "SE", analyzer = "keyword")
        ])
    ),
    minimumShouldMatch = 1  // At least one must match (OR logic)
)

4.4 — Range Filters

// Price: priceRange = { min: 20000, max: 50000 }
priceRange.toRangeBlock(Vehicle::price)
// MongoDB: { "range": { "path": "price", "gte": 20000.0, "lte": 50000.0 } }

// Year: yearRange = { min: 2020, max: 2025 }
yearRange.toRangeBlock(Vehicle::ymmt / Ymmt::year)
// MongoDB: { "range": { "path": "ymmt.year", "gte": 2020, "lte": 2025 } }

4.5 — String Filters (Colors, Body Style, Fuel, etc.)

Multiple string filters are combined into a single QueryStringBlock using Lucene query string syntax:

private fun buildStringFilters(filters: Filters): QueryStringBlock? {
    val queries = mutableListOf<String>()
    // exteriorColors = ["Red", "Blue"] --> "exteriorColor.name:(Red OR Blue)"
    // bodyStyles = ["SUV", "Sedan"]    --> "bodyStyle:(SUV OR Sedan)"
    // Combined:  "exteriorColor.name:(Red OR Blue) AND bodyStyle:(SUV OR Sedan)"
    return QueryStringBlock(path = Vehicle::vin, query = queries.joinToString(" AND "))
}
// MongoDB: { "queryString": { "defaultPath": "vin", "query": "exteriorColor.name:(Red OR Blue) AND bodyStyle:(SUV OR Sedan)" } }
Why defaultPath: "vin"? The defaultPath for the QueryStringBlock is set to vin as a placeholder. The actual field paths are embedded inside the query string using Lucene's fieldName:(value) syntax, which overrides the default path.

4.6 — Free Shipping Filter

// freeShipping=true with postalCode="97218"
QueryStringBlock(
    query = "contains 97218",
    path = Vehicle::dealer / Dealer::zoneZips
)
// Only returns vehicles whose dealer's free-shipping zone includes this zip

4.7 — Leasable Only Filter

This is the most complex filter. It uses the PostalCodeOemRegionCache to map the user's zip to OEM region IDs per make, then filters vehicles with matching regional lease prices:

// User in zip 97218 --> makeRegions = { "Honda": 101, "Toyota": 102 }

CompoundBlock(
    should = [
        // For Honda: make=Honda AND embedded regionalPrices has regionId=101
        CompoundBlock(filter = [
            QueryStringBlock(path = ymmt.make, query = "Honda"),
            EmbeddedDocument(
                path = leasing.regionalPrices,
                operator = CompoundBlock(filter = [
                    IntRangeBlock(path = regionalPrices.regionId, value = 101),
                    IntRangeBlock(path = regionalPrices.amountInCents, gte = 20000, lte = 50000)  // if leaseRange set
                ])
            )
        ]),
        // For Toyota: same pattern with regionId=102
        ...
    ]
)

5 Full-Text Search with Fuzzy Matching (TextBuilder)

search/.../builder/TextBuilder.kt

When the user types a search term (e.g. "honda civic red"), the TextBuilder creates a CompoundBlock(should=[...], minimumShouldMatch=1) inside the must section. Each field gets its own TextBlock with a configurable boost weight:

fun buildSearchTermBlock(query: String, textSearchWeights: Map<String, Double>): CompoundBlock =
    CompoundBlock(
        should = textSearchWeights.map { textSearchWeight ->
            TextBlock(
                query = query,
                path  = textSearchWeight.key,           // e.g. "ymmt.make"
                fuzzyBlock = buildFuzzyBlock(),
                scoreBlock = BoostScoreBlock(textSearchWeight.value) // e.g. 2.5
            )
        },
        minimumShouldMatch = 1  // At least one field must match
    )

private fun buildFuzzyBlock() = TextBlock.FuzzyBlock(
    maxEdits     = 1,   // Allow 1 typo (Levenshtein distance)
    prefixLength = 0,   // No prefix required to be exact
    maxExpansions = 50  // Max candidate terms for fuzzy expansion
)

Example: textSearchWeights Config

// Stored in searchScoreWeights collection for sortType=RECOMMENDED
{
  "textSearchWeights": {
    "ymmt.make":  2.5,   // Make matches weighted highest
    "ymmt.model": 2.0,
    "ymmt.trim":  1.5,
    "bodyStyle":  1.0,
    "vin":        3.0    // Exact VIN match weighted very high
  }
}

Generated MongoDB JSON

{
  "compound": {
    "should": [
      {
        "text": {
          "path": "ymmt.make",
          "query": "honda civic red",
          "fuzzy": { "maxEdits": 1, "prefixLength": 0, "maxExpansions": 50 },
          "score": { "boost": { "value": 2.5 } }
        }
      },
      {
        "text": {
          "path": "ymmt.model",
          "query": "honda civic red",
          "fuzzy": { "maxEdits": 1, "prefixLength": 0, "maxExpansions": 50 },
          "score": { "boost": { "value": 2.0 } }
        }
      }
      // ... one TextBlock per field in textSearchWeights
    ],
    "minimumShouldMatch": 1
  }
}
Rule: Prioritize Exact VIN Matches By giving VIN a high boost weight (e.g. 3.0), exact VIN matches will always score higher than partial make/model matches. This is configured through textSearchWeights, not code changes.

6 Scoring & Ranking (SearchScoreWeights)

library/.../models/SearchScoreWeights.kt

This is the primary mechanism for tuning search results. Each SortType has a versioned configuration stored in the searchScoreWeights MongoDB collection:

data class SearchScoreWeights(
    val id: ObjectId? = null,
    val version: Int = 1,               // Auto-incremented per sortType
    val description: String? = null,
    val enabled: Boolean = false,        // Only ONE version per sortType is active
    val sortType: SortType,              // Which sort context this config applies to
    val textSearchWeights: Map<String, Double>,  // Field path -> boost factor
    val sortFactors: SortFactors        // The scoring rules!
) {
    data class SortFactors(
        val staticFactors: List<SearchOperatorBlock> = listOf(),     // Pre-built scoring blocks
        val placeholders: List<SearchBlockTemplate.Request> = listOf() // Dynamic templates
    )
}

SortType → SortCriteria Mapping

Frontend SortCriteriaInternal SortTypeNotes
RELEVANCE (or null)RECOMMENDEDDefault sort
PRICE_LOWEST + condition=NEWLOW_PRICE_NEW_CARContext-aware: different scoring for NEW vs USED
PRICE_LOWEST + condition=USEDLOW_PRICE_USED_CAR
PRICE_HIGHEST + condition=NEWHIGH_PRICE_NEW_CARSame context-aware pattern
PRICE_HIGHEST + condition=USEDHIGH_PRICE_USED_CAR
SHIPPING_FEE_LOWESTSHIPPING_FEE_LOWEST
LEASING_LOWESTLEASING_LOWEST
MILEAGE_LOWESTLOWEST_MILEAGE
YEAR_NEWESTNEWEST_YEAR
YEAR_OLDESTOLDEST_YEAR
INVENTORY_NEWESTINVENTORY_NEWEST

Score Caching

search/.../cache/SearchScoreWeightsCache.kt

Enabled score weights are cached in memory and refreshed on a schedule. This avoids a DB lookup on every search request:

@PostConstruct
private fun initCache() = refreshEnabledCache()

@Scheduled(fixedRateString = "\${cache.enabled-score-weight-ttl-ms}")
fun refreshEnabledCache() {
    val refreshedValues = runBlocking { searchScoreWeightsDao.findEnabled() }
        .associateBy { it.sortType }
    enabledScoreWeightsCache.putAll(refreshedValues)
}

Score Types Available

Score BlockPurposeMongoDB Output
BoostScoreBlock(2.5)Multiply match score by constant{ "boost": { "value": 2.5 } }
ConstantScoreBlock(1000)Fixed score regardless of match quality{ "constant": { "value": 1000 } }
FunctionScoreBlock(gauss(...))Score decays with distance from origin{ "function": { "gauss": {...} } }
FunctionScoreBlock(path(...))Use a document field value as the score{ "function": { "path": { "value": "price" } } }
FunctionScoreBlock(multiply(...))Combine multiple scoring expressions{ "function": { "multiply": [...] } }
EmbeddedScoreBlock(MAX)Score from nested array (max element){ "embedded": { "aggregate": "maximum" } }

Example: Boost Newer Inventory Automatically

This would be a staticFactor in the RECOMMENDED sort's SearchScoreWeights:

// NearBlock: vehicles closer to current year score higher
NearBlock(
    path   = "ymmt.year",
    origin = 2026,     // Current year = highest score
    pivot  = 3         // Score halves every 3 years from origin
)

// MongoDB: { "near": { "path": "ymmt.year", "origin": 2026, "pivot": 3 } }

7 Dynamic Score Templates (Shipping & Leasing)

search/.../builder/SearchBlockTemplateBuilder.kt

Some scoring blocks need user-specific data (postal code, state) that can't be known when the score weight is saved to the DB. These are resolved at query time via templates:

enum class SearchBlockTemplate {
    FREE_SHIPPING,         // Boost vehicles with free shipping to user's zip
    BASE_SHIPPING_LOWEST,  // Boost vehicles with lowest shipping cost
    LEASING_LOWEST         // Boost vehicles with lowest lease payment
}

FREE_SHIPPING Template

search/.../builder/ShippingBuilder.kt
fun freeShippingBuilder(scoreBlock: ScoreBlock?, userLocation: UserLocation?): SearchOperatorBlock? {
    return userLocation?.postalCode?.let { postalCode ->
        TextBlock(
            query = postalCode,                          // "97218"
            path  = Vehicle::dealer / Dealer::zoneZips,  // Search free-ship zip list
            scoreBlock = scoreBlock                      // Custom boost from scoreWeight config
        )
    }
}
// If user zip is in dealer's free zones, this should-clause matches and boosts the score

BASE_SHIPPING_LOWEST Template

fun baseShippingLowestBuilder(scoreBlock: ScoreBlock?, userLocation: UserLocation?): SearchOperatorBlock? {
    return CompoundBlock(
        must = listOf(
            ExistsBlock(path = "dealer.stateShippingMap.${userLocation.state}")
        ),
        mustNot = listOf(
            TextBlock(query = postalCode, path = Vehicle::dealer / Dealer::zoneZips)
        ),
        scoreBlock = scoreBlock  // e.g. FunctionScoreBlock(path("dealer.stateShippingMap.OR"))
    )
}
// Matches vehicles NOT in free zone but WITH a state shipping cost, scored by that cost

LEASING_LOWEST Template

search/.../builder/LeasingBuilder.kt
suspend fun lowestLeasingBuilder(scoreBlock: ScoreBlock?, userLocation: UserLocation?, filters: Filters?): CompoundBlock? {
    val makeRegions = postalCodeOemRegionCache.get(userLocation?.postalCode)?.makeRegions

    return CompoundBlock(
        should = makeRegions.map { (make, regionId) ->
            CompoundBlock(
                must = listOf(
                    QueryStringBlock(path = ymmt.make, query = make),
                    EmbeddedDocument(
                        path = leasing.regionalPrices,
                        operator = IntRangeBlock(
                            path = regionalPrices.regionId,
                            value = regionId,
                            scoreBlock = FunctionScoreBlock(
                                expression = PathBlock(value = regionalPrices.amountInCents)
                                // Sets atlas score = the monthly lease price
                            )
                        ),
                        scoreBlock = EmbeddedScoreBlock(aggregate = MAX)
                    )
                )
            )
        },
        scoreBlock = scoreBlock  // Product-defined score manipulation
    )
}
// Result: Score = regional lease price, allowing the outer scoreBlock to invert/transform it

String Template Resolution

Score blocks stored in MongoDB can contain string templates like #USER_STATE# and #USER_POSTAL_CODE#. These are resolved at query time by StringTemplateResolver using reflection to walk the block tree and replace template strings:

// In MongoDB: { "path": { "value": "dealer.stateShippingMap.#USER_STATE#" } }
// At runtime: { "path": { "value": "dealer.stateShippingMap.OR" } }  (if user state = "OR")

8 Post-Search Stages ($addFields, $project)

8.1 — Shipping Fee Calculation

search/.../builder/ShippingBuilder.kt

Added only when the GraphQL query requests shippingFee:

fun buildShippingBlock(userLocation: UserLocation?): Block? =
    userLocation?.run {
        AddFieldsBlock(
            Vehicle::shippingFee,
            SwitchBlock(
                SwitchBlock.BranchBlock(
                    case = Document("\$in", listOf(postalCode, dealerZoneZipsPath)),
                    then = 0L  // Free shipping!
                ),
                default = userStateMatrixPath  // Lookup from state shipping matrix
            )
        )
    }
// MongoDB:
{
  "$addFields": {
    "shippingFee": {
      "$switch": {
        "branches": [{
          "case": { "$in": ["97218", "$dealer.zoneZips"] },
          "then": 0
        }],
        "default": "$dealer.stateShippingMap.OR"
      }
    }
  }
}

8.2 — Leasing Regional Price Projection

search/.../builder/LeasingBuilder.kt

Two $addFields stages: first filters the regionalPrices array to the user's region, then extracts the price:

// Stage 1: Filter regionalPrices to user's OEM region
{ "$addFields": { "leasing.regionalPrices": { "$filter": {
    "input": "$leasing.regionalPrices",
    "cond": { "$or": [
      { "$and": [
        { "$eq": ["$ymmt.make", "Honda"] },
        { "$eq": ["$$regionalPrices.regionId", 101] }
      ]}
    ]}
} } } }

// Stage 2: Set defaultPrice from the single remaining element
{ "$addFields": { "leasing.defaultPrice": { "$last": "$leasing.regionalPrices.amountInCents" } } }

8.3 — Field Projection

search/.../builder/ProjectBuilder.kt

Only fields requested by the GraphQL query are projected. If the query requests score, the Atlas Search score is added via $meta: "searchScore":

{ "$project": {
    "vin": 1, "price": 1, "ymmt": 1, "image": 1,
    "shippingFee": 1, "leasing": 1,
    "score": { "$meta": "searchScore" }
} }

9 Facets & Counts ($searchMeta)

search/.../builder/SearchMetaBuilder.kt

Facets and total counts use $searchMeta (NOT $search) — same filters, but no scoring. This powers the filter sidebar showing "Toyota (1,500), Honda (1,200)".

Available Facet Fields

graphql-shared/.../models/Facets.kt
EnumFacet NameVehicle Path
MAKEmakeFacetymmt.make
MODELmodelFacetymmt.model
TRIMtrimFacetymmt.trim
CONDITIONvehicleConditionFacetvehicleCondition
BODY_STYLEbodyStyleFacetbodyStyle
{
  "$searchMeta": {
    "index": "shopSearch",
    "facet": {
      "operator": { "compound": { "filter": [/* same filters as $search, NO scoring */] } },
      "facets": {
        "makeFacet":  { "type": "string", "path": "ymmt.make",  "numBuckets": 100 },
        "modelFacet": { "type": "string", "path": "ymmt.model", "numBuckets": 100 }
      }
    }
  }
}

// Response:
{ "count": { "lowerBound": 5000 },
  "facet": {
    "makeFacet": { "buckets": [
      { "_id": "Toyota", "count": 1500 },
      { "_id": "Honda",  "count": 1200 }
    ]}
  }
}

10 Autocomplete Suggestions

search/.../builder/SearchSuggestionsBuilder.kt

Uses the separate shopSuggestions Atlas Search index on the searchSuggestion collection. Returns up to 4 suggestions:

// Pipeline: $search (autocomplete) -> $limit(4)
{
  "$search": {
    "index": "shopSuggestions",
    "compound": {
      "must": [{
        "autocomplete": {
          "path": "value",
          "query": "hon"   // User typing...
        }
      }]
    }
  }
}
// Returns: ["Honda", "Honda Civic", "Honda Accord", "Honda CR-V"]

DSL The Aggregation DSL — Block Reference

library/.../mongo/aggregation/

Every MongoDB aggregation operator is represented as a Kotlin Block subclass. Blocks compose together via Pipeline<T> and serialize to BSON via toMongoDocument():

// Type-safe pipeline construction
Pipeline<Vehicle>()
    .addStage(SearchBlock(index, CompoundBlock(...)))
    .addStages(listOf(SkipBlock(0), LimitBlock(24)))
    .addStage(AddFieldsBlock(Vehicle::shippingFee, SwitchBlock(...)))
    .addStage(ProjectBlock(fields))
    .toMongoQuery()  // -> List<Bson> ready for MongoDB

Search Operator Blocks

BlockAtlas Search OperatorUse Case
CompoundBlockcompoundBoolean logic (filter/must/should/mustNot)
TextBlocktextFull-text search with fuzzy matching
RangeBlockrangeNumeric/date range filters
EqualsBlockequalsBoolean field matching
QueryStringBlockqueryStringLucene query syntax (OR/AND)
NearBlocknearProximity-based scoring (year, price)
ExistsBlockexistsField presence check
AutocompleteBlockautocompleteTypeahead suggestions
EmbeddedDocumentembeddedDocumentSearch within nested arrays

Projection Blocks

BlockMongoDB StageUse Case
ProjectBlock$projectInclude/exclude fields
AddFieldsBlock$addFieldsComputed fields (shipping, leasing, score)
FilterBlock$filterFilter arrays within documents
SwitchBlock$switchConditional logic (if/else)
MapBlock$mapTransform array elements

Score Expression Blocks

BlockMongoDB ScoreUse Case
BoostScoreBlock(2.5){ boost: { value: 2.5 } }Multiply match score
ConstantScoreBlock(100){ constant: { value: 100 } }Fixed score
FunctionScoreBlock(expr){ function: { ... } }Custom scoring function
PathBlock("price"){ path: { value: "price" } }Use field value as score
GaussExpressionBlock(...){ gauss: { ... } }Gaussian decay (distance scoring)
MultiplyExpressionBlock([...]){ multiply: [...] }Multiply expressions together
AddExpressionBlock([...]){ add: [...] }Sum expressions together
RelevanceScoreBlock(){ score: "relevance" }Reference to base relevance score
EmbeddedScoreBlock(MAX){ embedded: { aggregate: "maximum" } }Score from nested array

API Managing Score Weights (REST API)

routes/.../handlers/SearchScoreWeightsHandler.kt
MethodPathDescription
GET/search/score-weightsList latest version of each sort type (or all versions of a specific type via ?sortType=X)
GET/search/score-weights/templatesList available dynamic templates (FREE_SHIPPING, BASE_SHIPPING_LOWEST, LEASING_LOWEST)
POST/search/score-weightsCreate new version (auto-increments). Body: SearchScoreWeightRequest
POST/search/score-weights/enableActivate a version: ?sortType=X&version=N
DELETE/search/score-weightsDelete a version: ?sortType=X&version=N (cannot delete enabled version)

A/B Testing via HTTP Header

Test a specific score weight version without enabling it globally:

// Send this header with your GraphQL request:
Override-Score-Weight-Version: 5

// The search will use version 5 of the current sortType instead of the enabled version
// No production impact — only affects this single request

! How to Modify a Search Rule

There is no search rules UI. All search behavior is configured through a combination of code (filter logic, builder classes) and data (SearchScoreWeights in MongoDB, managed via REST API).

Scenario 1: Change How Results Are Ranked

Where: searchScoreWeights collection via REST API

  1. GET /search/score-weights?sortType=RECOMMENDED — see current config
  2. POST /search/score-weights — create a new version with modified textSearchWeights and/or sortFactors
  3. Test with header Override-Score-Weight-Version: N
  4. POST /search/score-weights/enable?sortType=RECOMMENDED&version=N — activate it

Scenario 2: Add a New Filter

Where: Code changes required

  1. Add the new field to Filters data class in VehicleCommonInputs.kt
  2. Add filter construction logic in FilterBuilder.buildSearchFilterBlocks()
  3. Add validation in VehicleCommonInputs.validate()
  4. Ensure the field is mapped in the shopSearch Atlas Search index (Terraform)

Scenario 3: Change Text Search Field Weights

Where: searchScoreWeights collection — no code changes needed

Modify the textSearchWeights map to boost/reduce specific fields:

// To make VIN matches dominate over make/model:
POST /search/score-weights
{
  "sortType": "RECOMMENDED",
  "description": "Boost VIN matches to 5.0",
  "textSearchWeights": {
    "vin": 5.0,
    "ymmt.make": 2.5,
    "ymmt.model": 2.0,
    "ymmt.trim": 1.5,
    "bodyStyle": 1.0
  },
  "sortFactors": { /* keep existing staticFactors + placeholders */ }
}

Scenario 4: Add a New Scoring Template

Where: Code changes required

  1. Add new enum value to SearchBlockTemplate in SearchBlockTemplate.kt
  2. Create the builder method (like ShippingBuilder.freeShippingBuilder())
  3. Add the when case in SearchBlockTemplateBuilder.resolveSearchBlockFor()
  4. Reference it from SearchScoreWeights.sortFactors.placeholders via REST API

Scenario 5: Modify the Fuzzy Search Behavior

Where: TextBuilder.TextConstants

// Current values (hardcoded)
const val FUZZY_MAX_EDITS = 1       // Allow 1 typo
const val FUZZY_PREFIX_LENGTH = 0  // No exact prefix required
const val FUZZY_MAX_EXPANSIONS = 50 // Max fuzzy candidates

Scenario 6: Add a New Facet Field

Where: FacetField enum in Facets.kt

  1. Add the enum value with facetName and field (KProperty path)
  2. Ensure the field is indexed in the Atlas Search index as a facet-compatible type

F Key File Reference

FilePurpose
search/.../queries/SearchPageQueries.ktGraphQL entry point: search(), getFacets(), getSearchSuggestions()
search/.../builder/PipelineBuilder.ktOrchestrates all builders into a Pipeline<Vehicle>
search/.../builder/SearchBuilder.ktCreates the SearchBlock ($search stage wrapper)
search/.../builder/SearchOperatorBuilder.ktBuilds the CompoundBlock with scoring + filters
search/.../builder/FilterBuilder.ktConstructs all hard-constraint filter blocks
search/.../builder/TextBuilder.ktFuzzy text search with per-field boost weights
search/.../builder/ShippingBuilder.ktShipping fee calc + free shipping score boost
search/.../builder/LeasingBuilder.ktLease filter, price projection, score boost
search/.../builder/SearchBlockTemplateBuilder.ktResolves dynamic templates at query time
search/.../builder/SearchMetaBuilder.ktBuilds $searchMeta for facets and counts
search/.../builder/PageBuilder.kt$skip + $limit pagination
search/.../builder/ProjectBuilder.kt$project field selection from GraphQL query
search/.../cache/SearchScoreWeightsCache.ktIn-memory cache for enabled score weights
search/.../cache/PostalCodeOemRegionCache.ktCache for postal code to OEM region mappings
library/.../models/SearchScoreWeights.ktScore weights data model + SortType enum
library/.../daos/mongo/SearchScoreWeightsDaoImpl.ktCRUD + versioning for score weights
library/.../mongo/aggregation/Custom aggregation DSL (Pipeline, Block, all operators)
library/.../config/SearchConfig.ktAtlas index names, text operator settings
graphql-shared/.../models/VehicleCommonInputs.ktInput types: Filters, SortCriteria, UserLocation
graphql-shared/.../models/Facets.ktFacetField enum, facet response types
routes/.../handlers/SearchScoreWeightsHandler.ktREST API for managing score weights
terraform/.../mongo-indexes.tfMongoDB index definitions (Terraform)