Skip to main content

Yelp — Local Business Search & Reviews

Docs architecture and request flow


Problem Statement

Design a system like Yelp that allows users to discover local businesses, read and write reviews, and search by location, category, and rating. This is a geo-spatial search, content management, and social platform problem combined.


Scale & Requirements

Functional

  • Business owners can register and manage their business listing (name, address, category, hours, photos)
  • Users can search for businesses by location + keyword + filters (category, rating, price range, open now)
  • Users can write reviews (text + star rating 1–5) and upload photos
  • Users can view a business profile: info, aggregate rating, paginated reviews, photos
  • Personalized recommendations (nearby trending, similar to past visits)

Non-Functional

  • Search: p99 latency <200ms for geo+keyword queries
  • Reviews: Eventual consistency acceptable; reviews visible within seconds
  • Scale: 100M businesses globally, 200M monthly active users, 50M reviews/day reads, 500K reviews/day writes
  • Availability: 99.99% for search and business profiles
  • Geo precision: Support radius search (1km–50km), bounding box, city-level

Scale Estimates

  • Businesses: 100M × 1KB metadata = 100GB — fits in memory for a hot index
  • Reviews: 500K writes/day × 365 = ~180M/year; avg 500 bytes = 90GB/year
  • Photos: 2 photos/review avg, 250KB each → 1M photos/day → 3.5TB/month (object storage)
  • Search QPS: 200M MAU × 10 searches/day / 86400 ≈ 23K QPS peak

High-Level Architecture

┌──────────────────────────────────────────────────────────────────────┐
│ Write Path │
│ Business Registration / Review Submit │
│ → API Gateway → Write Service → DB (Postgres) │
│ → Event (Kafka) │
│ → Search Index Updater → Elasticsearch │
│ → Photo Upload → S3 → CDN │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│ Read Path │
│ Search Query (lat/lng + keyword + filters) │
│ → API Gateway → Search Service → Elasticsearch (geo+text) │
│ → Redis (hot business cache) │
│ Business Profile │
│ → API Gateway → Profile Service → Postgres + Redis │
│ → Review Service → Cassandra │
└──────────────────────────────────────────────────────────────────────┘

Data Model

Business Table (Postgres — source of truth for metadata)

CREATE TABLE businesses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id BIGINT REFERENCES users(id),
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL, -- 'restaurant', 'spa', 'plumber'
subcategories TEXT[],
phone TEXT,
website TEXT,
price_range SMALLINT CHECK (price_range BETWEEN 1 AND 4), -- $ $$ $$$ $$$$
address_line TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT,
country TEXT NOT NULL,
zip TEXT,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
hours JSONB, -- { "mon": ["09:00","21:00"], ... }
attributes JSONB, -- { "wifi": true, "parking": "lot" }
status TEXT DEFAULT 'active', -- active | closed | pending
total_reviews INT DEFAULT 0,
avg_rating NUMERIC(3,2) DEFAULT 0, -- denormalized aggregate
created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_businesses_geo ON businesses USING GIST (
ll_to_earth(lat, lng) -- PostGIS earth_distance extension
);
CREATE INDEX idx_businesses_category ON businesses(category, status);

Reviews (Cassandra — high write throughput, time-ordered reads)

Table: reviews
Partition key: business_id
Clustering key: created_at DESC, review_id

Columns: review_id, user_id, rating, text, photos[], helpful_votes,
owner_response, flagged, created_at

Why Cassandra for reviews?

  • Reviews are append-only → LSM tree is ideal
  • Query pattern is always WHERE business_id = ? ORDER BY created_at DESC LIMIT 20 → perfect for partition + clustering key
  • Write throughput is high (500K/day); Cassandra handles this better than Postgres under write load
  • Reviews older than N months move to cold storage (S3 + Athena for analytics)

Users Table (Postgres)

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
location TEXT,
elite_status BOOLEAN DEFAULT false,
review_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);

Geo-Spatial Search Deep Dive

