← Home Lecture 14 Β· Indexing & the Aggregation Framework
1 / 19 πŸ“„ Notes
Module 2 Β· Lecture 14

Indexing & the Aggregation Framework

Making queries fast β€” then turning 15,000 raw bookings into the handful of numbers a management meeting actually needs.

⏱ 90 minutes 🎯 CLO3 πŸ“¦ MongoDB 201 πŸ—‚ bdma.rides Β· 15,000 docs
Where We Are

Two halves, one collection

Lecture 13 gave you find() and the query operators. Every query you wrote worked. None of them were fast β€” the collection was simply small enough to hide it.

Half 1 Β· Indexing (~40 min)

Why an unindexed query is O(n), how a B-tree fixes it, the ESR rule for compound indexes, and how to read explain() β€” plus why more indexes is not better.

Half 2 Β· Aggregation (~40 min)

Pipelines as a conveyor belt of stages. Seven real business questions answered against bdma.rides, and the output materialised for Lecture 15's dashboard.

The through-line: indexing makes retrieval fast; aggregation turns retrieval into an answer. A dashboard needs both.
The Problem & The Fix

A scan reads everything. A B-tree does not.

With no usable index, db.rides.find({ "route.pickup": "Connaught Place" }) reads all 15,000 documents to return ~1,139. Plan stage: COLLSCAN β€” and it is O(n), with no early exit.

Collection sizeDocs read per queryResponseBusiness reality
15,000 (today)15,000~30 msFeels instant β€” the problem is invisible
1,500,0001,500,000~3 sA dashboard tile that visibly stalls
15,000,00015,000,000~30 sTimeouts. The report is abandoned, not slow.

COLLSCAN β€” O(n)

Read the whole book to find one sentence. Twice the data, twice the work β€” and every document dragged through RAM evicts useful data from cache, slowing unrelated queries too.

IXSCAN β€” O(log n)

A sorted B-tree of values plus pointers: read the index page, turn to page 412, read one sentence. Finding one doc among 15 million costs about twice as much as among 15 thousand β€” not a thousand times.

Translation: a 400 ms query becomes ~12 s at 30Γ— the data. That is not degradation β€” it is the difference between a report people open every morning and one they stop opening.
Syntax

createIndex() β€” and when direction matters

db.rides.createIndex({ status: 1 })                 // 1 = ascending
db.rides.createIndex({ booking_value: -1 })         // -1 = descending
db.rides.createIndex({ "route.pickup": 1 }, { name: "pickup_idx" })

db.rides.getIndexes()
db.rides.dropIndex("pickup_idx")

For a single-field index, direction is almost irrelevant β€” a B-tree walks either way. It becomes decisive for a compound sort, which an index serves only if the sort matches it exactly, or is its exact mirror.

db.rides.createIndex({ vehicle_type: 1, booking_value: -1 })

.sort({ vehicle_type:  1, booking_value: -1 })   // βœ… exact match
.sort({ vehicle_type: -1, booking_value:  1 })   // βœ… exact mirror
.sort({ vehicle_type:  1, booking_value:  1 })   // ❌ in-memory SORT
Why the ❌ hurts: in-memory sorts have a hard 100 MB limit. Past it the query does not slow down β€” it fails. A report that passed testing can break purely because the data grew.
The Toolkit

Five index types worth knowing by name

TypeCreated withForOn rides
Single-field{ status: 1 }Filter or sort on one fieldEvery "completed only" query
Compound{ status: 1, date: 1 }Several fields at once; serves prefixes too"Completed rides in March, newest first"
Multikey{ tags: 1 }Automatic on arrays β€” one entry per elementA surge_flags array
Text{ "cancellation.reason": "text" }Word search with stemming; one per collectionKeyword search of cancel reasons
TTL{ expireAfterSeconds: 86400 }Auto-delete after an interval; needs a real DateExpiring driver-location pings

Also: unique (natural for booking_id), partial β€” ideal here, since booking_value is absent on ~5,000 cancelled bookings β€” and 2dsphere for "drivers within 3 km".

TTL as policy: "we do not keep raw location data beyond 30 days" becomes one line of config, not a cron job nobody maintains.
Compound Indexes

The prefix rule, then ESR

