A deep-dive into how Driveway.com's vehicle search pipeline is built, from GraphQL entry to MongoDB Atlas Search execution.
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:
shopSearch on vehicleV3, shopSuggestions on searchSuggestion.$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
All search traffic enters through SearchPageQueries.search():
@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
}
| Field | Type | Description |
|---|---|---|
searchTerm | String? | Free-text query (e.g. "Honda Civic red") |
sortCriteria | SortCriteria? | RELEVANCE, PRICE_LOWEST, PRICE_HIGHEST, SHIPPING_FEE_LOWEST, LEASING_LOWEST, MILEAGE_LOWEST, YEAR_NEWEST, YEAR_OLDEST, INVENTORY_NEWEST |
filterInput | Filters? | Structured filters (see table below) |
paginationInput | PaginationInput? | skip (default 0, max 9900) & items (default 24, max 500) |
userLocation | UserLocation? | state + postalCode (needed for shipping & leasing) |
| Filter | Type | Vehicle Path | Atlas Operator |
|---|---|---|---|
vehicleConditions | List<VehicleCondition> | vehicleCondition | QueryStringBlock |
makeModelTrims | List<MakeModelTrimFilter> | ymmt.make / model / trim | CompoundBlock + TextBlock(keyword) |
priceRange | DoubleRange | price | RangeBlock (gte/lte) |
mileageRange | IntRange | mileage | RangeBlock |
yearRange | IntRange | ymmt.year | RangeBlock |
mpgHwyRange | IntRange | mpg.highway | RangeBlock |
leaseRange | IntRange | leasing.regionalPrices.amountInCents | EmbeddedDocument + IntRangeBlock |
exteriorColors | List<String> | exteriorColor.name | Combined into single QueryStringBlock with AND/OR logic |
interiorColors | List<String> | interiorColor.name | |
bodyStyles | List<String> | bodyStyle | |
fuelTypes | List<String> | fuel | |
driveTypes | List<String> | driveType | |
transmissions | List<String> | transmission | |
engines | List<String> | engine | |
leasableOnly | Boolean | leasing.regionalPrices | EmbeddedDocument + CompoundBlock |
freeShipping | Boolean | dealer.zoneZips | QueryStringBlock |
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
}
[
{ $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" }, ... } }
]
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)
})
)
}
| Section | Purpose | Affects Score? | Logic |
|---|---|---|---|
| filter | Hard constraints — must match or document is excluded | No | AND |
| must | Required match — text search terms go here | Yes | AND |
| should | Optional boosting — scoring factors go here | Yes | OR (additive) |
| mustNot | Exclusion — documents matching are removed | No | NOT |
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.
{
"$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
]
}
}
}
Filters go into compound.filter. They are AND-ed together — every document must match all of them. They do not affect the relevance score.
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)
)
status != ACTIVE or manuallySuppressed == true. This is a hard-coded business rule that cannot be overridden by the frontend.
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" } }
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)
)
// 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 } }
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)" } }
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.
// 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
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
...
]
)
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
)
// 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
}
}
{
"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
}
}
textSearchWeights, not code changes.
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
)
}
| Frontend SortCriteria | Internal SortType | Notes |
|---|---|---|
| RELEVANCE (or null) | RECOMMENDED | Default sort |
| PRICE_LOWEST + condition=NEW | LOW_PRICE_NEW_CAR | Context-aware: different scoring for NEW vs USED |
| PRICE_LOWEST + condition=USED | LOW_PRICE_USED_CAR | |
| PRICE_HIGHEST + condition=NEW | HIGH_PRICE_NEW_CAR | Same context-aware pattern |
| PRICE_HIGHEST + condition=USED | HIGH_PRICE_USED_CAR | |
| SHIPPING_FEE_LOWEST | SHIPPING_FEE_LOWEST | |
| LEASING_LOWEST | LEASING_LOWEST | |
| MILEAGE_LOWEST | LOWEST_MILEAGE | |
| YEAR_NEWEST | NEWEST_YEAR | |
| YEAR_OLDEST | OLDEST_YEAR | |
| INVENTORY_NEWEST | INVENTORY_NEWEST |
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 Block | Purpose | MongoDB 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" } } |
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 } }
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
}
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
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
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
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")
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"
}
}
}
}
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" } } }
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" }
} }
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)".
| Enum | Facet Name | Vehicle Path |
|---|---|---|
| MAKE | makeFacet | ymmt.make |
| MODEL | modelFacet | ymmt.model |
| TRIM | trimFacet | ymmt.trim |
| CONDITION | vehicleConditionFacet | vehicleCondition |
| BODY_STYLE | bodyStyleFacet | bodyStyle |
{
"$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 }
]}
}
}
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"]
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
| Block | Atlas Search Operator | Use Case |
|---|---|---|
CompoundBlock | compound | Boolean logic (filter/must/should/mustNot) |
TextBlock | text | Full-text search with fuzzy matching |
RangeBlock | range | Numeric/date range filters |
EqualsBlock | equals | Boolean field matching |
QueryStringBlock | queryString | Lucene query syntax (OR/AND) |
NearBlock | near | Proximity-based scoring (year, price) |
ExistsBlock | exists | Field presence check |
AutocompleteBlock | autocomplete | Typeahead suggestions |
EmbeddedDocument | embeddedDocument | Search within nested arrays |
| Block | MongoDB Stage | Use Case |
|---|---|---|
ProjectBlock | $project | Include/exclude fields |
AddFieldsBlock | $addFields | Computed fields (shipping, leasing, score) |
FilterBlock | $filter | Filter arrays within documents |
SwitchBlock | $switch | Conditional logic (if/else) |
MapBlock | $map | Transform array elements |
| Block | MongoDB Score | Use 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 |
| Method | Path | Description |
|---|---|---|
GET | /search/score-weights | List latest version of each sort type (or all versions of a specific type via ?sortType=X) |
GET | /search/score-weights/templates | List available dynamic templates (FREE_SHIPPING, BASE_SHIPPING_LOWEST, LEASING_LOWEST) |
POST | /search/score-weights | Create new version (auto-increments). Body: SearchScoreWeightRequest |
POST | /search/score-weights/enable | Activate a version: ?sortType=X&version=N |
DELETE | /search/score-weights | Delete a version: ?sortType=X&version=N (cannot delete enabled version) |
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
Where: searchScoreWeights collection via REST API
GET /search/score-weights?sortType=RECOMMENDED — see current configPOST /search/score-weights — create a new version with modified textSearchWeights and/or sortFactorsOverride-Score-Weight-Version: NPOST /search/score-weights/enable?sortType=RECOMMENDED&version=N — activate itWhere: Code changes required
Filters data class in VehicleCommonInputs.ktFilterBuilder.buildSearchFilterBlocks()VehicleCommonInputs.validate()shopSearch Atlas Search index (Terraform)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 */ }
}
Where: Code changes required
SearchBlockTemplate in SearchBlockTemplate.ktShippingBuilder.freeShippingBuilder())when case in SearchBlockTemplateBuilder.resolveSearchBlockFor()SearchScoreWeights.sortFactors.placeholders via REST APIWhere: 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
Where: FacetField enum in Facets.kt
facetName and field (KProperty path)| File | Purpose |
|---|---|
search/.../queries/SearchPageQueries.kt | GraphQL entry point: search(), getFacets(), getSearchSuggestions() |
search/.../builder/PipelineBuilder.kt | Orchestrates all builders into a Pipeline<Vehicle> |
search/.../builder/SearchBuilder.kt | Creates the SearchBlock ($search stage wrapper) |
search/.../builder/SearchOperatorBuilder.kt | Builds the CompoundBlock with scoring + filters |
search/.../builder/FilterBuilder.kt | Constructs all hard-constraint filter blocks |
search/.../builder/TextBuilder.kt | Fuzzy text search with per-field boost weights |
search/.../builder/ShippingBuilder.kt | Shipping fee calc + free shipping score boost |
search/.../builder/LeasingBuilder.kt | Lease filter, price projection, score boost |
search/.../builder/SearchBlockTemplateBuilder.kt | Resolves dynamic templates at query time |
search/.../builder/SearchMetaBuilder.kt | Builds $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.kt | In-memory cache for enabled score weights |
search/.../cache/PostalCodeOemRegionCache.kt | Cache for postal code to OEM region mappings |
library/.../models/SearchScoreWeights.kt | Score weights data model + SortType enum |
library/.../daos/mongo/SearchScoreWeightsDaoImpl.kt | CRUD + versioning for score weights |
library/.../mongo/aggregation/ | Custom aggregation DSL (Pipeline, Block, all operators) |
library/.../config/SearchConfig.kt | Atlas index names, text operator settings |
graphql-shared/.../models/VehicleCommonInputs.kt | Input types: Filters, SortCriteria, UserLocation |
graphql-shared/.../models/Facets.kt | FacetField enum, facet response types |
routes/.../handlers/SearchScoreWeightsHandler.kt | REST API for managing score weights |
terraform/.../mongo-indexes.tf | MongoDB index definitions (Terraform) |