This is the hardest part of Yelp. Naively querying WHERE lat BETWEEN x1 AND x2 AND lng BETWEEN y1 AND y2 doesn't use indexes efficiently on a spherical earth.

Geohash encodes a (lat, lng) pair into a string prefix where shared prefix = geographic proximity.

lat=37.7749, lng=-122.4194 (San Francisco)
→ Geohash: "9q8yy" (precision 5 = ~5km cell)
→ Geohash: "9q8yy1" (precision 6 = ~1km cell)

Search radius = "find all geohash cells that overlap the search circle"
1. Compute center geohash at appropriate precision
2. Find 8 neighboring geohash cells + center = 9 cells
3. Query: WHERE geohash LIKE '9q8yy%'
4. Post-filter: precise distance check on candidates
ALTER TABLE businesses ADD COLUMN geohash TEXT GENERATED ALWAYS AS (
ST_GeoHash(ST_SetSRID(ST_Point(lng, lat), 4326), 6)
) STORED;

CREATE INDEX idx_businesses_geohash ON businesses(geohash, category, status);

-- Range query: all businesses within geohash prefix
SELECT * FROM businesses
WHERE geohash LIKE '9q8yy%'
AND category = 'restaurant'
AND status = 'active'
AND earth_distance(ll_to_earth(lat, lng), ll_to_earth(37.7749, -122.4194)) < 5000
ORDER BY avg_rating DESC
LIMIT 20;

Geohash precision guide:

PrecisionCell SizeUse Case
4~40kmCity-level search
5~5kmNeighborhood search
6~1kmWalking distance
7~150mPrecise location

At 23K QPS with combined text + geo + filter queries, Elasticsearch is the right tool.

{
"query": {
"bool": {
"must": {
"multi_match": {
"query": "sushi",
"fields": ["name^3", "description", "subcategories"]
}
},
"filter": [
{
"geo_distance": {
"distance": "5km",
"location": { "lat": 37.7749, "lon": -122.4194 }
}
},
{ "term": { "category": "restaurant" } },
{ "term": { "status": "active" } },
{ "range": { "avg_rating": { "gte": 3.5 } } }
]
}
},
"sort": [{ "_score": { "order": "desc" } }, { "avg_rating": { "order": "desc" } }]
}

Index mapping stores location as geo_point type — Elasticsearch internally uses quadtree/BKD-tree for efficient geo filtering.

Sync strategy: Postgres is the source of truth. Kafka events trigger an Elasticsearch indexer that updates the index asynchronously within <1s of a business create/update. Full re-index runs weekly as a background job.

Search Ranking

Yelp doesn't just sort by distance or rating — it uses a learned ranking model:

Score = f(
text_relevance_score, // BM25 tf-idf from Elasticsearch
avg_rating, // aggregate of all reviews
review_count, // more reviews = more confidence
recency_of_reviews, // recent activity signals quality
distance_decay, // closer = higher score (but not linear)
price_range_match, // user preference match
photo_count, // richer profiles rank higher
elite_review_ratio, // Yelp Elite reviewer reviews weighted more
business_completeness_score // complete profiles rank higher
)

For MVP: use Elasticsearch's function_score query with Gaussian decay on distance. For production: train a gradient-boosted model (XGBoost/LightGBM) on click-through rate and booking conversion.


Review System Deep Dive

Write Path