Given { status: 1, vehicle_type: 1, booking_value: -1 }, the index serves any query using a left prefix of its fields:

Query filters onUsable?
status  Β·  status + vehicle_type  Β·  all threeβœ… Yes
vehicle_type alone  Β·  booking_value alone❌ No β€” skips a leading field

The ESR rule β€” order compound fields Equality β†’ Sort β†’ Range

E exact matches first Β· S sort fields next Β· R range operators ($gt, $lt, $in over a span) last.

Why: equality narrows the B-tree to one contiguous block, inside which entries are still ordered by the next field β€” so the sort is free. A range selects a span, so anything after it is no longer in one sorted run and must be re-sorted in memory.

One well-ordered compound index often replaces three single-field ones: fewer indexes, less RAM, cheaper writes.
ESR Β· Worked on rides

"Completed Go Sedan rides over β‚Ή500, newest first"

db.rides.find({
  status: "Completed",          // E β€” equality
  vehicle_type: "Go Sedan",     // E β€” equality
  booking_value: { $gt: 500 }   // R β€” range
}).sort({ date: -1 })           // S β€” sort

db.rides.createIndex({ status: 1, vehicle_type: 1, date: -1, booking_value: 1 })
//                     └── E β”€β”€β”˜  └──── E β”€β”€β”€β”€β”€β”˜  └─ S β”€β”˜  └───── R β”€β”€β”€β”€β”€β”˜
Field orderWhat happensVerdict
status, vehicle_type, date, booking_valueEquality narrows the block; it is already ordered by date; range applied lastESR βœ…
status, vehicle_type, booking_value, dateRange fans the scan, so date loses its order β†’ an in-memory SORT appearsSlow
booking_value, status, vehicle_type, dateLeads with a range: the scan starts broad and stays broadWorst
Watch carefully: all three produce IXSCAN. "The index is being used" is not evidence the index is good. Only ESR gets docs-examined close to docs-returned.
Diagnosis

explain("executionStats") β€” four numbers

db.rides.find({ "route.pickup": "Connaught Place" })
        .explain("executionStats")
FieldMeaningWhat you want
stageCOLLSCAN / IXSCAN / FETCH / SORTIXSCAN. A SORT stage means an in-memory sort.
nReturnedDocuments actually returnedYour denominator β€” the useful work
totalDocsExaminedDocuments the engine had to readAs close to nReturned as possible
executionTimeMillisWall-clock timeThe headline β€” and the least trustworthy number

The metric is a ratio, not a time

totalDocsExamined Γ· nReturned. 1:1 is perfect. 10:1 is tolerable. 1,000:1 means the database did a thousand times more work than the answer needed β€” and it scales exactly that badly.

Prefer the ratio because it is hardware- and cache-independent. 12 ms on a warm laptop cache with a 1,000:1 ratio is an incident waiting for a cold start.
Before Β· After Β· Covered

Three tiers of query efficiency

❌ Scan β€” 13:1

stage: "COLLSCAN"
nReturned: 1139
totalDocsExamined: 15000
executionTimeMillis: 28

βœ… Indexed β€” 1:1

// createIndex({"route.pickup":1})
stage: "IXSCAN"
nReturned: 1139
totalDocsExamined: 1139
executionTimeMillis: 3

πŸ† Covered β€” 0 docs

stage: "PROJECTION_COVERED"
nReturned: 1763
totalDocsExamined: 0

A covered query is answered from the index alone β€” every filtered and returned field lives in the index, so the documents are never touched. Note that _id must be explicitly excluded:

db.rides.createIndex({ status: 1, vehicle_type: 1, booking_value: 1 })
db.rides.find(
  { status: "Completed", vehicle_type: "Go Sedan" },
  { _id: 0, vehicle_type: 1, booking_value: 1 }
)
Why engineer for it: indexes are far smaller than documents, so they are far likelier to be resident in RAM. Covered queries are the ideal shape for exactly the few-field, high-frequency reads dashboards make.
The Other Side of the Ledger

Indexes are not free β€” more is not better

1 Β· Every write pays

An insert writes the document plus one entry per index. Twelve indexes β‰ˆ thirteen units of write work. The analytics team's indexes are paid for, permanently, by the booking ingest path.

