Dating app-ын санал болгох алгоритм — Flint-ийг хэрхэн бүтээсэн тухай дэлгэрэнгүй кейс
Flint бол миний програмчлан бүтээсэн гэж хэлж болохуйц dating app. Энэ постонд би яг тэр аппликейшны хамгийн чухал хэсэг — "хэнтэй танилцуулах вэ" гэдгийг шийдэх санал болгох алгоритмыг яаж дизайнчилсан, ямар асуудлуудтай тулгарсан, тэдгээрийг хэрхэн шийдсэн тухай дэлгэрэнгүй бичлээ. Reciprocal scoring, Elo desirability, cold start стратеги, tier-based weight гээд бодит код, бодит туршлагаас үндэслэсэн дэвтэр шиг урт пост.
Flint бол миний бүтээж байгаа dating app. NestJS + GraphQL + MongoDB үндсэн backend, мобайл клиент талдаа Expo/React Native, энэ бүхний дунд тусдаа FastAPI дээр явдаг — санал болгох алгоритмын микросервис.
Энэ постонд би тэр микросервисийн дотор юу болж байгааг, яагаад тэгж бүтээсэн шалтгаан, бодит код, бодит зовлонгуудаа дэлгэрэнгүй бичлээ. Энэ нь "docs" биш — кейс судалгаа. Тиймээс зөвхөн "яаж ажилладаг вэ" гэдгээс гадна яагаад тэгж шийдсэнийг ч бас тайлбарласан.
Дотор юу байх вэ
- Асуудлын тодорхойлолт — dating recommendation яагаад YouTube/Netflix-тэй адилхан биш
- Архитектур — яагаад тусдаа микросервис, яагаад 3 database
- Pipeline — 4 stage-ийн дотор
- Cold start — хамгийн хэцүү хэсэг
- Scoring engine — 6 signal, tier-based weight
- Elo desirability rating system
- Behavioral affinity + Reciprocity (ALS factor vectors)
- Reranker — бизнес дүрмүүд
- Тав тухтай ажиллахын тулд хийсэн edge case-ийн заслууд
- Хийгээгүй зүйл, дараагийн алхамууд
Энэ постод буй техник дизайныг би өөрөө 90+ эх сурвалж, Tinder, Hinge, Bumble, OkCupid, Coffee Meets Bagel зэрэг апп-уудын engineering blog, академик paper, ML архитектурын document-уудыг уншиж, Flint-ийн өөрийн нөхцөл байдалд тохируулан дахин зохион байгуулсан.
1. Асуудлын тодорхойлолт
Эхлээд хамгийн чухал санааг ил тавья: Dating app-ын recommendation систем нь YouTube-ийн "чамд таалагдах видео"-той огт адилхан биш.
Яагаад гэвэл:
| YouTube / Netflix | Dating app |
|---|---|
| Хэрэглэгч → Контент (нэг талт) | Хэрэглэгч ↔ Хэрэглэгч (хоёр талт) |
| Зөвхөн P(хэрэглэгч контент хүсэх вэ) | P(А-д Б таалагдах) × P(Б-д А таалагдах) |
| Алгасах нь neutral (Оноо нэмэх ч үгүй, хасах ч үгүй) | Татгалзал нь оноонд нөлөөлдөг |
| Контент хязгааргүй (1 видео-г сая хүн үзэж болно) | Анхаарлын төсөв хязгаартай (1 хүнд хязгаартай хугацаа) |
Өөрөөр хэлбэл dating app дээр алгоритм нь mutual interest гэдгийг таамаглах ёстой. Зөвхөн "А-д Б таалагдсан" гэдгийг ойлгоод болохгүй — Б нь А-г харахаар яасан байх вэ гэдгийг бас урьдчилан таамаглах ёстой.
Энэ нь reciprocal recommendation problem гэдэг. Судалгаа ёсоор reciprocal scoring нь нэг талт scoring-оос +27–37% илүү match үүсгэдэг гэдгийг RECON framework нотолсон байдаг.
Cold start — давхар хүнд
Энгийн recommendation систем дээр шинэ хэрэглэгч ороход ямар фильм/видео санал болгох нь тодорхойгүй байдаг (User cold start). Dating app дээр энэ давхарласан байдаг:
- User cold start — шинэ нэвтэрсэн хэрэглэгч 0 swipe-тэй
- Two-sided cold start — шинэ нэвтэрсэн хэрэглэгчийн профайл бусдын feed-д хэдэн удаа гарсан болохыг бид мэдэхгүй
- Item cold start — шинэ профайлууд (item-ийг хайр сэтгэлийн зах зээл дээр "item" гэж дуудах нь хачирхалтай) дотор бас тийм байна
Хэрэв шинэ хэрэглэгчид зөвхөн хамгийн алдартай профайлууд харагдаад байвал — "popularity spiral" гэдэг үзэгдэл үүсэх вий: топ 10% профайл бүх like-ийг авах, бусад нь хог дотор алдагдах.
Gender imbalance
Tinder-ийн нийтэлсэн статистикаар эрчүүд 46% профайлд баруун swipe хийдэг, эмэгтэйчүүд ердөө 14%. Энэ нь дискаунттай арифметик хэрэглэлгүйгээр л харж байхад асар их imbalance. Хэрэв алгоритм нь зүгээр л "like-ийн тоо" дээр үндэслэвэл эмэгтэйчүүдийн like нь маш үнэ цэнэтэй болж — топ эмэгтэйчүүд аль хэдийн орон зайд харагдахаа больсон байна.
2. Архитектур
Нэвтрэх цэг бол NestJS GraphQL backend. Мобайл клиент бүх request-ийг үүгээр явуулдаг. Гэвч recommendation логик нь:
- ML/scoring-ийн математик ихтэй
- Pandas/NumPy шиг тоон бодолтоор хийгдэх ёстой
- Гол backend-ийн event loop-ыг блоклож болохгүй
- А/B тестлэх, model swap хийх боломж дунд багадаа байх ёстой
- Туслах өгөгдөл нь зарим тохиолдолд OLAP-тай төстэй query хэрэгтэй (percentile, partitioned event тable)
Иймээс би тусдаа FastAPI микросервис болгож салгасан.
┌──────────────────┐ ┌──────────────────────────┐
│ NestJS Backend │ │ Flint Algorithm │
│ (GraphQL) │ │ (FastAPI) │
│ │ │ │
│ Auth │ │ GET /candidates │
│ Users CRUD │ HTTP │ POST /swipes │
│ Chat │──────>│ POST /events │
│ Payments │ │ GET /leaderboard │
│ Match creation │ │ GET /metrics/* │
└──────┬───────────┘ └────────┬─────────────────┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ MongoDB │ │ Redis │ │ PostgreSQL │
│(shared) │ │ (shared) │ │ (algo-only) │
└─────────┘ └──────────┘ └──────────────┘
Яагаад 3 database вэ?
Энэ нь "микросервис дандаа өөрийнхөө database-тэй байх ёстой" гэдэг dogma-аас гарсан зүйл биш. Үндсэн шалтгаан нь database тус бүр өөрийн гайхалтай шинж чанартай учраас:
- MongoDB (shared, read-only) — User профайлууд аль хэдийн энд байна. Алгоритм нь зөвхөн
$geoNearaggregation-аар ойролцоо хүнийг түүх хэрэгтэй болохоор шууд унших нь хамгийн хямд. Schema-ийг өөрөө эзэмшихийг хүсэхгүй — NestJS-н схем эзэн. - PostgreSQL (algo-owned) — Swipe history, Elo rating, behavioral events. Энэ дата нь:
- Аналитик query-уудтай (percentile, GROUP BY user_id, COUNT)
- Range partition-той (
behavioral_eventstable сар бүр partition үүсгэдэг) - Transactional update-той (Elo rating-ийн write-through) Mongo дээр энэ бүхнийг хийх боломжтой ч, тийм биш — Postgres дээр илүү байгалийн.
- Redis (shared) — Cooldown set (бид нэг хүнийг 2 удаа харуулахгүйн тулд), feature hash, Elo cache, ALS factor vectors, pair interaction-ы hash. Энэ бол hot path-ийн data store.
JWT хэрхэн хуваалцах вэ
Доторх auth: хоёр сервис ижил JWT_SECRET-той. NestJS-аас гарсан токен FastAPI дээр шууд validate хийгдэнэ. Гэхдээ нэг гайхалтай зүйл: NestJS-ийн sub claim нь зүгээр string биш, object { userId: "<id>" }. Тиймээс FastAPI-ийн auth handler хоёр форматыг хоёуланг нь дэмждэг (app/core/auth.py). Ийм "бид багавтар differences"-ийг нэгтгэх нь microservice setup-ийн тав тухтай байдлын 30%-ийг бүрдүүлдэг.
3. Recommendation Pipeline
Алгоритм нь 4 stage-тэй:
Stage 1 Stage 2 Stage 3 Stage 4
Hard Filters → Candidate Gen → Scoring → Re-Ranking
(instant) (~100ms) (~50ms) (business)
Сая → 10K 10K → 500 500 → 50 Final feed
Энэ дарааллыг яг нэг ийм байдлаар хадгалах нь маш чухал. Магадгүй "бүх хүн дээр scoring шууд явуулчихаар яасын бэ?" гэдэг асуулт гарч ирж байгаа байх. Гарч ирвэл хариулт нь:
- 100k хэрэглэгчтэй базын дотор хэрэглэгч бүрд 100k score тооцоолох нь ~10 секунд цаг шаардана.
- Гэтэл хэрэглэгчид 90% хүн нь "насны хязгаар, хүйс, газар зүй" гэдэг hard filter-аар шууд хасагдчихна.
- Hard filter-ийг index ашиглан Mongo-д хийвэл 50ms-ын дотор багтана.
Өөрөөр хэлбэл бид хямд алхамыг үнэтэй алхмын өмнө хийнэ.
Stage 1 — Hard filters
MongoDB-ийн $geoNear aggregation дотроос дараах нөхцлүүдийг хасч авна:
gender IN (user.preferences)— хэрэглэгчийн хүсэх хүйсdob BETWEEN min AND max— насны хязгаар_id NOT IN (cooldown set)— өмнө харсан профайлууд (Redis)_id NOT IN (blocked users)— bidirectional блок (blockscollection)_id NOT IN (interacted users)— баруун swipe, super-like хийгдсэн хүмүүс (interactionscollection)distance < radius_km—$geoNearрекүний радиус
Энэ stage-ийн гаралт нь ~10,000 хэрэглэгчийн ID. Их хэмжээний өгөгдлийг хямд хэлбэрээр шүүж байгаа.
Stage 2 — Candidate generation
LLM системүүд дээр энэ stage-ийг "retrieval" гэж нэрлэдэг. Бид хэрэглэгчийн дугаарыг 10,000-аас ~500 болгож хасах ёстой. Шалтгаан: дараагийн stage дээрх scoring engine нь хэрэглэгч бүрд 6 signal тооцоолох болохоор 10k-аас илүү ачаалал зөвшөөрөгдөхгүй.
Flint-ийн одоогийн implementation нь $geoNear-ийн distance-аар sorted-аар buffer-ийг хадгалдаг — 500 ширхэг.
Ирээдүйд энэ stage-д ANN (Approximate Nearest Neighbors) embedding хайлт орох төлөвлөгөөтэй. Гэвч одоогоор embedding-ийн зардал нь авч ирэх benefit-аас зөв тэнцвэртэй биш — учир нь өгөгдлийн хэмжээ хараахан жижиг.
Stage 3 — Scoring
Энэ нь үлдсэн 500 хэрэглэгчийг 6 signal ашиглан 0-ээс 1.0-ийн хооронд score-уудаар жигнэж буцаах хэсэг.
Scoring engine-ийн талаар 5-р хэсэгт нарийвчилж ярина.
Stage 4 — Reranking
Энэ stage нь хамгийн "бизнес" хамгийн их хэсэг:
- New user visibility boost — Шинэ хэрэглэгчийн профайлыг харагдалт болгох. Энэ нь cold start-ын нөгөө тал — "шинэ хэрэглэгчийн профайлыг бусдад харуулах".
- Premium boost — Premium-ийн профайлд жижиг multiplier (1.2x).
- Profile boost — Хэрэглэгч худалдаж авсан "30 минутын boost" идэвхтэй байвал 5x multiplier.
- Golden swipes — Сүүлийн swipe үлдсэн бол хамгийн өндөр score-той хүнийг харуул.
- Active boost decay — boost-ийн multiplier нь хугацаа явах тусам багасна.
Эдгээр дүрмүүдийг scoring engine-аас тусдаа reranker.py дотор хадгалсан. Шалтгаан нь:
- Бизнес дүрэм нь маш олон удаа өөрчлөгдөнө (premium plan-ны үнэ, boost-ийн magnitude, гэх мэт)
- Эдгээр өөрчлөлтийг ML scoring engine-аас тусгаарлаж бичих нь A/B тестлэх боломжийг өгдөг
- Дүрэм бүр өөрийн "тогтворгүй байдлын зэрэгтэй". Жишээ нь golden swipes дүрэм нь хэрэглэгчийг premium худалдаж авах сэдлийг өгөх зорилготой — энэ нь pure ML scoring-той ямар ч холбоогүй.
4. Cold start стратеги — хамгийн хэцүү хэсэг
Dating app дээр cold start гэдэг нь хэрэглэгчид анх удаа орж ирээд аппыг шууд хаяхгүй байх найдвар. Хэрэв шинэ хэрэглэгч анхных нь 10 хэрэглэгчийг харахад бүгд таалагдахгүй байвал — тэр аппыг хэзээ ч дахин нээхгүй.
Иймээс би cold start-ыг 2 талаас нь шийдэх ёстой байсан:
- User cold start: шинэ хэрэглэгчид яаж сайн профайл харуулах вэ
- Visibility cold start: шинэ хэрэглэгчийн профайл яаж бусдад харагдах вэ
User cold start path
Хэрэглэгчийн swipe тоог swipe_history table-аас тоолоод (get_user_swipe_count) threshold (50)-аас доош байвал тэр хэрэглэгчийг "cold start" гэж тооцно.
Cold start үед бид "popular pool"-аас сонгоно. Popular pool гэдэг нь:
- Бүх хэрэглэгчийн Elo rating дээр gender-аар нь нормчилсон percentile (хүйс хооронд харьцуулах боломжтой болгох гол ажил)
- Percentile 40-аас 80-ын хооронд хэрэглэгчдийг сонгоно
Яагаад 40-80 гэдэг range вэ? Учир нь:
- 0-40 percentile — Бид шинэ хэрэглэгчид болохгүй гэдгээ мэдсэн профайл харуулахыг хүсэхгүй (эхний impression-аа алддаг).
- 80-100 percentile — "Top 20%"-ийг бүх шинэ хэрэглэгчид харуулбал popularity spiral үүснэ.
- 40-80 percentile — "Дунд зэрэг сайн" хэрэглэгчид. Engagement-нь сайн, гэвч аль хэдийн алдартай биш.
async def get_popular_user_ids(
session: AsyncSession,
percentile_min: int = 40,
percentile_max: int = 80,
min_candidates: int = 10,
redis_client=None,
) -> list[str]:
# Redis cache first (1 hour TTL)
if redis_client:
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
scores = await get_desirability_scores(session)
result = _select_in_percentile(percentile_min, percentile_max)
# Fallback chain: P40-P80 too small? P20-P90. Still too small? Full range.
if len(result) < min_candidates:
result = _select_in_percentile(20, 90)
if len(result) < min_candidates:
result = list(scores.keys())
# Cap to 5000 to prevent memory bloat at 100k+ users
if len(result) > max_size:
result = random.sample(result, max_size)
random.shuffle(result)
await redis_client.setex(cache_key, ttl, json.dumps(result))
return result
Энэ функцэд миний хувийн fallback chain гэдэг pattern байгаа: anh range дотор хангалттай хүн байхгүй бол — өргөтгөнө. Бүхэлдээ хэт жижиг бол бүх scores-ийн жагсаалт буцаана. Алгоритм хэзээ ч "0 candidate буцаахгүй".
Premium хэрэглэгчдийн өргөтгөсөн pool
Хэрэв хэрэглэгч cold start үед premium байвал — би тэдэнд илүү өргөн range (P30-P95) өгдөг. Учир нь premium хэрэглэгч бүрэн pool-ыг үзэх эрхтэй гэж бид амласан. Эдгээрийн union-ийг auth-аар сонгоно.
Visibility boost — шинэ хэрэглэгчийн профайл хэрхэн харагдах вэ
Энэ нь cold start-ын нөгөө тал. Шинэ профайл ороход тэр хүн өөрөөсөө гадна бусдын feed-д харагдах ёстой. Үгүй бол үндсэн like-ийг хэзээ ч авахгүй, Elo rating-аа дээшилж чадахгүй, мөнхөд cold start-аас гарч чадахгүй.
Иймээс scoring-ын ажиллаж дууссаны дараа би visibility boost оруулдаг. Энэ нь:
def compute_visibility_boost(swipe_count, threshold, magnitude):
if swipe_count >= threshold:
return 0.0
if swipe_count <= 0:
return magnitude # Full boost
return magnitude * (1.0 - swipe_count / threshold) # Linear decay
Линейн уналт. 0 swipe-тэй хэрэглэгчид максимум boost (magnitude), threshold-д (50) хүрэхэд 0. Threshold-аас давсан хэрэглэгчид boost байхгүй.
Энэ boost-ийг score-д нь нэмж дараа нь дахин эрэмбэлдэг.
5. Scoring engine — 6 signal, tier-based weight
Энэ нь алгоритмын зүрх. Энэ хэсэгт би хамгийн их цаг зарцуулсан.
Scoring engine 6 signal-аас тогтоно:
| Signal | Утга | Range |
|---|---|---|
preference_overlap | Сонирхлын Jaccard similarity + газар зүйн дөтлөг | [0, 1] |
activity_recency | Сүүлд хэзээ онлайн орсон бэ | [0.1, 1] |
freshness | Профайл хэр шинэ вэ (14 хоног half-life decay) | [0, 1] |
elo_proximity | Desirability percentile-ын ойролцоо байдал | [0, 1] |
behavioral_affinity | Хоёрын хооронд хийгдсэн интеракцийн жинтэй нийлбэр | [0, 1] |
reciprocity | P(А-д Б таалагдах) × P(Б-д А таалагдах) | [0, 1] |
Эцсийн score:
score = (
w1 * preference_overlap +
w2 * activity_recency +
w3 * freshness +
w4 * elo_proximity +
w5 * behavioral_affinity +
w6 * reciprocity
)
W-нүүд хаанаас гарч ирэх вэ? Үндсэн ажиглалт: scoring weight нь хэрэглэгчийн tier-аас хамаардаг.
Tier-based weight resolution
MongoDB-аар "swipe count"-аар хэрэглэгчийг 3 tier болгож хуваана:
- Cold (< 50 swipe) — Шинэ хэрэглэгч. Бид түүнтэй холбоотой behavioral data маш бага. Иймээс explicit signal (preference_overlap, freshness) дээр илүү дулаахан тулгуурлана.
- Growing (50–200 swipe) — Behavioral pattern бий болж эхлэх. Affinity signal нэмж эхэлдэг.
- Mature (200+ swipe) — Бүх signal-ыг бүрэн ашиглах. Reciprocity, behavioral affinity дээр илүү жин.
def resolve_weights_for_tier(swipe_count, behavioral_settings):
if swipe_count < 50:
return ScoringWeights(
preference_overlap=0.35,
activity_recency=0.20,
freshness=0.20,
elo_proximity=0.20,
behavioral_affinity=0.05, # almost no signal
reciprocity=0.0, # disabled
)
elif swipe_count < 200:
return ScoringWeights(
preference_overlap=0.25,
activity_recency=0.15,
freshness=0.10,
elo_proximity=0.20,
behavioral_affinity=0.20,
reciprocity=0.10,
)
else: # mature
return ScoringWeights(
preference_overlap=0.15,
activity_recency=0.10,
freshness=0.10,
elo_proximity=0.15,
behavioral_affinity=0.30, # heavy behavioral
reciprocity=0.20, # full reciprocity
)
Энэ tier-based scheme нь "behavioral > stated" гэдэг dating research-ийн гол үр дүнг шууд кодлоход хэрэглэдэг. Хэрэглэгч уу мэдээллээ өгөхөөс илүү яаж swipe-лж байгаагаар нь хүнийг "ойлгож" таамаглах нь 3-5 дахин нарийвчлалтай. Гэвч тэр data-ыг эхний өдөр хуримтлуулах боломжгүй — тиймээс explicit signal-аар эхлээд, дараа нь шилжих.
Behavioral data beats stated preferences 3-5x. Users say one thing, swipe another.
Жишээ: preference_overlap
Энэ нь хамгийн "уламжлалт" content-based signal. Хэрэглэгчийн сонирхол (interests) болон зайны ойролцоо байдал хоёрыг хольсон:
def _preference_overlap(user_profile, candidate):
score_parts = []
# Interest overlap (Jaccard similarity)
user_interests = set(user_profile.get("interests", []))
candidate_interests = set(candidate.get("interests", []))
if user_interests and candidate_interests:
intersection = len(user_interests & candidate_interests)
union = len(user_interests | candidate_interests)
score_parts.append(intersection / union)
# Distance closeness
distance_m = candidate.get("distance_meters", 0)
max_distance_m = user_profile.get("maxDistance", 10) * 1000
if max_distance_m > 0:
closeness = 1.0 - (distance_m / max_distance_m)
score_parts.append(max(0.0, closeness))
if not score_parts:
return 0.5 # neutral
return sum(score_parts) / len(score_parts)
Jaccard similarity — энгийн set theory. |A ∩ B| / |A ∪ B|. Хоёр хэрэглэгчид ижил сонирхол хэдэн ширхэг хоёр талаасаа хичнээн ширхэг гэдгийг тооцдог. Эхэндээ би cosine similarity ашиглах гэж байсан ч одоогийн өгөгдлийн хэмжээтэйгээр Jaccard нь хангалттай байсан.
Жишээ: freshness
def _freshness(candidate):
created_at = candidate.get("createdAt")
if created_at is None:
return _FRESHNESS_DEFAULT # 0.3
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
days_since_creation = (now - created_at).total_seconds() / 86400
if days_since_creation <= 0:
return 1.0
return math.pow(2, -days_since_creation / 14) # 14-day half-life
Энэ нь экспоненциал decay. 14 хоног бол half-life — өөрөөр хэлбэл шинэ профайл 14 хоног дараа score нь 0.5, 28 хоног дараа 0.25 болж буурна. Энэ нь бид эртний профайлуудыг гадагшаа түлхэхгүй, харин шинэ профайлуудыг үндсэн feed-д эрт оруулж ирэх.
6. Elo desirability rating system
Энэ хэсэг өөрөө кейс судалгаа болж болох хэмжээтэй. Гэвч би уртхан тайлбарлая.
Elo гэдэг нь шатарын турнайраас гарсан рейтинг систем. "Бат-Тинд хожих магадлалыг түүний оноогоор тооцдог". Dating app дээр энэ нь:
- Хэрэглэгч right-swipe авах = win (тэр алдартай байна гэсэн үг)
- Left-swipe авах = loss
- Hi
K-factorдунд жигнэх — өөрөөр хэлбэл нэг тоглолтын үр нөлөө дунд багадаа
K-factor decay
Элинээгийн (Elo-ийн) K-factor нь rating-ийн нэг тоглолтод хичнээн өөрчлөгдөж болохыг хянана. K хэт өндөр бол rating маш тогтворгүй (шуугианаар их хөөрнө), хэт нам бол rating маш удаан тогтворждог.
Flint дээр би decaying K-factor ашиглаж байна:
def k_factor(interaction_count, k_max=64.0, k_min=16.0, tau=50.0):
return k_min + (k_max - k_min) * math.exp(-interaction_count / tau)
interaction_countнь хэрэглэгчийн received swipe-ийн тоо (өөрөө хийсэн биш, бусдаас түүн рүү ирсэн)n=0дээр K = 64 (хамгийн өндөр volatility — шинэ хэрэглэгч маш хурдан зөв rating олох ёстой)n=50дээр K ≈ 33.7 (хагас унасан)n=200дээр K ≈ 16.3 (бараг floor-д)n→∞үед K → 16 (asymptote)
Энэ нь Glicko-2 rating system-ийн "rating deviation" концепцийн хялбаршуулсан хувилбар. Шинэ хэрэглэгчийн rating нь хурдан тогтворждог байх ёстой ч, тогтсонийхоо дараа маш бага өөрчлөгдөх ёстой.
compute_elo_delta
Хэн нэгэн чам руу swipe хийхэд чиний rating өөрчлөгдөнө. Гэхдээ swipe хийсэн хүний rating чиний өөрчлөлтөд нөлөөлнө:
def compute_elo_delta(
candidate_rating, swiper_rating, action, interaction_count,
left_swipe_weight=0.5, super_like_weight=2.0,
):
k = k_factor(interaction_count)
expected = expected_score(candidate_rating, swiper_rating)
if action == "right":
actual = 1.0
elif action == "super_like":
actual = 1.0
k *= super_like_weight
elif action == "left":
actual = 0.0
k *= left_swipe_weight # left swipe doesn't hurt as much
return k * (actual - expected)
Яагаад left-swipe-ийн жинг 0.5 болгож хорогдуулсан вэ? Учир нь:
- Хэрэглэгчид right-swipe-ийг хайрладаг ч (positive event), left-swipe-ийг даалгаваргаар хийдэг (default action нь зөвхөн "swipe-ийг continue")
- Right-swipe-ийн semantic signal нь илүү цэвэр
- Зүүн swipe-аар хэн нэгний rating-ийг хурдан унагалт хийвэл popularity spiral-ийн эсрэг талын эффект үүснэ
Super-like-ийн жин 2x — учир нь super-like худалдан авдаг (Tinder-д) учраас илүү дохиолол байх ёстой.
Gender-normalized percentile
Elo rating-ийг зөвхөн нэг scale дотор жагсаах нь Tinder/Bumble-ийн алдааны нэг гэж би үздэг. Эрчүүд эмэгтэйчүүдээс дунд багадаа like-ийн тоо нь өөр болохоор Elo rating нь систематик offset-той гарах болно.
Иймээс би gender хооронд нь нормчилсон percentile хэрэглэдэг. get_gender_percentiles(session, user_ids) функц нь:
- Хүйс тус бүрд (MALE, FEMALE, ...) бүх Elo rating-уудыг түүж авна
- Хүйс бүрд тус тусдаа percentile тооцоолно
- Хэрэглэгч бүрд
[0.0, 1.0]хооронд нэг тоо буцаана
Энэ нь elo_proximity signal-ийн утга бий болгож байгаа. Бид хүсэлтэй "ижил утгат" хэрэглэгчдийг хооронд нь холбож байгаа — ялангуяа дунд зэргийн percentile-д байгаа хэрэглэгчдийг нэг нэгэнтэйгээ илүү тааруулдаг.
Write-through cache
Swipe хийгдэх бүрд:
swipe_historytable-д swipe-ийн бичлэг нэмэгдэнэelo_ratingstable-д хүлээн авагчийн Elo rating шинэчилнэ- Redis cache-д мөн шинэчилнэ (write-through).
elo_rating:{user_id}гэдэг key.
Write-through нь read-time-д хэрэглэгчийн rating database-ээс уншихгүйгээр Redis-ээс хайх боломж олгож байна. Энэ нь scoring engine-ийн "500 хэрэглэгчид Elo percentile уншиж байсан" timing-ийг 200ms-аас 20ms болгож бууруулсан.
7. Behavioral affinity + Reciprocity
Энэ хоёр signal нь би хамгийн их инженерийн бүтээлч ажил оруулсан хэсэг.
Pair interaction hash
Хоёр хэрэглэгчийн хоорондын бүх интеракцийн түүхийг агуулдаг Redis hash:
pair:{userA}:{userB} (sorted alphabetically by ID for uniqueness)
├── messages_count: "24"
├── matched: "1"
├── right_swipe: "1"
├── left_swipe: "0"
├── a_viewed_b: "1"
├── b_viewed_a: "1"
├── last_message_at: "2026-02-15T14:30:00Z"
└── ...
Ключ нь alphabetically sorted ID-ний хэлбэртэй — pair:abc:xyz мөн нь pair:xyz:abc-ийн мөн тэр key биш гэдэг. Ингэхгүй бол хоёр key үүсэх нь дамжиггүй.
Клиент талаас интеракци явахад event endpoint руу post хийгдэнэ:
view— Профайлыг хэдэн миллисекунд харсан вэmessage— Message илгээгдсэнmatch— Mutual right-swipe үүссэнsession— Тухайн session-ийн уртын мэдээлэл
Events хүлээн авах events.py service-ээс тэдгээрийг pair hash-д бичих болон behavioral_events table-д (Postgres partitioned) долоо хоног ачаалал хадгалах ёстой.
Behavioral affinity computation
Scoring engine pair hash-аас интеракцийн жинлэгдсэн нийлбэрийг тооцдог:
INTERACTION_WEIGHTS = {
"messages_count": 1.0, # Highest — direct conversation
"matched": 0.8, # Mutual interest event
"right_swipe": 0.6, # Strong positive
"view": 0.3, # Weak positive
"left_swipe": -0.2, # Negative (small)
}
Жингийн дараалал нь signal hierarchy-ийг илэрхийлдэг. Message нь хамгийн өндөр (1.0) — учир нь шууд яриа гэдэг бол хамгийн чухал сэтгэл харьцааны үнэ цэн дохиолол. View нь хамгийн доош (0.3) — учир нь random tap, swipe-аас зайлсхийх боломжтой.
Left swipe нь сөрөг утгатай (-0.2). Гэвч мхо хэт сөрөг биш — учир нь хэн нэг хэрэглэгч өмнө нь left swipe хийгээд буцаад дахин харуулахыг хүсч магадгүй ("undo" feature). Бид тэр хүнийг бүхэлдээ мартахгүй.
def compute_behavioral_affinity(
pair_data, user_id, candidate_id,
als_cf_score=None, alpha=0.5,
):
a, b = sorted([user_id, candidate_id])
is_a = user_id == a
direct = 0.0
if int(pair_data.get("messages_count", "0")) > 0:
direct += 1.0
if pair_data.get("matched") == "1":
direct += 0.8
if pair_data.get("right_swipe") == "1":
direct += 0.6
view_key = "a_viewed_b" if is_a else "b_viewed_a"
if pair_data.get(view_key) == "1":
direct += 0.3
if pair_data.get("left_swipe") == "1":
direct -= 0.2
direct = max(0.0, min(1.0, direct)) # clamp
# Optional ALS CF blend
if als_cf_score is not None:
return alpha * direct + (1.0 - alpha) * als_cf_score
return direct
Reciprocity — ALS factor vectors
Дараагийн signal: P(А-д Б таалагдах) × P(Б-д А таалагдах).
Энэ хэсэг нь matrix factorization дээр үндэслэсэн. ALS (Alternating Least Squares) алгоритм нь implicit feedback (бид зөвхөн "like"-ийг харна, "dislike"-ийг харахгүй) дээр маш сайн ажилладаг.
Дотор юу болж байна вэ:
- Хэрэглэгч бүрд хоёр vector:
user_vec(тэр хэн нэгнийг хайх vector) болонitem_vec(тэр хэн нэгэнд хайгдах vector). Эдгээрийн хэмжээ ~16-32 dimension. - Эдгээр vectors-ыг 4 цаг тутамд Celery worker-аар дахин сургадаг (бүх swipe history дээр).
- Sigmoid функцийн дамжуулан probability болж хувирдаг.
def compute_reciprocity(user_factors, candidate_factors):
if user_factors is None or candidate_factors is None:
return 0.5 # neutral fallback
n = len(user_factors) // 2
a_user_vec, a_item_vec = user_factors[:n], user_factors[n:]
b_user_vec, b_item_vec = candidate_factors[:n], candidate_factors[n:]
# P(A likes B) = sigmoid(dot(A_user_vec, B_item_vec))
p_ab = _sigmoid(float(a_user_vec @ b_item_vec))
# P(B likes A) = sigmoid(dot(B_user_vec, A_item_vec))
p_ba = _sigmoid(float(b_user_vec @ a_item_vec))
return p_ab * p_ba
Энэ нь reciprocal recommendation-ийн математик гол үндэс. Хоёр магадлалыг үрждэг ба нэгийг нэмдэгийн биш. Иймээс нэг талд хүчтэй, нөгөө талд сул байх нь — final score-ыг доош нь зөв татна (low product, even if sum is high).
Жишээ нь P(A→B) = 0.9, P(B→A) = 0.1 байсан тохиолдолд:
- Aggregation (sum) → 1.0 / 2 = 0.5 (looks balanced)
- Reciprocity (product) → 0.09 (revealed asymmetry)
Энэ хосыг хүчтэй гэж бид буруу таамаглах вий — иймээс product нь reciprocal nature-ийг үнэн илэрхийлдэг.
Cold tier-д reciprocity-ийг зайлуулдаг
Харж байгаа эсэхт cold tier-д reciprocity weight = 0.0 байсан. Шалтгаан нь:
- Шинэ хэрэглэгчид swipe history маш бага. ALS factor vector нь хангалттай trained биш.
- Гарсан магадлал бүгд noise.
- 0.0 жин өгснөөр noise-ыг final score-аас бүхэлд нь зайлуулж байна.
Growing tier-д reciprocity = 0.10, mature-д 0.20. Аажмаар нэмэгдэнэ.
8. Reranker — бизнес дүрмүүд
Scoring engine-ийн ажил нь "хүнийг хүнтэй хэр сайн тааруулах вэ" гэдэгт хариулна. Гэвч аппликейшний бизнес талд:
- Premium хэрэглэгчийг арай илүү харуул (тэр төлбөр төлж байгаа)
- Profile boost худалдан авсан хэрэглэгчийг 20 минут идэвхтэй харуул
- Шинэ хэрэглэгчийг арай түрүүлж харуул (visibility boost)
- Сүүлийн swipe үлдсэн хэрэглэгчид (free tier) хамгийн сайн профайлыг харуул ("golden swipe")
Эдгээр 6 reranking pass-ийг scoring-аас тусгаарласан reranker.py module-д хадгалсан.
Golden swipes
Free-tier хэрэглэгч өдөрт 10 swipe-тай (Tinder/Hinge-ийн загвар). 5-аас цөөн swipe үлдсэн үед түүний feed-д үлдсэн swipe тоогоор хамгийн өндөр оноотой профайлуудыг харуулдаг.
Яагаад? Учир нь үлдсэн swipe-уудаа "үрж" хог дээр гаргавал — тэр хэрэглэгч аппыг хаяна. Хэрэв бид сүүлийн swipe-ыг хамгийн сайн профайл дээр зарцуулах боломж олговол — match үүсэх магадлал өндөрсөнө.
def _apply_golden_swipes(scored, settings, swipes_remaining):
if swipes_remaining is None or swipes_remaining > settings.golden_threshold:
return scored
# Top N candidates (where N = swipes_remaining) get a multiplier
for i in range(min(swipes_remaining, len(scored))):
scored[i].score *= settings.golden_multiplier # e.g., 1.3x
return scored
Энэ нь "hard rule" биш — multiplier-аар scoring дотор blend хийгдэнэ.
Profile boost (paid)
Хэрэглэгч "30 минутын boost" худалдаж авдаг бол is_boosted=True. Reranker нь тэр хэрэглэгчийн бусдын feed-д оруулагдсан үед 5x score multiplier өгнө.
Active boost decay
Profile boost худалдаж авсны дараа Postgres-д active_boosts table-д бичигдэнэ:
+----------+-----------+--------------+----------------+
| user_id | started_at| expires_at | initial_multiplier|
+----------+-----------+--------------+----------------+
| user_123 | 14:30:00 | 15:00:00 | 5.0 |
+----------+-----------+--------------+----------------+
fetch_active_boost_multipliers функц нь Redis cache-аас (5 минут TTL) эсвэл Postgres-ээс уншиж, бүх идэвхтэй boost-уудын multiplier-ийг авдаг. Дараа нь multiplier нь хугацаа явах тусам decay хийгдэнэ:
- 0–10 минут — 5.0x
- 10–20 минут — 3.5x
- 20–30 минут — 2.0x
- 30+ минут — boost дууссан, 0 multiplier
Энэ нь "boost-ийг шууд харуулаад нэг минутын дотор бүх хэрэгцээг түүж дуусгахгүй" — хэрэглэгч өөрийн боостаа долоо хоногийн туршид удаан үргэлжилсэн утга авах.
Premium visibility boost
Premium хэрэглэгчид (1.2x multiplier) — гэхдээ хэт өндөр биш. Учир нь:
- Хэт өндөр (2x+) бол free-tier хэрэглэгч premium-ыг тэр чигт нь олж харахгүй болно (popularity spiral)
- Хэт нам (1.05x) бол premium төлсөн утга нэмэрлэхгүй
- 1.2x нь optimum-той сонголт байна
9. Edge case-ийн заслууд
Энэ хэсэгт олон ажил байсан — algorithm бичих хамгийн их цаг авдаг тал бол математик биш, edge case.
Cooldown — Redis-ийг backup-тай байлгах
Хэрэглэгч нэг профайлыг харсны дараа дахин харуулахгүй. Энэ нь Redis sorted set-д хадгалагдана:
cooldown:{user_id} (sorted set with score = expiry timestamp)
Гэвч Redis нь cache юм — restart-ад хогшсон. Хэрэв Redis ачаалал хэт их болж memory-ийн зориулалт давсан тохиолдолд хуучирсан key-үүд хальж унтрах магадлалтай. Иймээс би MongoDB-д бас backup query оруулсан:
seen_ids = await get_excluded_ids(redis_client, user_id) # Redis primary
interacted_ids = await get_interacted_ids(db, user_id) # Mongo backup
block_ids = await get_blocked_ids(db, user_id) # Mongo permanent
all_excluded = list(seen_ids | interacted_ids | block_ids)
interactions collection нь NestJS backend-ийн өмчлөл — хэрэглэгчид баруун swipe эсвэл super-like хийсэн бүх профайлуудыг хадгалдаг (left-swipe-ийг хадгалдаггүй — учир нь тэр нь зөвхөн "7 хоног түр зуурын ялгал"). Тиймээс бүх "hard rejection" Mongo-д үлдэнэ.
Энэ нь Redis-ийн кэшийн уналт нь "бид бүх профайлыг хэн нэг хүнд дахин харуулах вий" гэдэг зовлонгийг бэлгэдээгүй.
$geoNear нь 0 буцаах үед — geo fallback
Хэрэв $geoNear aggregation нь 0 candidate буцаах тохиолдол гарвал — бид geo-гүй query руу уналт хийнэ:
if not raw_candidates:
logger.info("Geo-based query returned 0 candidates, falling back to non-geo query")
raw_candidates = await fetch_candidates_no_geo(
collection=users_collection,
target_gender=target_gender,
min_birth_date=min_birth_date,
max_birth_date=max_birth_date,
excluded_ids=all_excluded + [user_id],
limit=limit,
)
Энэ зам ажиллах хамгийн түгээмэл шалтгаан нь — хуучин хэрэглэгчдийн профайл GeoJSON location field-гүй (би энэ нь GeoJSON-ыг хожуу нэмсэн). Эдгээр профайлуудыг "харуулахгүй" гэдэг нь жижиг платформ дээр аюултай — иймээс бид distance-аас татгалзаад харин фалбэк-аар буцаана.
Popular pool empty чухал case
Cold start үед популар pool-аас сонгоход:
- Popular pool empty (Elo rating-ууд хараахан тооцоологдоогүй) → geo-only fallback
- Popular pool бүгд excluded (хэрэглэгч аль хэдийн бүгдийг харсан) → geo-only fallback
- Popular pool жижиг (
effective_pool < limit) → geo-only fallback
Гурван case-ийг тус тусдаа logging-той аль аль нь geo-only fallback ашиглана.
Timezone-ийн зөв
MongoDB нь зарим тохиолдолд naive datetime хадгалдаг (timezone-гүй). Postgres-аас аль аль нь tzinfo=timezone.utc ашигладаг. Гэхдээ scoring engine нь:
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
Энэ бол tiny edge case ч — тооцоологсон энэ defensive check байхгүй бол now - created_at нь TypeError: can't subtract offset-naive and offset-aware datetimes гэдэг алдаа гарна. Нэг ийм буг production-д хагас өдөр явсан.
Behavioral events нь partition хэрэгтэй
Behavioral event table нь created_at column-аар range partition хийгдсэн. Сар тутамд partition үүсгэж байх ёстой:
CREATE TABLE behavioral_events_2026_03
PARTITION OF behavioral_events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
Хэрэв нэг сараас өмнө шинэ partition үүсгээгүй бол event-ийн INSERT амжилтгүй болно. Иймээс worker/partition_maintainer.py өдөр бүр тэр сар болон дараагийн сарын partition байгаа эсэхийг шалгана.
10. Хийгээгүй зүйл, дараагийн алхамууд
Энэ бол MVP. Сүүлийн 4 сарын турш ажиллаж байгаа. Гэвч маш олон зүйл хийгдээгүй байна:
Хэрэгжээгүй (хийх төлөвлөгөөтэй)
- Embedding-based candidate generation — Одоо $geoNear distance дээр sort хийнэ. Илүү ухаалаг сонголт нь user embedding ANN query.
- Two-tower neural network — Tinder-ийн TinVec шиг 200-dim user embedding-ийг swipe sequence дээр Word2Vec Skip-gram ашиглан сургах. Энэ нь long-term plan.
- Photo CNN scoring — Photo bare quality score-аас гадна composition, expression score-ыг CNN-аар тооцох.
photo_scoring.pyнь skeleton хэлбэрээр байна. - Photo deep-learning algorithms — Гэрэл хангаагүй зураг, групп зураг, нүүр харагдахгүй зураг автоматаар үнэлэгдэх.
- Online learning — Одоогоор ALS retraining нь 4 цаг тутамд явдаг. Шинэ swipe-ыг шууд incremental update хийх стратеги.
- Multi-armed bandit exploration — "30% алгоритм-ийн сонголт, 70% хэрэглэгчийн өмнөх таалагдсан pattern" гэдэг trade-off-ыг Thompson sampling-аар хийх.
Хийсэн ч сайжруулах хэрэгтэй
- A/B testing framework — Одоогоор зүгээр л feature flag-ууд бий. Бодит experiment framework (e.g. ramped rollout, statistical significance check) хэрэгтэй.
- Real-time metrics dashboard —
/metrics/elo-health,/metrics/match-ratesгэх мэт API бий. Гэхдээ тэдгээрийг PostHog-той biind-лж бодит dashboard үүсгэх хэрэгтэй. - Fairness constraints — Алгоритм нь "нэг группын хүн арай илүү харагдсан" гэдгийг автомат илрүүлэх ёстой.
Хийхгүй гэж шийдсэн
- Federated learning — Хэт complex. Платформын хэмжээ хэт жижиг.
- Identity provider integration — Phone + Email хангалттай. Apple/Google OAuth-аас энд харагдаж буй ROI хэт жижиг.
- Voice-based prompts — Hinge-ийн сонирхолтой feature ч, audio storage нь expensive.
Эпилог — юу сурсан бэ
Энэ нь миний хувьд анх удаа end-to-end recommendation system-ыг production-д барьж буй ажил. Хичнээн нийтлэлийг доорх дүгнэлтэд багтаасан гэж бодсон ч хязгаар байгаа.
Энэ кейс судалгааг уншигч инженерт хэрэгтэй мэдээлэл өгсөн гэж найдаж байна. Recommendation system нь нэг хүнд богино пост бичих сэдэв биш — миний доорх нийтлэл ердөө гадаргуу гэдгийг хүлээн зөвшөөрнө. Гэвч энэ нь "ML/AI бол ердийн SaaS-аас илүү жижиг asymmetric advantage" гэдэг ойлголтыг хувь хүнийнхээ дотор маш баттай суулгасан билээ.
Дараагийн пост дээрээ ALS training-ийн celery worker-ийн дотор юу болж байгаа, partitioned table-ийн maintenance schedule-ыг яаж тааруулсан, болон production-д ажиллаж буй edge case-ийг хэрхэн илрүүлэхээ бичих болно.
Энэ постонд дурдсан кодын fragment бүгд Flint algorithm-ийн өөрийн codebase-аас. Эх сурвалжуудаа нийтлээгүй учир нь private repository.