User submits review
→ POST /api/businesses/{id}/reviews { rating, text, photos[] }
→ Rate limiting: 1 review per user per business (Redis SET check)
→ Spam/quality filter: ML model scores text (async, doesn't block submission)
→ Write to Cassandra: reviews table
→ Publish ReviewCreated event to Kafka

Aggregate Worker (consumes ReviewCreated):
UPDATE businesses SET
total_reviews = total_reviews + 1,
avg_rating = ((avg_rating * total_reviews) + new_rating) / (total_reviews + 1)
WHERE id = ?

UPDATE users SET review_count = review_count + 1 WHERE id = ?
Invalidate Redis cache for business profile

Why not update avg_rating atomically? High-volume concurrent reviews would cause lock contention. Instead, use an async consumer that processes in batches. Eventual consistency on the aggregate (seconds of lag) is acceptable.

Review Quality & Fraud Detection

ReviewCreated event → ML Scoring Pipeline
Features:
- Account age at review time
- Review velocity (N reviews in last 24h)
- IP geolocation vs business location
- Text similarity to previous reviews (copy-paste detection)
- Friend network (are reviewer and owner connected?)
- Review language patterns (template detection)

Output:
- score: 0.0–1.0 (higher = more likely spam/fake)
- action: PUBLISH | HOLD_FOR_REVIEW | REJECT | NOT_RECOMMENDED

"Not Recommended" reviews:
- Still stored, not counted in aggregate
- Visible under a collapsed section ("X not recommended reviews")
- Yelp's controversial but effective spam filter

Helpful Votes

POST /api/reviews/{reviewId}/vote { type: 'useful' | 'funny' | 'cool' }
→ Write to votes table (Postgres): { review_id, user_id, type, created_at }
→ Unique constraint prevents double-voting
→ Async job updates reviews.helpful_count in Cassandra
→ Vote counts used in ranking: highly-voted reviews surfaced first

Photo Storage

Client → POST /api/photos/upload
→ Server validates auth, checks quota (max 10 photos/review, 30/business)
→ Generates presigned S3 URL for direct upload
→ Client uploads directly to S3 (no bytes through app server)
→ S3 event → Lambda → resize to multiple sizes:
thumbnail: 100×100
medium: 400×300
large: 1024×768
→ Store all sizes in S3: photos/{businessId}/{photoId}/{size}.jpg
→ CDN (CloudFront) serves all photo URLs
→ Write photo metadata to Postgres: { id, business_id, user_id, s3_key, caption, status }

Moderation: Photos go through content moderation (AWS Rekognition or custom model) before being marked status = 'approved'. Rejected photos are quarantined. Business owners can flag user photos; users can flag business photos.


Business Profile API

The profile page aggregates: business metadata + recent reviews + photos + hours + similar businesses.

GET /api/businesses/{id}

Response assembled from:
1. Business metadata → Redis cache (TTL 5min) → Postgres fallback
2. Recent reviews (page 1)→ Cassandra (first 20, sorted by date)
3. Review summary → Redis (aggregate: rating dist, top keywords)
4. Cover photos (5) → Postgres + CDN URLs
5. Similar businesses → Elasticsearch (same category, nearby, high rating)
6. Owner response → Cassandra (latest owner comment on each review)

All assembled in parallel → &lt;50ms server time

Cache strategy:

Key: business:{id}:profile
TTL: 5 minutes
Invalidation: on review write, business update, new photo approval

Recommendations & Personalization

Offline (batch, nightly):
Collaborative filtering: users who visited A also visited B
→ Stored as: user:{id}:recommendations → [businessId, score][]

Online (real-time per request):
"Near you" feed: Elasticsearch geo query + trending weight
"Trending in {city}": Redis sorted set, updated by review/view velocity
"You might like": served from offline model output in Redis

Trending sorted set (Redis):
ZINCRBY trending:{city}:{category}:{date} 1 {businessId}
ZREVRANGE trending:sf:restaurant:2026-08-02 0 9 → top 10 trending restaurants in SF today
Expire key at midnight

Open / Closed in Real-Time

"Open now" is a filter users commonly apply. Business hours are stored in JSONB:

{ "mon": ["09:00","21:00"], "tue": ["09:00","21:00"], ..., "sun": null }

At query time:

def is_open_now(hours: dict, tz: str) -> bool:
local_time = datetime.now(pytz.timezone(tz))
day = local_time.strftime('%a').lower() # 'mon', 'tue', ...
if not hours.get(day):
return False
open_t, close_t = hours[day]
current = local_time.strftime('%H:%M')
return open_t <= current <= close_t

This runs in the application layer, not the DB. Elasticsearch indexes a precomputed is_open_now boolean updated hourly via a background job (avoids per-request timezone math at 23K QPS).


Infrastructure & Capacity

ComponentTechnologyWhy
Business metadataPostgresACID, complex queries, PostGIS for geo
ReviewsCassandraHigh write throughput, time-ordered reads
SearchElasticsearchGeo+text+filter combined queries
CacheRedisHot business profiles, trending sets
PhotosS3 + CloudFrontBlob storage, CDN delivery
EventsKafkaAsync fanout: index updates, aggregates, ML pipeline
RecommendationsRedis + offline batchLow-latency serving

Failure Scenarios & Mitigations

FailureImpactMitigation
Elasticsearch downSearch unavailableFallback to Postgres geo query (slower but correct); circuit breaker
Cassandra node failurePartial review readsReplication factor 3 + QUORUM reads; remaining nodes serve requests
Review aggregate consumer lagStale avg_ratingAcceptable — <30s lag; display "ratings updating" if lag detected
Photo upload S3 failureUser sees errorRetry with exponential backoff; queue upload for background retry
Spam filter outageSpam reviews publishedBuffer reviews in Kafka; process once filter recovers; manual review queue
Redis eviction under memory pressureCache misses spikeDB handles load; auto-scaling; LFU eviction to keep hottest data

Key Design Decisions & Trade-offs

DecisionRationaleTrade-off
Cassandra for reviewsWrite throughput, time-ordered readsNo complex queries; must denormalize for other access patterns
Elasticsearch for searchGeo+text+filter in one query, 23K QPSEventual consistency with Postgres (seconds lag); infra cost
Geohash prefix searchSimple, fast, works at scaleBoundary artifacts (cells near geohash boundary need neighbor cells)
Async aggregate updatesAvoids write contention on avg_ratingSeconds of lag; acceptable for review aggregates
"Not Recommended" instead of deletePreserves data, auditableControversial; reduces trust if over-applied
Denormalized avg_rating in business rowFast reads; no join neededMust keep in sync with reviews; eventual consistency

Staff Interview Follow-ups

"How do you handle the 'open now' filter at 23K QPS?" Pre-compute is_open_now boolean in Elasticsearch indexed field, updated by a background job every hour (or triggered on hour boundaries). At query time, simply filter "term": \{"is_open": true\}. Avoids per-request timezone arithmetic. Accuracy: worst case 59-minute stale for a business that changed hours — acceptable.

"How do you prevent fake reviews?" Multi-layer defense: rate limiting (1 review per user per business), account age gate (accounts <7 days old flagged), IP/device fingerprinting, ML text analysis (template detection, sentiment anomalies), graph analysis (reviewer-owner relationship), behavioral signals (reviewing only 1-star or only 5-star). "Not Recommended" bucket instead of delete — keeps data for model training.

"How would you scale the search to handle global traffic spikes?" Elasticsearch cluster with dedicated data nodes per region (US, EU, APAC). Route search queries to nearest regional cluster via GeoDNS. Each region maintains a full replica of its regional data + a partial replica of global popular businesses. Index sharding: shard by geohash prefix so geo queries hit minimal shards. Auto-scaling data nodes on CPU and heap pressure.

"What's your strategy for photo storage at 1M uploads/day?" Client-direct S3 multipart upload (no bytes through app servers). Lambda-triggered resizing pipeline generates 3 sizes. Content moderation before CDN publication. Storage tiering: recent photos in S3 Standard; photos older than 1 year move to S3 Infrequent Access; very old/rarely accessed photos to Glacier. CDN cache-control: public, max-age=31536000, immutable (content-addressed keys never change). Total monthly cost at 3.5TB/month ingestion: ~$80/month S3 + CloudFront egress.

"How do you handle business owners disputing a review?" Flag → enters manual review queue (staffed moderation team). Automated escalation: if business has >10 flags on a single review, auto-hold pending human review. Owner can post a public "owner response" visible below the review (most effective resolution). If review violates ToS (personal info, off-topic) → remove. If review is legitimate negative opinion → keep, owner can respond publicly. Track flag abuse: owners who over-flag get their flag weight reduced.