2 Β· RAM is the whole game

Your working set is the data + indexes queries touch regularly. Fits in RAM β†’ fast. Doesn't β†’ disk reads, often 50Γ— slower. Indexes compete with documents for that same cache.

3 Β· Unused indexes are pure cost

They still slow writes and still occupy RAM, while contributing nothing.

db.rides.aggregate([{ $indexStats: {} }])
// accesses.ops: 0 β†’ drop candidate

Atlas Performance Advisor (Clusters β†’ Performance Advisor) profiles real traffic and ranks index recommendations by expected impact, flagging unused and redundant ones too.

Use it, don't outsource judgement to it. It optimises each slow query in isolation β€” it knows nothing about your write volume or RAM budget. Accept a recommendation when you can explain it in ESR terms.
Half 2 Β· The Mental Model

A pipeline is a conveyor belt of stages

db.rides.aggregate([
  { $match: { status: "Completed" } },                  // 15,000 β†’ 9,343
  { $group: { _id: "$vehicle_type",
              revenue: { $sum: "$booking_value" } } },  //  9,343 β†’ 7
  { $sort:  { revenue: -1 } },                          //      7 β†’ 7, ordered
  { $limit: 3 }                                         //      7 β†’ 3
])
// β†’ Premier Sedan β‚Ή5,36,855 Β· Go Sedan β‚Ή4,91,473 Β· Uber XL β‚Ή4,31,927
SQLStageSQLStage
WHERE$matchORDER BY$sort
GROUP BY$group (key is always _id)LIMIT / OFFSET$limit / $skip
HAVING$match after $groupLEFT OUTER JOIN$lookup
SELECT col, expr$project / $addFieldsCOUNT(*)$count or { $sum: 1 }
Read a pipeline top to bottom as an English sentence. HAVING and WHERE are the same stage β€” only the position differs, and the position is the meaning.
Vocabulary

The stages and accumulators you need

Stages

$matchFilter the stream
$groupCollapse to one doc per key
$projectChoose / compute output fields
$addFields / $setAdd fields, keep the rest
$unwindArray of n β†’ n documents
$lookupLeft outer join β†’ array
$count / $bucketCount Β· numeric histogram
$facetMany sub-pipelines, one pass

Accumulators (inside $group)

$sumTotal; $sum: 1 counts
$avgMean β€” ignores missing
$min / $maxExtremes
$pushAll values, duplicates kept
$addToSetDistinct values only
$first / $lastSort first, or it is arbitrary
The $avg trap: it skips missing fields rather than treating them as zero. So $avg: "$booking_value" over all 15,000 rides silently returns the average completed fare (β‚Ή235.73). Make the population explicit with $match.
The Golden Rule

$match and $limit as early as possible

❌ Late filter

db.rides.aggregate([
  { $group: { _id: "$vehicle_type",
      revenue: { $sum: "$booking_value" }}},
  { $match: { _id: "Go Sedan" } }
])
// groups all 15,000, discards most

βœ… Early filter

db.rides.aggregate([
  { $match: { vehicle_type: "Go Sedan" } },
  { $group: { _id: "$vehicle_type",
      revenue: { $sum: "$booking_value" }}}
])
// groups 2,854 β€” a fifth of the work

Fewer documents for every later stage is merely arithmetic. The decisive reason is different: only a $match in the first position can use an index. Once any stage has transformed the stream, the documents are no longer the indexed documents on disk β€” every later $match is an in-memory filter over intermediate results.

Same logic for $sort: leading $sort β†’ index-served. After $group β†’ in-memory, against a hard 100 MB stage limit. $sort + $limit adjacent gets a special top-n optimisation.

Verify, don't assume: db.rides.aggregate([…]).explain("executionStats") β€” check the first stage. IXSCAN is the goal, COLLSCAN is the warning.
Questions 1 & 6

Two findings that are usefully negative

// Q1 β€” completion rate by vehicle type. $cond inside $sum gives you
// numerator and denominator in a single pass over the data.
db.rides.aggregate([
  { $group: {
      _id: "$vehicle_type",
      total:     { $sum: 1 },
      completed: { $sum: { $cond: [{ $eq: ["$status", "Completed"] }, 1, 0] } },
      revenue:   { $sum: "$booking_value" }
  }},
  { $addFields: { completion_rate_pct: { $round: [
      { $multiply: [{ $divide: ["$completed", "$total"] }, 100] }, 2 ]}}},
  { $sort: { revenue: -1 } }
])
VehicleBookingsCompletionRevenueAvg fare
Premier Sedan1,95462.13%β‚Ή5,36,855β‚Ή442.22
Go Sedan2,85461.77%β‚Ή4,91,473β‚Ή278.77
Uber XL1,23162.79%β‚Ή4,31,927β‚Ή558.77

Q6 β€” average driver rating by vehicle, completed rides only ($match on status + "ratings.driver": { $exists: true }) β€” lands every type between 4.39 and 4.42, against a fleet average of 4.41.

Report the dimension that discriminates β€” and say which one doesn't. Completion sits within a point of 62% everywhere and ratings within 0.03, so neither explains the 37.71% failure rate. Revenue varies more than tenfold. That is where to look.
Questions 2 & 3

Dates are strings here β€” and that is the lesson

booked_at ("2024-03-14T18:22:07Z") and date ("2024-03-14") are strings, not BSON Dates. ISO strings sort lexicographically in date order, so substrings work β€” or convert properly.

// Q2 β€” revenue by month
db.rides.aggregate([
  { $match: { status: "Completed" } },
  { $group: { _id: { $substr: ["$date", 0, 7] },        // "2024-03"
              revenue: { $sum: "$booking_value" },
              rides:   { $sum: 1 } }},
  { $sort: { _id: 1 } }
])
// β†’ Mar strongest β‚Ή1,99,972 / 859 rides Β· Jun weakest β‚Ή1,66,260
//   twelve months sum to the β‚Ή22,02,449 of completed value

// Q3 β€” busiest hour of day
db.rides.aggregate([
  { $group: { _id: { $toInt: { $substr: ["$booked_at", 11, 2] } },
              bookings: { $sum: 1 } }},
  { $sort: { bookings: -1 } }, { $limit: 5 }
])

// The convert-first alternative:
{ $hour: { $dateFromString: { dateString: "$booked_at" } } }
{ $dateToString: { format: "%Y-%m",
    date: { $dateFromString: { dateString: "$booked_at" } } } }
Fix types at ingestion, not in every downstream pipeline. Converting 15,000 docs per query is cheap; converting 15 million is a daily tax β€” and strings support no range arithmetic, no time zones, no date-aware indexes.
Question 3 Β· The Finding

Demand is bimodal β€” and supply arrives late

Hours carrying real volume

HourBookingsFail %
18:001,37337.14%
19:001,24635.96%
09:001,17236.18%
08:001,07636.71%
17:001,06239.92%

⚠️ 17:00 β€” the weak point

39.92%

worst failure rate among the hours that carry volume

Higher than the 18:00 peak's 37.14% β€” even though 18:00 takes 1,373 bookings to 17:00's 1,062. The evening surge begins at 17:00, and that is exactly where completions collapse.

🚫 Rank by rate alone and you get this

03:004643.48%
07:0063741.60%
16:0076440.45%

46 bookings all year β€” one every eight days. One extra failure moves that rate two points. It is noise, and it would have you staffing drivers at 3 a.m.

{ $match: { bookings: { $gte: 1000 } } }
// ← guard, after $group, before ranking
A rate without a denominator is not a finding. State the threshold out loud: "worst failure rate, among hours with at least 1,000 bookings". Then the recommendation writes itself β€” time driver incentives to 16:30, not 18:00.
Questions 4 & 5

Where and why bookings fail

// Q4 β€” cancellation rate by pickup. $match AFTER $group = SQL's HAVING:
// a rate on a thin sample is noise, so guard with a volume threshold.
db.rides.aggregate([
  { $group: {
      _id: "$route.pickup",
      bookings:  { $sum: 1 },
      cancelled: { $sum: { $cond: [{ $in: ["$status",
          ["Cancelled by Driver","Cancelled by Customer","No Driver Found"]] }, 1, 0] }}
  }},
  { $match: { bookings: { $gte: 1000 } } },
  { $addFields: { cancellation_rate_pct: { $round: [
      { $multiply: [{ $divide: ["$cancelled","$bookings"] }, 100] }, 2 ]}}},
  { $sort: { cancellation_rate_pct: -1 } }, { $limit: 10 }
])

Worst pickups

Noida Sector 621,20933.50%
New Delhi Rly Stn1,17933.16%
Connaught Place1,13932.84%
IGI Airport1,18332.80%
Gurgaon Cyber City1,17831.24%

Q5 β€” who cancels, and why

Driver (2,698): customer related issue 27.02% Β· more than permitted people 24.68% Β· personal & car 24.17% Β· customer coughing/sick 24.13%

Customer (1,022): driver not moving toward pickup 20.65% Β· AC not working 20.55% Β· driver asked to cancel 20.35% Β· change of plans 19.86% Β· wrong address 18.59%

The asymmetry is the story. Drivers cancel 2.6Γ— more than customers β€” and "driver asked to cancel" sits inside a fifth of customer cancellations too. The reported split understates the driver side. Intervene on supply behaviour.
Question 7 Β· Serving the Dashboard

Every KPI in one pass, then materialise it

db.rides.aggregate([{ $facet: {
  headline: [
    { $group: { _id: null,                       // null = one grand total
        total_bookings: { $sum: 1 },
        completed: { $sum: { $cond: [{ $eq: ["$status","Completed"] }, 1, 0] }},
        completed_value: { $sum: "$booking_value" },
        avg_distance_km: { $avg: "$ride_distance" } }}
  ],
  by_status:   [ { $group: { _id: "$status", n: { $sum: 1 } } },
                 { $sort: { n: -1 } } ],
  payment_mix: [ { $match: { payment_method: { $exists: true } } },
                 { $group: { _id: "$payment_method", n: { $sum: 1 } } } ],
  fare_bands:  [ { $match: { booking_value: { $exists: true } } },
                 { $bucket: { groupBy: "$booking_value",
                     boundaries: [0,100,250,500,1000], default: "1000+",
                     output: { rides: { $sum: 1 } } } } ]
}}])
// headline β†’ 15,000 bookings Β· 9,343 completed Β· 62.29% Β· β‚Ή22,02,449 Β· 10.87 km
// payment_mix β†’ UPI 45.03% Β· Cash 25.31% Β· Wallet 11.79% Β· Credit 10.79% Β· Debit 7.08%

Then write the answer down once, so the dashboard reads results instead of raw data:

{ $out: "monthly_revenue" }                              // full rebuild, destructive
{ $merge: { into: "monthly_revenue", on: "_id",
            whenMatched: "replace", whenNotMatched: "insert" } }   // incremental, safe
Careful: a $facet sub-pipeline cannot use an index β€” the stream is already read. Put any index-eligible $match before the $facet, never only inside it.
Wrap-Up

Six things to carry forward

  1. An unindexed query is O(n). COLLSCAN reads everything; a B-tree IXSCAN is ~O(log n), so growth stops mattering.
  2. Compound field order is decided by ESR β€” Equality, Sort, Range β€” because a range selects a span, and nothing stays sorted behind a span.
  3. Judge a query by totalDocsExamined Γ· nReturned, not milliseconds. 1:1 is perfect; a covered query examines zero documents.
  4. Indexes are not free. Every write pays for every index and they compete with documents for RAM. Index the queries you run; drop what $indexStats shows unused.
  5. A pipeline is an ordered conveyor belt that maps almost one-to-one onto SQL: $matchβ‰ˆWHERE, $groupβ‰ˆGROUP BY, $sortβ‰ˆORDER BY, $projectβ‰ˆSELECT, $lookupβ‰ˆLEFT JOIN.
  6. $match and $limit go first β€” later stages then do less work, and only a leading $match can use an index. Materialise with $out/$merge.
Next β€” Lecture 15: Atlas Charts & the Uber dashboard. Your $facet becomes the KPI header, the hourly pipeline becomes the bimodal demand curve with its 17:00 spike, and monthly_revenue drives the trend line. Bring working pipelines β€” the charts are only as good as the queries underneath.
← β†’ or Space to navigate Β· F for fullscreen