সব problem (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · High Level Design · Read-Heavy: Serve It Fast

Design Search Autocomplete

IntermediateTrieTop-KCachingOffline aggregation⏱ 2-3 ঘণ্টা

প্রতিটা key চাপার সাথে ১০০ ms এর মধ্যে suggestion: প্রতিটা prefix এ top-K সহ trie, search log থেকে offline এ সেটা বানানো, আর user এর কাছাকাছি cache।

Learn mode: পুরো solution নিচে দেখানো আছে।

Problem এর পরিচয়

DifficultyIntermediate
সময়design আর core বানাতে 2–3 ঘণ্টা; interview তে মোটামুটি 45 মিনিট
আগে যা জানা দরকারStorage and search, caching আর CDN, trie জিনিসটা কী
ConceptLatency budget, প্রতিটা node এ আগে থেকে হিসাব করা top-K সহ trie, offline batch এ বানানো বনাম online এ serve করা, immutable snapshot আর atomic swap, version সহ cache key, edge caching, prefix range ধরে sharding, time decay দিয়ে trending
বাস্তবেযেকোনো বড় e-commerce site এর search box, Google আর YouTube এর suggestion, app store, map ("dhanmondi 27..."), bus বা train ticket site এর "কোথায় যাবেন" ঘর

Autocomplete দেখতে ছোট একটা feature: search box এর নিচে একটা dropdown। কিন্তু HLD problem হিসেবে এটা দারুণ, কারণ সংখ্যাগুলো এমন একটা design এ ঠেলে দেয় যেটা "database এ query মারো" থেকে একদম আলাদা: প্রতিটা key চাপা একটা request, পরের অক্ষর টাইপ করার আগেই উত্তর আসতে হবে, আর data (মানুষ কী খোঁজে) সারাদিন বদলায়। মূল শিক্ষাটা অনেক জায়গায় কাজে লাগবে: system টাকে দুই ভাগ করো, একটা ধীর offline পথ যেটা উত্তর বানিয়ে রাখে, আর একটা দ্রুত online পথ যেটা শুধু সেই উত্তর পড়ে।

Problem statement

মিরপুরের কেউ একটা বড় shopping site খুলে "dha" টাইপ করা শুরু করল। শব্দটা শেষ করার আগেই dropdown এ চলে এসেছে "dhaka weather", "dhakaiya biryani", "dhaka traffic"। একটায় tap করল, বাকিটা আর টাইপ করতে হলো না। Phone এর keyboard এ এতে সত্যিই কষ্ট বাঁচে, আর মানুষ এমন query তে যায় যেটার আসলে result আছে।

কী বানাচ্ছি: এমন একটা service, user এ পর্যন্ত যা টাইপ করেছে (prefix) সেটা দিলে ওই prefix দিয়ে শুরু হওয়া সবচেয়ে popular 10 টা পুরো query ফেরত দেয়, এত দ্রুত যে মনে হয় সাথে সাথে চলে এলো।

কে ব্যবহার করে: search box এ যে টাইপ করে সবাই, প্রতিটা key চাপায়। পরোক্ষভাবে search team, যাদের দরকার suggestion যেন fresh থাকে (নতুন metro line, Eid sale, breaking news) আর পরিষ্কার থাকে (কোনো গালি বা কারো personal তথ্য suggest হবে না)।

কী সমস্যা মেটায়: কম টাইপ, কম বানান ভুল, আর এমন search যেটা আসলে আছে এমন জিনিসে গিয়ে পড়ে।

Technically কোথায় মজা:

  • Latency: mobile network সহ পুরো আসা-যাওয়া 100 ms এর অনেক কমে শেষ হতে হবে, নাহলে dropdown টাইপিং এর পেছনে পড়ে থাকে, কোনো কাজে আসে না।
  • পরিমাণ: একটা search মানে কয়েকটা request (টাইপের প্রতিটা বিরতিতে একটা), তাই suggestion এর traffic search এর traffic এর কয়েক গুণ।
  • ছোট prefix বিশাল: "d" দিয়ে শুরু হওয়া query লক্ষ লক্ষ। প্রতিটা key চাপায় এগুলো sort করা অসম্ভব।
  • Fresh বনাম দ্রুত: কোনটা popular সেটা দিনের মধ্যেই বদলায়, কিন্তু request এর পথে উত্তর নতুন করে বানালে latency শেষ।
  • Content safety: মানুষ যা টাইপ করে সেটাই লাখো মানুষকে suggestion হিসেবে দেখানো হতে পারে, গালি, গুজব, phone number সহ।

Requirement

Functional requirement

  1. Suggest: একটা prefix এর জন্য ওটা দিয়ে শুরু হওয়া সর্বোচ্চ 10 টা পুরো query ফেরত দাও, সবচেয়ে popular টা আগে।
  2. শুধু আসল search: suggestion আসবে মানুষ আসলে যেগুলো submit করেছে (Enter চেপেছে) সেগুলো থেকে, প্রতিটা key চাপা থেকে না।
  3. Freshness: কোনো query popular হয়ে গেলে মোটামুটি এক ঘণ্টার মধ্যে দেখাবে; হঠাৎ ভাইরাল হওয়া trend মোটামুটি 15 মিনিটে (stretch goal)।
  4. Filtering: blocked শব্দ, আপত্তিকর কিছু, আর personal data র মতো দেখতে কিছু কখনো suggest হবে না; অভিযোগ এলে কয়েক মিনিটের মধ্যে একটা suggestion সরানো যাবে।
  5. Scope এর বাইরে: বানান ঠিক করা ("dhkaa"), personal suggestion, আর search result page নিজে। প্রথম দুইটা interview অংশে আলোচনা করব।

Non-functional requirement

Requirementলক্ষ্যএখানে কেন গুরুত্বপূর্ণ
Latencyserver এ p99 10 ms এর নিচে; user থামার ~100 ms এর মধ্যে dropdownপরের key চাপার চেয়ে ধীর হলে user সেটা দেখেই না
Throughputpeak এ সেকেন্ডে ~60,000 request (নিচে হিসাব)টাইপের প্রতিটা বিরতি একটা request
Availability99.9%Autocomplete সাহায্য করে, কিন্তু critical না: এটা না চললেও search চলে
Freshnessনতুন popular query ~1 ঘণ্টায়পুরনো suggestion Eid sale আর খবর মিস করে
ConsistencyEventual; একটা request একটাই version এর data দেখবেকয়েক মিনিট পুরনো চলে, এক উত্তরে দুই version মিশে গেলে গোলমাল
DurabilitySearch log এ সামান্য data হারালে সমস্যা নেই; বানানো trie সবসময় আবার বানানো যায়এগুলো statistics, টাকার হিসাব না
Safety আর privacyকোনো blocked বা personal suggestion না; দ্রুত সরানোএকটা খারাপ suggestion মানেই খবরের শিরোনাম

Latency আর throughput ই design ঠিক করে। খেয়াল করো কোনগুলো কড়া না: freshness মিনিটে মাপা, durability ঢিলা, আর availability 99.9% হলেই চলে কারণ feature টা ভাঙলেও search চলে। এই ছাড়টুকুর জন্যই আমরা সবকিছু আগে থেকে হিসাব করে রাখতে পারি।

Hint 1: সময়ের budget

পুরো আসা-যাওয়া যদি 100 ms এর নিচে শেষ করতে হয় আর mobile network ই তার বেশিরভাগ খেয়ে ফেলে, তাহলে server এর হাতে আসলে কত millisecond থাকে? ওই সময়ে কী ধরনের কাজ করা যায়?

Hint 2: ছোট prefix এর সমস্যা

"d" দিয়ে লক্ষ লক্ষ query মেলে। প্রতিটা key চাপায় এগুলো sort করা যাবে না। তাহলে sort টা কখন করা যায়?

Hint 3: popularity কোথা থেকে আসে

কোনটা গুনবে: key চাপা, নাকি submit করা search? হিসাবটা কতটা fresh হতে হবে, আর কাজটা কি user অপেক্ষা করার সময়ই করতে হবে?

Hint 4: কম request

"dhaka metro" টাইপ করা মানে 11 বার key চাপা। Server এর কি 11 টাই দেখা দরকার? Browser আর CDN কী করতে পারে?

Capacity আন্দাজ

ধরে নেওয়া (মুখে বলা, গোল সংখ্যা):

  • দিনে 5 কোটি active user, প্রত্যেকে দিনে 6 টা search করে।
  • একটা query গড়ে 18 অক্ষর, কিন্তু ছোট একটা debounce থাকায় browser প্রতি search এ মোটামুটি 6 টা suggestion request পাঠায়।
  • সবচেয়ে ব্যস্ত ঘণ্টা গড়ের মোটামুটি 3 গুণ।
  • 30 দিনে অন্তত 20 জন আলাদা মানুষ যেটা search করেছে শুধু সেটাই suggestion হতে পারবে (এটা privacy rule ও, Security অংশ দেখো)। তাতে মোটামুটি 1 কোটি suggest করার মতো query থাকে, গড়ে 20 byte লম্বা।
  • প্রতিটা prefix এ top 10 রাখব।

Traffic

Formulaফল
দিনে search5 কোটি × 630 কোটি
দিনে suggestion request30 কোটি × 6180 কোটি
সেকেন্ডে suggestion request (গড়)180 কোটি ÷ 86,400≈ 21,000 / s
সেকেন্ডে suggestion request (peak)21,000 × 3≈ 63,000 / s
সেকেন্ডে search log event (গড় / peak)30 কোটি ÷ 86,400≈ 3,500 / s, ≈ 10,500 / s

Storage

Formulaফল
দিনে search log30 কোটি × প্রতি event 200 Bদিনে 60 GB
Log 90 দিন রাখা, 3 copy60 GB × 90 × 3≈ 16 TB
Query এর text1 কোটি × 20 B200 MB
Trie এর node1 কোটি × 20 এর কম (common prefix ভাগাভাগি করায় ~6 কোটি)≈ 6 কোটি node
Trie এর size6 কোটি node × ~80 B (children + 4 B এর 10 টা query ID + overhead)≈ 5 GB

Server: memory তে trie lookup কয়েক microsecond এর কাজ; আসল সময় যায় HTTP এর আশেপাশের কাজে। একটা server সেকেন্ডে 10,000 request সামলায় ধরে নিলে (হিসেব করে কম ধরা), peak এ মোটামুটি 7 টা server লাগে। দুইটা zone মিলিয়ে 10 টা চালাও, যাতে একটা zone পড়ে গেলেও সমস্যা না হয়। প্রতিটা server পুরো 5 GB trie রাখে, তাই যেকোনো server যেকোনো prefix এর উত্তর দিতে পারে।

Bandwidth: 10 টা suggestion সহ একটা response মোটামুটি 400 byte JSON আর ~300 byte header। Peak এ 63,000 × 700 B ≈ 44 MB/s (~350 Mbit/s)। চোখে পড়ার মতো, তবে সমস্যা না, আর gzip আর CDN এটা আরো কমায়।

Cache size: 1 থেকে 3 অক্ষরের prefix মোট মোটামুটি 37 + 37² + 37³ ≈ 52,000 টা (অক্ষর, সংখ্যা, space)। প্রতিটা 700 B হলে 36 MB, আর এই ছোট prefix গুলোই সবচেয়ে বেশি বার আসে, তাই CDN edge cache এর জন্য একদম মাপমতো।

সংখ্যাগুলো কী বলছে: serve করার data (5 GB) একটা machine এর memory তে ধরে যায়, তাই একটা lookup কোনো database ছাড়াই শুধু pointer ধরে হাঁটা হতে পারে। খরুচে কাজটা (30 কোটি search গোনা আর প্রতিটা prefix এর ranking) offline এ, batch এ করা যায়, কারণ freshness মিনিটে মাপা। পুরো design টা এই দুই বাক্যেই।

API design

Method আর pathRequestসফল হলেError
GET /v1/suggest?q=dha&lang=en&limit=10কিছু না200 {"q": "dha", "version": 412, "suggestions": ["dhaka weather", "dhakaiya biryani", "dhaka traffic"]} সাথে Cache-Control: public, max-age=60400 prefix বেশি লম্বা বা ভুল lang, 429 বেশি request
POST /v1/search (search service এর, আমাদের না){"q": "dhaka metro rail", "event_id": "..."}result pageগোনার জন্য search event হিসেবে log হয়
POST /admin/v1/blocked-terms{"term": "...", "reason": "..."} (শুধু staff)201403
  • GET আর URL এ prefix কেন: এতে browser আর CDN URL ধরে response cache করতে পারে। এই API এর সবচেয়ে জরুরি গুণ এটাই।
  • Response এ count নেই: popularity এর সংখ্যা ভেতরের ব্যাপার; বাইরে দেখালে মানুষ সেটা নিয়ে কারসাজি করতে চায়।
  • Authentication: suggestion anonymous। ইচ্ছা করেই: request এ cookie বা user ID থাকলে response personal হয়ে যায়, আর cache করা যায় না। Rate limit হয় IP আর client app ধরে।
  • Idempotency: GET এমনিতেই idempotent। Search event এ event_id থাকে, যাতে log একটা event দুইবার দিলে গোনার pipeline duplicate বাদ দিতে পারে।
  • version বলে কোন trie উত্তর দিয়েছে; "এই suggestion কেন দেখলাম" debug করতে কাজে লাগে, আর পুরনো version এর কোনো response দেরিতে এলে client সেটা বাদ দিতে পারে।
  • Client এর দায়িত্ব: browser পাঠানোর আগে একটু থামে (মোটামুটি 50–100 ms), নতুন key চাপলে আগের request cancel করে, আর session জুড়ে উত্তর cache করে। এই তিনটা নিয়মেই server এর traffic অনেক কমে যায়।

Database design

Access pattern: online পথ পড়ে "ঠিক এই prefix এর top 10", সেকেন্ডে হাজার হাজার বার, আর কখনো লেখে না। Offline পথ সারাদিন search event যোগ করে, আর ঘণ্টায় একবার সব count নতুন করে লেখে।

সোজা database এ query করলে কী সমস্যা? SELECT query FROM queries WHERE query LIKE 'd%' ORDER BY count DESC LIMIT 10 prefix এর জন্য B-tree index ব্যবহার করতে পারে, কিন্তু "d" এর জন্য তবুও লক্ষ লক্ষ মিলে যাওয়া row পড়ে count ধরে sort করতে হয়। 1,000 query এর site এ এটা ঠিক আছে, আমাদের scale এ আশা নেই। সমাধান হলো প্রতিটা prefix এর উত্তর আগেই হিসাব করে রাখা, আর সেই উত্তরগুলো ঘন করে রাখার একটা উপায় হলো trie।

তাই serve করার "database" হলো trie নিজেই, memory তে, একটা file থেকে load করা। বাকি store গুলো সাধারণ:

-- Raw search events: an append-only log (Kafka topic), then hourly files in object storage
-- (Parquet, partitioned by hour). Not a SQL table; shown as columns for clarity.
--   event_id, query, lang, region, user_hash, ts

-- Aggregated counts, rewritten by every batch (warehouse table or Parquet files).
CREATE TABLE query_counts (
  query        TEXT    NOT NULL,
  lang         TEXT    NOT NULL,
  score        BIGINT  NOT NULL,          -- decayed count: old score / 2 + new searches
  users_30d    INT     NOT NULL,          -- distinct users, for the privacy threshold
  PRIMARY KEY (lang, query)
);

-- Which trie version each shard should serve (small, PostgreSQL).
CREATE TABLE trie_versions (
  version     BIGINT PRIMARY KEY,
  shard       TEXT   NOT NULL,            -- e.g. 'a-c', 'd-k', 'l-z'
  path        TEXT   NOT NULL,            -- object storage key of the snapshot file
  checksum    TEXT   NOT NULL,
  queries     INT    NOT NULL,
  status      TEXT   NOT NULL,            -- building, validated, live, rolled_back
  built_at    TIMESTAMPTZ NOT NULL
);

-- Blocked words and removed suggestions (small, PostgreSQL, edited by staff).
CREATE TABLE blocked_terms (
  term        TEXT PRIMARY KEY,
  reason      TEXT NOT NULL,
  added_by    BIGINT NOT NULL,
  added_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • Log শুধু append হয় আর সময় ধরে ভাগ করা, তাই সেকেন্ডে 10,000 event লেখা সস্তা, আর এক ঘণ্টার data মানে পড়ার জন্য একটা directory।
  • Count নতুন করে হিসাব হয়, জায়গায় update হয় না: batch আগের count আর নতুন ঘণ্টা পড়ে পুরো নতুন একটা set লেখে। কোনো row নিয়ে কাড়াকাড়ি হয় না।
  • trie_versions হলো control plane: server গুলো এটা poll করে (বা notification পায়), নিজের shard এর file নামায়, checksum মিলিয়ে swap করে। Rollback মানে পুরনো একটা row কে আবার live করে দেওয়া।
  • Replication আর partitioning: log replicate করে Kafka আর object storage; trie "replicate" হয় প্রতিটা server এ একই file load করে; shard হলো prefix range (Scaling দেখো)। ছোট PostgreSQL table গুলোর জন্য একটা standby ই যথেষ্ট।

Architecture এর ছবি

Browsertypes d, h, aLBSuggesttrie in RAMPrefix cacheversion:prefixSearch APIon EnterQuery logappend onlyAggregatorhourly batchTrie buildertop 3 per nodeSnapshot storetrie v2 filedhav1:dhahittop 3searchcountstrie v2online: each keystroke reads a precomputed list, cache firstoffline: submitted searches are logged and counted in batchesa new trie is built, stored and swapped in; no request waits for it
Play চাপো। Online: একটা key চাপা যায় Browser → LB → Suggest server এ, যেটা আগে prefix cache দেখে, নাহলে নিজের memory র trie পড়ে। Offline: user Enter চাপলে search টা log হয়; ঘণ্টায় একবার batch সেটা গোনে, builder নতুন trie বানায়, file হিসেবে রাখে, আর server গুলো সেটা swap করে নেয়। কোনো request offline পথের জন্য অপেক্ষা করে না।
Componentকেন আছেকার সাথে যুক্ত
BrowserDebounce করে, পুরনো request cancel করে, session জুড়ে উত্তর cache করেGET /v1/suggest নিয়ে → CDN / LB
CDN edge (শেষ design এ)সবচেয়ে বেশি আসা ছোট prefix গুলোর উত্তর user এর কাছ থেকেই দেয়URL ধরে 60 s cache
Load balancerSuggest server গুলোর মধ্যে request ভাগ করে, অসুস্থ server বাদ দেয়→ suggest server
Suggest serverপুরো trie RAM এ রাখে; pointer ধরে হেঁটে prefix এর উত্তর দেয়শুধু পড়ে; snapshot load করে
Prefix cacheবারবার আসা prefix এ হাঁটাটা বাঁচায়; key = version:prefixআগে process এর ভেতরের LRU; shared Redis শুধু তখন যখন উত্তর বানানো খরুচে
Search APIUser query submit করলে একটা search event log করে→ query log
Query logDurable, শুধু append হওয়া search event এর stream→ aggregator
Aggregatorঘণ্টায় একবার batch: search গোনে, পুরনো score অর্ধেক করে, blocklist আর privacy threshold লাগায়→ trie builder
Trie builderTrie আর প্রতিটা node এর top 10 বানায়, যাচাই করে→ snapshot store
Snapshot storeObject storage এ version করা trie file→ suggest server নামিয়ে নেয়

Prefix cache নিয়ে একটা কথা: RAM এ trie ধরে হাঁটা এমনিতেই microsecond এর ব্যাপার, তাই সামনে একটা shared Redis বসালে উল্টো একটা network hop বাড়ে। এখানে আমাদের cache হলো process এর ভেতরের একটা LRU, যেটা hot prefix এর হাঁটা আর JSON বানানো বাঁচায়। Shared Redis এর দাম ওঠে পরে, যখন একটা উত্তর কয়েকটা shard থেকে জোড়া লাগাতে হয় বা personal history র সাথে মেশাতে হয়। এটাই caching strategy: আগে browser, তারপর CDN, তারপর process এর ভেতরে, Redis শুধু খরুচে জোড়া লাগানো উত্তরের জন্য।

যেগুলো ইচ্ছা করে রাখিনি: read পথে database, read পথে কোনো write, বা প্রথম দিন থেকে stream processor (ঘণ্টায় একবারের batch দিয়েই freshness এর requirement মেটে)।

একটা request এর জীবন

একটা prefix টাইপ করা (cache miss)

BrowserSuggestCacheTrie (RAM)d, dh, dha: wait 100 msGET /suggest?q=dhaGET v1:dhamissTop("dha")3 suggestionsSET v1:dha200 [...] max-age=60show the dropdown
Play চাপো। User d, h, a টাইপ করে; browser একটু থামার জন্য অপেক্ষা করে "dha" এর জন্য একটাই request পাঠায়। Suggest server cache এ "v1:dha" খোঁজে, পায় না, trie ধরে "dha" node এ হেঁটে যায়, সেখানে রাখা list টা নেয়, cache ভরে, আর 60 সেকেন্ডের max-age সহ উত্তর দেয়। Browser dropdown দেখায়।
  1. User d, h, a টাইপ করে। শেষ key এর পর browser ~100 ms অপেক্ষা করে, তাই একটাই request যায়: GET /v1/suggest?q=dha। dh এর request তখনো রাস্তায় থাকলে সেটা cancel হয়।
  2. আগে browser cache, তারপর CDN: আশেপাশের কেউ গত 60 সেকেন্ডে dha চেয়ে থাকলে edge ই উত্তর দেয়, আমাদের server টের ও পায় না।
  3. LB → যেকোনো suggest server। Server prefix টা normalise করে (ছোট হাতের অক্ষর, একটা করে space, সর্বোচ্চ 50 অক্ষর; শেষের space রেখে দেয়, কারণ dhaka আর dhaka আলাদা prefix)।
  4. Current trie এর pointer একবার পড়ে (version 1), cache এ 1:dha খোঁজে। Miss।
  5. Trie তে d → h → a হাঁটে: তিন ধাপ। Top-10 list ওই node এ আগে থেকেই রাখা, খোঁজা বা sort করার কিছু নেই।
  6. List টা 1:dha নামে রাখে আর Cache-Control: public, max-age=60 সহ 200 দেয়। Server এর মোট সময়: এক millisecond এর অনেক কম।

একটা search গোনা হচ্ছে (offline পথ)

Query lograw searchesCountper queryMergehalve old, blockBuildtop 3 per nodeSnapshottrie v2 fileServer 1Server 2Server 3metro x9metro 9weather 3trie v2v1v1v1v2v2v2every hour: count what people actually submittedhalve old counts so a new trend can win; drop blocked wordsbuild the whole trie offline and store it as one fileservers load v2 and swap one pointer: v1 serves until then
Play চাপো। Log থেকে search গুলো query ধরে গোনা হয়; merge আগের সময়ের score অর্ধেক করে যাতে নতুন trend জিততে পারে, আর blocked শব্দ বাদ দেয়; builder পুরো নতুন একটা trie বানায়, প্রতিটা node এ top-3 list সহ; সেটা একটা file হিসেবে রাখা হয়; তারপর প্রতিটা server v2 load করে একটা pointer swap করে। নিজের swap পর্যন্ত server গুলো v1 থেকেই উত্তর দিতে থাকে।
  1. User "dhaka metro rail" এ Enter চাপে। Search service query log এ {event_id, query, lang, user_hash, ts} লেখে। Search নিজে এর জন্য অপেক্ষা করে না।
  2. প্রতি ঘণ্টায় aggregator গত ঘণ্টার event পড়ে, duplicate event_id বাদ দেয়, query normalise করে গোনে।
  3. আগের score এর সাথে মেলায়: score = old_score / 2 + new_count (exponential decay, যাতে গত সপ্তাহ মিলিয়ে যায় আর নতুন trend সামনে আসতে পারে), blocked শব্দ আর 20 জন আলাদা user এর নিচের query বাদ দেয়।
  4. Builder নতুন trie বানায়, নিচ থেকে উপরে প্রতিটা node এর top 10 হিসাব করে, তারপর যাচাই চালায় (size আগের build এর ±20% এর মধ্যে, কিছু "golden" prefix এখনো ঠিকঠাক উত্তর দেয়, কোনো blocked শব্দ নেই)।
  5. Snapshot file checksum সহ object storage এ যায়; trie_versions এর একটা row প্রথমে validated, তারপর live হয়।
  6. প্রতিটা suggest server file নামায়, পুরনো trie এর পাশে memory তে load করে, আর একটা pointer swap করে। যে request গুলো চলছিল সেগুলো পুরনো trie তেই শেষ হয়; নতুন গুলো নতুনটা পায়। পুরনো cache entry র key তে পুরনো version, তাই সেগুলো আর কখনো serve হয় না, এমনি এমনি বের হয়ে যায়।

Component গুলো গভীরে

প্রতিটা node এ top-K সহ trie

rootddhdhadhakdhaka"dhaka "dhakaidadhakaiya biryani 4dal recipe 2dha"dhaka "walk d, h, a: three steps, no searching belowthe answer is already stored at the nodedha: dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3"dhaka ": dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2built offline, bottom-up: a node's list = its own query + its children's lists
Play চাপো। "dha" টাইপ করলে root থেকে তিনটা ধাপ হেঁটে "dha" node এ আগে থেকে রাখা top-3 list টা ফেরত আসে। "dhaka " (space সহ) টাইপ করলে আরো এগিয়ে আলাদা একটা node এ যায়, list ও আলাদা। List গুলো offline এ, নিচ থেকে উপরে ভরা হয়েছিল।

সাধারণ trie তে "কোন query গুলো d দিয়ে শুরু?" এর উত্তর দিতে d এর নিচের পুরো subtree হাঁটতে হয়: লক্ষ লক্ষ node। আমরা request এর সময় এটা করি না। বরং offline এ বানানোর সময় প্রতিটা node তার subtree র সেরা K টা query রেখে দেয়। হিসাবটা হয় নিচ থেকে উপরে: একটা node এর list হলো তার নিজের query (যদি ওখানে কোনো query শেষ হয়) আর children দের list মিলিয়ে, sort করে, K তে কাটা। Children দের list আগেই নিজের subtree র সেরা, তাই merge শুধু K × children টা জিনিস দেখে, পুরো subtree না।

আগে থেকে list না রাখলেপ্রতিটা node এ top-K রাখলে
Lookupprefix হাঁটো + পুরো subtree ঘাঁটো + sortprefix হাঁটো, list ফেরত দাও
"d" এর খরচলক্ষ লক্ষ node1 ধাপ
সাধারণভাবে খরচO(subtree এর size)O(prefix এর দৈর্ঘ্য)
বাড়তি memoryনেইপ্রতিটা node এ K টা entry (string না, query ID রাখো)
একটা count বদলানোসস্তাroot পর্যন্ত পথের সব list ঠিক করতে হয়, তাই আমরা batch এ পুরোটা আবার বানাই

Production এর জন্য memory বাঁচানোর কৌশল: list এ string এর বদলে 4-byte query ID রাখো; একটাই child আছে এমন node এর শিকল জুড়ে দাও (radix tree); একটা নির্দিষ্ট গভীরতা পর্যন্তই list রাখো, তার নিচে ছোট একটা scan; বা finite-state transducer ব্যবহার করো (Lucene এর suggester যেটা ব্যবহার করে), যেটা prefix এর সাথে suffix ও ভাগাভাগি করে।

Immutable snapshot আর atomic swap

Trie কখনো জায়গায় edit হয় না। প্রতিটা build পুরো নতুন একটা trie বানায়; server সেটা current টার পাশে load করে, তারপর একটা মাত্র pointer swap করে। এতে তিনটা জিনিস ফ্রিতে পাই:

  • Read পথে কোনো lock নেই: reader কখনো আধা-update হওয়া trie দেখে না, কারণ কেউ ওটা update ই করে না।
  • Consistent উত্তর: একটা request শুরুতে একবারই pointer পড়ে, তাই তার পুরো উত্তর এক version থেকে আসে (Go এর TestReadersDuringSwaps test টা -race সহ ঠিক এটাই যাচাই করে)।
  • সাথে সাথে rollback: আগের file এখনো object storage এ আছে; ফিরে যাওয়াও ঠিক একই কাজ।

দাম হলো memory: swap এর সময় একটা server অল্প সময়ের জন্য দুইটা trie রাখে (2 × 5 GB)। Server এর size সেই হিসাবে ঠিক করো, নাহলে shard গুলো একটা একটা করে load করো।

Version সহ cache key

Cache key যদি শুধু prefix হতো, তাহলে swap এর পরেও প্রতিটা entry expire না হওয়া পর্যন্ত cache পুরনো list দিতে থাকত। প্রতিটা swap এ সব server জুড়ে সব key মুছে ফেলা ধীর, আর race এর ঝুঁকি আছে। তার বদলে key তে trie এর version থাকে: 412:dha। 413 এ swap হওয়ার পর প্রতিটা lookup 413:... key ব্যবহার করে, তাই পুরনো entry গুলোতে আর কেউ hit করে না, LRU এগুলো বের করে দেয়। CDN এর key তে আমাদের version বসানো যায় না (ও URL ধরে cache করে), তাই সেখানে max-age (60 s) পর্যন্ত পুরনো থাকা মেনে নিই, freshness এর requirement এটা অনুমতি দেয়।

Decay সহ গোনা, আর trend

Batch হিসাব করে score = old_score / 2 + this_hour (production এ অর্ধেক করার হারটা ঠিক করে নেওয়া হয়, যেমন কয়েক দিনের half-life)। Decay না থাকলে গত বছরের "eid ul adha 2025" এই সপ্তাহের খবরকে চিরকাল হারিয়ে দিত। Decay থাকলে হঠাৎ অনেক search হওয়া একটা query এক-দুইটা build এর মধ্যেই পুরনো প্রিয় query গুলোকে ছাড়িয়ে যায়। Demo ঠিক এটাই দেখায়: "dhaka metro rail" 1 টা search থেকে এক দিনের ভারী search এর পর d এর একদম উপরে চলে যায়।

"15 মিনিটে breaking news" এর জন্য ঘণ্টার batch যথেষ্ট না। শেষ design এ ছোট একটা stream job যোগ হয় যেটা গত 15 মিনিটের count রাখে, যে query গুলো স্বাভাবিকের চেয়ে অনেক দ্রুত বাড়ছে সেগুলো খুঁজে বের করে, আর server গুলোতে ছোট একটা "boost list" পাঠায়; পরের batch সেগুলো নিজের মধ্যে নেওয়া পর্যন্ত server গুলো এটা উত্তরে মিশিয়ে দেয়। আলাদা, সহজ একটা পথ, যাতে মূল build টা একঘেয়ে আর নির্ভরযোগ্য থাকে।

চালিয়ে দেখার মতো core

এই program টা design এর মূল অংশ, এক বসায় পড়ে ফেলার মতো ছোট: normalisation, submit করা search গোনার একটা aggregator, decay আর blocklist সহ একটা merge, প্রতিটা prefix এর top K আগে থেকে হিসাব করে রাখা একটা trie, version:prefix key এর একটা LRU cache, আর একটা service যেটা key চাপার উত্তর দেয় আর atomic ভাবে নতুন trie swap করে।

autocomplete/
├── go.mod
├── trie.go               Trie: Build (with top K per node), Top
├── aggregate.go          Normalize, Aggregator, Merge (decay + blocklist)
├── cache.go              LRU cache of version:prefix -> suggestions
├── service.go            Service: Suggest, Publish (atomic swap)
├── autocomplete_test.go  top K vs brute force, swaps under -race, versioned cache, merge
└── cmd/demo/main.go      two days of searches, one user typing, a rebuild and a swap
// trie.go
package autocomplete

import "sort"

// Suggestion is one query and how often people searched for it.
type Suggestion struct {
	Query string
	Count int
}

// better orders suggestions: more searches first, then alphabetically, so every build and
// every language gives the same list.
func better(a, b Suggestion) bool {
	if a.Count != b.Count {
		return a.Count > b.Count
	}
	return a.Query < b.Query
}

type node struct {
	kids  map[byte]*node
	count int          // > 0 when a whole query ends here
	top   []Suggestion // the best k queries that start with this prefix, precomputed
}

// Trie is a read-only snapshot built offline from search counts. Every node already knows
// its top k, so a lookup is just "walk the prefix, return the list": no searching at query time.
type Trie struct {
	root    *node
	k       int
	Version int
	Queries int
	Nodes   int
}

// Build makes a trie from query counts and fills in the top k of every prefix.
func Build(version int, counts map[string]int, k int) *Trie {
	t := &Trie{root: &node{kids: map[byte]*node{}}, k: k, Version: version, Nodes: 1}
	for q, c := range counts {
		if c <= 0 || q == "" {
			continue
		}
		n := t.root
		for i := 0; i < len(q); i++ {
			next, ok := n.kids[q[i]]
			if !ok {
				next = &node{kids: map[byte]*node{}}
				n.kids[q[i]] = next
				t.Nodes++
			}
			n = next
		}
		n.count = c
		t.Queries++
	}
	t.fill(t.root, nil)
	return t
}

// fill computes top k bottom-up: a node's list is its own query plus its children's lists.
func (t *Trie) fill(n *node, path []byte) {
	var all []Suggestion
	if n.count > 0 {
		all = append(all, Suggestion{string(path), n.count})
	}
	for c, kid := range n.kids {
		t.fill(kid, append(path, c))
		all = append(all, kid.top...)
	}
	sort.Slice(all, func(i, j int) bool { return better(all[i], all[j]) })
	if len(all) > t.k {
		all = all[:t.k]
	}
	n.top = all
}

// Top returns the precomputed best k for a prefix (nil if nobody searched it). The slice
// belongs to the snapshot: callers must not change it.
func (t *Trie) Top(prefix string) []Suggestion {
	n := t.root
	for i := 0; i < len(prefix); i++ {
		if n = n.kids[prefix[i]]; n == nil {
			return nil
		}
	}
	return n.top
}
// aggregate.go
package autocomplete

import (
	"strings"
	"sync"
)

// MaxLen is the longest query we store or look up, in bytes.
const MaxLen = 50

// Normalize makes "  Dhaka   WEATHER " and "dhaka weather" the same query: lowercase, single
// spaces, trimmed, at most MaxLen bytes. (This demo uses ASCII; production also normalises
// Unicode, and Bangla queries work the same way.)
func Normalize(s string) string {
	s = strings.Join(strings.Fields(strings.ToLower(s)), " ")
	if len(s) > MaxLen {
		s = strings.TrimRight(s[:MaxLen], " ")
	}
	return s
}

// Aggregator counts searches as they happen, like the log pipeline that feeds the builder.
type Aggregator struct {
	mu     sync.Mutex
	counts map[string]int
	Seen   int
}

func NewAggregator() *Aggregator { return &Aggregator{counts: map[string]int{}} }

// Add records one search that the user actually submitted.
func (a *Aggregator) Add(raw string) {
	q := Normalize(raw)
	if q == "" {
		return
	}
	a.mu.Lock()
	defer a.mu.Unlock()
	a.counts[q]++
	a.Seen++
}

// Drain hands over the counts collected so far and starts a new period.
func (a *Aggregator) Drain() map[string]int {
	a.mu.Lock()
	defer a.mu.Unlock()
	out := a.counts
	a.counts = map[string]int{}
	a.Seen = 0
	return out
}

// Merge prepares the next build: old counts are halved (so last week fades and new trends can
// win), this period's counts are added, and queries with a blocked word are removed.
func Merge(old, recent map[string]int, blocked map[string]bool) (counts map[string]int, removed int) {
	counts = map[string]int{}
	for q, c := range old {
		if c/2 > 0 {
			counts[q] = c / 2
		}
	}
	for q, c := range recent {
		counts[q] += c
	}
	for q := range counts {
		for _, w := range strings.Fields(q) {
			if blocked[w] {
				delete(counts, q)
				removed++
				break
			}
		}
	}
	return counts, removed
}
// cache.go
package autocomplete

import (
	"container/list"
	"sync"
)

// Cache is a small LRU cache of "version:prefix" -> suggestions, like Redis (or a CDN edge)
// in front of the trie servers.
type Cache struct {
	mu           sync.Mutex
	cap          int
	order        *list.List // front = most recently used
	items        map[string]*list.Element
	Hits, Misses int
}

type entry struct {
	key  string
	list []Suggestion
}

func NewCache(capacity int) *Cache {
	return &Cache{cap: capacity, order: list.New(), items: map[string]*list.Element{}}
}

func (c *Cache) Get(key string) ([]Suggestion, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	e, ok := c.items[key]
	if !ok {
		c.Misses++
		return nil, false
	}
	c.Hits++
	c.order.MoveToFront(e)
	return e.Value.(entry).list, true
}

func (c *Cache) Put(key string, list []Suggestion) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if e, ok := c.items[key]; ok {
		c.order.MoveToFront(e)
		return
	}
	if c.order.Len() == c.cap {
		old := c.order.Back()
		c.order.Remove(old)
		delete(c.items, old.Value.(entry).key)
	}
	c.items[key] = c.order.PushFront(entry{key, list})
}
// service.go
// Package autocomplete is the core of a search autocomplete service: searches are counted,
// a trie with the top k per prefix is built offline, swapped in atomically, and served
// through a cache whose keys carry the trie's version.
package autocomplete

import (
	"strconv"
	"strings"
	"sync/atomic"
)

// Service is one suggestion server. Lookups never lock the trie: it is read-only, and a new
// build replaces it with one atomic pointer swap.
type Service struct {
	trie    atomic.Pointer[Trie]
	cache   *Cache
	lookups atomic.Int64 // requests that reached the trie (cache misses)
}

func NewService(t *Trie, cache *Cache) *Service {
	s := &Service{cache: cache}
	s.trie.Store(t)
	return s
}

// Publish swaps in a freshly built trie. Requests already running finish on the old one.
func (s *Service) Publish(t *Trie) { s.trie.Store(t) }

// Version is the version of the trie being served now.
func (s *Service) Version() int { return s.trie.Load().Version }

// Lookups is how many requests missed the cache and walked the trie.
func (s *Service) Lookups() int64 { return s.lookups.Load() }

// Suggest answers one keystroke. The prefix is normalised like stored queries, except that a
// trailing space is kept ("dhaka " should not suggest "dhakaiya"). The cache key includes the
// version, so after a swap old answers are never served; they simply age out of the LRU.
func (s *Service) Suggest(raw string) []Suggestion {
	p := Normalize(raw)
	if p == "" {
		return nil
	}
	if strings.HasSuffix(raw, " ") && len(p) < MaxLen {
		p += " "
	}
	t := s.trie.Load() // one snapshot for the whole request
	key := strconv.Itoa(t.Version) + ":" + p
	if list, ok := s.cache.Get(key); ok {
		return list
	}
	s.lookups.Add(1)
	list := t.Top(p)
	s.cache.Put(key, list)
	return list
}
// cmd/demo/main.go
// Command demo counts a day of searches, builds a trie, serves keystrokes through a cache,
// then counts a second day, rebuilds and swaps the new trie in.
package main

import (
	"fmt"
	"strings"

	"autocomplete"
)

func show(s *autocomplete.Service, typed string) {
	list := s.Suggest(typed)
	parts := []string{}
	for _, x := range list {
		parts = append(parts, fmt.Sprintf("%s %d", x.Query, x.Count))
	}
	if len(parts) == 0 {
		parts = append(parts, "(no suggestions)")
	}
	fmt.Printf("  %-12s -> %s\n", `"`+typed+`"`, strings.Join(parts, " | "))
}

func searches(a *autocomplete.Aggregator, q string, times int) {
	for i := 0; i < times; i++ {
		a.Add(q)
	}
}

func main() {
	blocked := map[string]bool{"fake": true}
	agg := autocomplete.NewAggregator()

	// Day 1: what people submitted, messy spelling and all.
	searches(agg, "dhaka weather", 3)
	searches(agg, "Dhaka Weather", 1)
	searches(agg, "  dhaka   weather ", 1)
	searches(agg, "dhakaiya biryani", 4)
	searches(agg, "dhaka fake certificate", 4)
	searches(agg, "dhaka traffic", 3)
	searches(agg, "dhaka to chittagong bus", 2)
	searches(agg, "dal recipe", 2)
	searches(agg, "dhaka metro rail", 1)
	searches(agg, "eid sale", 3)
	searches(agg, "egg price", 1)
	fmt.Printf("day 1: %d searches logged\n", agg.Seen)
	counts, removed := autocomplete.Merge(nil, agg.Drain(), blocked)
	t1 := autocomplete.Build(1, counts, 3)
	fmt.Printf("build v1: %d queries (%d blocked), %d trie nodes, top 3 kept per prefix\n", t1.Queries, removed, t1.Nodes)

	cache := autocomplete.NewCache(8)
	s := autocomplete.NewService(t1, cache)
	fmt.Println("user 1 types \"dhaka m\", one request per keystroke:")
	for _, p := range []string{"d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"} {
		show(s, p)
	}
	fmt.Println("user 2 and a few others:")
	for _, p := range []string{"d", "dh", "dha", "  DHAKA  ", "e", "xyz"} {
		show(s, p)
	}
	fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())

	// Day 2: a new metro line opens and everyone searches for it.
	searches(agg, "dhaka metro rail", 9)
	searches(agg, "dhaka weather", 1)
	searches(agg, "eid sale", 2)
	fmt.Printf("day 2: %d searches logged\n", agg.Seen)
	counts, _ = autocomplete.Merge(counts, agg.Drain(), blocked)
	t2 := autocomplete.Build(2, counts, 3)
	s.Publish(t2)
	fmt.Printf("build v2: old counts halved + day 2 added -> %d queries; now serving v%d\n", t2.Queries, s.Version())
	for _, p := range []string{"d", "dhaka ", "e"} {
		show(s, p)
	}
	fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())
}

Demo চালালে:

$ go run ./cmd/demo
day 1: 25 searches logged
build v1: 8 queries (1 blocked), 83 trie nodes, top 3 kept per prefix
user 1 types "dhaka m", one request per keystroke:
  "d"          -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dh"         -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dha"        -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhak"       -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhaka"      -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhaka "     -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
  "dhaka m"    -> dhaka metro rail 1
user 2 and a few others:
  "d"          -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dh"         -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dha"        -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "  DHAKA  "  -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
  "e"          -> eid sale 3 | egg price 1
  "xyz"        -> (no suggestions)
cache: 4 hits, 9 misses; trie lookups: 9
day 2: 12 searches logged
build v2: old counts halved + day 2 added -> 7 queries; now serving v2
  "d"          -> dhaka metro rail 9 | dhaka weather 3 | dhakaiya biryani 2
  "dhaka "     -> dhaka metro rail 9 | dhaka weather 3 | dhaka to chittagong bus 1
  "e"          -> eid sale 3
cache: 4 hits, 12 misses; trie lookups: 12

Output টা পড়া:

  • প্রথম দিন আর build: 25 টা search log হয়েছে, কিন্তু "Dhaka Weather" আর " dhaka weather " normalise হয়ে "dhaka weather" হয়ে গেছে, তাই এগুলো 5 টা search সহ একটাই query। "dhaka fake certificate" 4 বার search হয়েছিল, dhaka এর নিচে দ্বিতীয় suggestion হয়ে যেত; build এর আগেই blocklist এটা বাদ দিয়েছে। 8 টা query থেকে 83 node এর trie।
  • "dhaka m" টাইপ করা: d থেকে dhaka পর্যন্ত সবগুলোতে একই তিনটা suggestion: "dhaka weather" (5), "dhakaiya biryani" (4), "dhaka traffic" (3)। এটাই ঠিক: এই সব prefix এর নিচে এগুলোই সেরা query। Space টাইপ করার সাথে সাথে dhaka থেকে "dhakaiya biryani" বাদ পড়ে (এটা "dhaka " দিয়ে শুরু না), "dhaka to chittagong bus" ঢুকে যায়। dhaka m এ থাকে শুধু "dhaka metro rail"।
  • দ্বিতীয় user: d, dh, dha cache hit: প্রথম user আগেই এগুলো চেয়েছিল। DHAKA normalise হয়ে dhaka (শেষের space রাখা হয়) হয়, সেটাও hit। xyz কিছু দেয় না, আর এই খালি উত্তরটাও cache হয়, তাই কোনো bot আজেবাজে prefix দিয়ে trie কে চাপ দিতে পারে না।
  • Cache এর সংখ্যা: এ পর্যন্ত 4 টা hit আর 9 টা miss; শুধু miss গুলোই trie তে হেঁটেছে।
  • দ্বিতীয় দিন: নতুন metro line চালু হলো, "dhaka metro rail" 9 বার search হলো। Merge প্রথম দিনকে অর্ধেক করে (weather 5 → 2, সাথে নতুন 1 = 3) আর দ্বিতীয় দিন যোগ করে। "egg price" (1 → 0) হারিয়ে যায়। v2 তে "dhaka metro rail" (9) d আর dhaka দুই জায়গাতেই সবার উপরে।
  • Swap এর পর: d, dhaka আর e এক মিনিট আগে cache এ থাকলেও miss, কারণ key এখন 2:d, 1:d না। কেউ পুরনো list দেখে না, আর কাউকে কিছু মুছতেও হয়নি।

Test গুলো সবচেয়ে জরুরি জিনিসগুলো যাচাই করে: random data তে আগে থেকে হিসাব করা top K আর brute-force উত্তর একই, normalisation আর শেষের space, merge এর decay আর blocklist, swap এর পর কখনো পুরনো cache করা list আসে না, 49 টা swap চলার সময় যে reader গুলো চলছে তারা সবসময় ঠিক একটা version থেকে উত্তর পায় (-race সহ চালানো), আর একসাথে গোনায় কোনো search হারায় না। Atomic pointer এর জায়গায় সাধারণ pointer বসালে swap এর test এ -race data race ধরে, তাই test টা সত্যিই swap কে পাহারা দেয়।

$ go test -race -v ./...
=== RUN   TestTopKMatchesBruteForce
--- PASS: TestTopKMatchesBruteForce (0.00s)
=== RUN   TestNormalize
--- PASS: TestNormalize (0.00s)
=== RUN   TestMergeDecaysAndBlocks
--- PASS: TestMergeDecaysAndBlocks (0.00s)
=== RUN   TestSwapNeverServesOldCacheEntries
--- PASS: TestSwapNeverServesOldCacheEntries (0.00s)
=== RUN   TestTrailingSpaceMatters
--- PASS: TestTrailingSpaceMatters (0.00s)
=== RUN   TestReadersDuringSwaps
--- PASS: TestReadersDuringSwaps (0.00s)
=== RUN   TestAggregatorConcurrentAdds
--- PASS: TestAggregatorConcurrentAdds (0.01s)
PASS
ok  	autocomplete	1.022s
?   	autocomplete/cmd/demo	[no test files]

একই core আরো পাঁচটা language এ

Single-threaded version, একই class, একই output। Swap এখনো একটা reference এর assignment; বদলায় শুধু thread থাকলে প্রতিটা language কীভাবে সেটা নিরাপদ রাখে:

  • Python: GIL এর নিচে self.trie = new_trie assignment atomic, আর একটা request নিজের reference t ধরে রাখে, তাই ওই request শেষ হওয়া পর্যন্ত পুরনো trie বেঁচে থাকে।
  • TypeScript: Node সব request একটা event loop এ চালায়, তাই swap কোনো lookup এর মাঝখানে ঢুকতে পারে না। (Class টার নাম SuggestCache, কারণ Cache browser এর একটা global।)
  • Rust: service একটা Rc<Trie> রাখে, প্রতিটা request সেটা clone করে। Thread থাকলে atomic swap এর পেছনে Arc<Trie> লাগবে (যেমন arc-swap crate), আর thread-safe না এমন version compiler নিজেই মানবে না।
  • Java: AtomicReference<Trie> হলো Go এর atomic.Pointer এর সরাসরি জোড়া, আর access order এ LinkedHashMap একটা তৈরি LRU।
  • C++: service একটা std::shared_ptr<const Trie> রাখে; প্রতিটা request সেটা copy করে, তাতে পুরনো trie বেঁচে থাকে। Thread থাকলে swap এর জন্য std::atomic<std::shared_ptr<const Trie>> (C++20)।
"""Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
offline, swapped in as a whole, and served through a cache whose keys carry the trie's version."""
from collections import OrderedDict
from dataclasses import dataclass, field

MAX_LEN = 50


@dataclass(frozen=True)
class Suggestion:
    query: str
    count: int


def rank(s: Suggestion) -> tuple[int, str]:
    """More searches first, then alphabetical: the same order in every language."""
    return (-s.count, s.query)


@dataclass
class Node:
    kids: dict[str, "Node"] = field(default_factory=dict)
    count: int = 0  # > 0 when a whole query ends here
    top: list[Suggestion] = field(default_factory=list)  # best k with this prefix, precomputed


class Trie:
    """A read-only snapshot built offline. Every node already knows its top k."""

    def __init__(self, version: int, counts: dict[str, int], k: int):
        self.root, self.k, self.version = Node(), k, version
        self.queries, self.nodes = 0, 1
        for q, c in counts.items():
            if c <= 0 or not q:
                continue
            n = self.root
            for ch in q:
                if ch not in n.kids:
                    n.kids[ch] = Node()
                    self.nodes += 1
                n = n.kids[ch]
            n.count = c
            self.queries += 1
        self._fill(self.root, "")

    def _fill(self, n: Node, path: str) -> None:
        """Bottom-up: a node's list is its own query plus its children's lists."""
        everything = [Suggestion(path, n.count)] if n.count > 0 else []
        for ch, kid in n.kids.items():
            self._fill(kid, path + ch)
            everything += kid.top
        n.top = sorted(everything, key=rank)[: self.k]

    def top(self, prefix: str) -> list[Suggestion]:
        n = self.root
        for ch in prefix:
            n = n.kids.get(ch)
            if n is None:
                return []
        return n.top


def normalize(s: str) -> str:
    """Lowercase, single spaces, trimmed, at most MAX_LEN characters."""
    s = " ".join(s.lower().split())
    return s[:MAX_LEN].rstrip(" ")


class Aggregator:
    """Counts submitted searches, like the log pipeline that feeds the builder."""

    def __init__(self):
        self.counts: dict[str, int] = {}
        self.seen = 0

    def add(self, raw: str) -> None:
        q = normalize(raw)
        if q:
            self.counts[q] = self.counts.get(q, 0) + 1
            self.seen += 1

    def drain(self) -> dict[str, int]:
        out, self.counts, self.seen = self.counts, {}, 0
        return out


def merge(old: dict[str, int], recent: dict[str, int], blocked: set[str]) -> tuple[dict[str, int], int]:
    """Old counts halved, recent counts added, queries with a blocked word removed."""
    counts = {q: c // 2 for q, c in old.items() if c // 2 > 0}
    for q, c in recent.items():
        counts[q] = counts.get(q, 0) + c
    kept = {q: c for q, c in counts.items() if not any(w in blocked for w in q.split())}
    return kept, len(counts) - len(kept)


class Cache:
    """LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers."""

    def __init__(self, capacity: int):
        self.cap, self.items = capacity, OrderedDict()
        self.hits = self.misses = 0

    def get(self, key: str) -> list[Suggestion] | None:
        if key not in self.items:
            self.misses += 1
            return None
        self.hits += 1
        self.items.move_to_end(key)
        return self.items[key]

    def put(self, key: str, value: list[Suggestion]) -> None:
        if key in self.items:
            self.items.move_to_end(key)
            return
        if len(self.items) == self.cap:
            self.items.popitem(last=False)
        self.items[key] = value


class Service:
    """One suggestion server. A new build replaces the trie in one assignment."""

    def __init__(self, trie: Trie, cache: Cache):
        self.trie, self.cache, self.lookups = trie, cache, 0

    def publish(self, trie: Trie) -> None:
        self.trie = trie

    def suggest(self, raw: str) -> list[Suggestion]:
        p = normalize(raw)
        if not p:
            return []
        if raw.endswith(" ") and len(p) < MAX_LEN:
            p += " "  # "dhaka " must not suggest "dhakaiya"
        t = self.trie  # one snapshot for the whole request
        key = f"{t.version}:{p}"
        cached = self.cache.get(key)
        if cached is not None:
            return cached
        self.lookups += 1
        found = t.top(p)
        self.cache.put(key, found)
        return found


def show(s: Service, typed: str) -> None:
    parts = [f"{x.query} {x.count}" for x in s.suggest(typed)] or ["(no suggestions)"]
    label = '"' + typed + '"'
    print(f"  {label:<12} -> {' | '.join(parts)}")


def searches(a: Aggregator, q: str, times: int) -> None:
    for _ in range(times):
        a.add(q)


def main() -> None:
    blocked = {"fake"}
    agg = Aggregator()
    # Day 1: what people submitted, messy spelling and all.
    searches(agg, "dhaka weather", 3)
    searches(agg, "Dhaka Weather", 1)
    searches(agg, "  dhaka   weather ", 1)
    searches(agg, "dhakaiya biryani", 4)
    searches(agg, "dhaka fake certificate", 4)
    searches(agg, "dhaka traffic", 3)
    searches(agg, "dhaka to chittagong bus", 2)
    searches(agg, "dal recipe", 2)
    searches(agg, "dhaka metro rail", 1)
    searches(agg, "eid sale", 3)
    searches(agg, "egg price", 1)
    print(f"day 1: {agg.seen} searches logged")
    counts, removed = merge({}, agg.drain(), blocked)
    t1 = Trie(1, counts, 3)
    print(f"build v1: {t1.queries} queries ({removed} blocked), {t1.nodes} trie nodes, top 3 kept per prefix")

    cache = Cache(8)
    s = Service(t1, cache)
    print('user 1 types "dhaka m", one request per keystroke:')
    for p in ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"]:
        show(s, p)
    print("user 2 and a few others:")
    for p in ["d", "dh", "dha", "  DHAKA  ", "e", "xyz"]:
        show(s, p)
    print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")

    # Day 2: a new metro line opens and everyone searches for it.
    searches(agg, "dhaka metro rail", 9)
    searches(agg, "dhaka weather", 1)
    searches(agg, "eid sale", 2)
    print(f"day 2: {agg.seen} searches logged")
    counts, _ = merge(counts, agg.drain(), blocked)
    t2 = Trie(2, counts, 3)
    s.publish(t2)
    print(f"build v2: old counts halved + day 2 added -> {t2.queries} queries; now serving v{s.trie.version}")
    for p in ["d", "dhaka ", "e"]:
        show(s, p)
    print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")


if __name__ == "__main__":
    main()

Scaling এর ধাপ

এই ধাপগুলো বোঝানোর জন্য; আসল সীমা hardware আর traffic এর ধরনের উপর নির্ভর করে।

ধাপTrafficআগে কী ভাঙেকী বদলায়
1. 100 userমিনিটে কয়েকটা suggestionকিছু নাআলাদা কোনো system লাগে না: index সহ ছোট একটা query_counts table এ LIKE 'dha%' ORDER BY count LIMIT 10, count রাতে একবার হিসাব।
2. 10K userসেকেন্ডে কয়েক ডজনd এর মতো ছোট prefix অনেক row ঘাঁটেরাতে একবার prefix → top 10 table বানিয়ে primary key দিয়ে পড়া; Cache-Control যোগ করা যাতে browser উত্তর cache করে।
3. 100K userসেকেন্ডে কয়েকশোপ্রতিটা key চাপায় database; রাতের freshnessMemory তে trie সহ ছোট একটা suggest service, log থেকে ঘণ্টায় একবার বানানো, file থেকে load; client এ debounce।
4. 1M userসেকেন্ডে কয়েক হাজারএকটা server এর CPU; নতুন data deploy করা ঝুঁকিরLB এর পেছনে কয়েকটা replica, ছোট prefix এর জন্য CDN, Kafka log, batch job, snapshot যাচাই, আর rollback সহ version ধরে rollout।
5. 10M+ userসেকেন্ডে কয়েক দশ হাজার, কয়েকটা language আর regionTrie একটা machine এ আর ধরে না; দূরের user ধীর উত্তর পায়; খবরের জন্য ঘণ্টা বেশি ধীরPrefix range ধরে shard (range কাটা হয় traffic মেপে, তাই d হয়তো নিজেই একটা shard আর x-z মিলে একটা), প্রতিটা language এর আলাদা trie, প্রতিটা region এ snapshot copy, trend এর জন্য stream job।

খেয়াল করো, ছোট product এর জন্য ধাপ 1 আর 2 পুরোপুরি ভালো design। Trie service, Kafka আর stream job হলো ধাপ 4 আর 5 এর হাতিয়ার; 100 user এর site এর জন্য এগুলো বানানো মানে খরচ আর on-call এর ঝামেলা, লাভ কিছু না।

Hash না করে prefix range ধরে shard কেন? একটা lookup সবসময় ঠিক একটা prefix এর জন্য, আর trie এর কোন অংশ লাগবে সেটা prefix ই ঠিক করে। Range shard (a-c, d-k, l-z) এ router প্রথম অক্ষর (বা কয়েকটা) দেখে request টা সেই একটা shard এ পাঠায় যেখানে ওই subtree আছে। Prefix string hash করেও চলে, যদি একটা সমতল prefix → list table রাখো (প্রতিটা prefix নিজেই একটা key), তবে storage বেশি লাগে, কারণ প্রতিটা prefix এর list আলাদা করে রাখা থাকে। Range এর ঝামেলা হলো skew: x এর চেয়ে d বা s দিয়ে অনেক বেশি query শুরু হয়, তাই range কাটতে হয় মাপা traffic আর size দেখে, বর্ণমালা দেখে না।

Failure এর পরিস্থিতি

Failureকী হয়কীভাবে ধরা পড়েকীভাবে সারেData নিরাপদ?User এর উপর প্রভাব
Suggest server crashওই মুহূর্তের request গুলো failLB health checkLB ওখানে পাঠানো বন্ধ করে; নতুন server current snapshot load করে, warm up করে, তারপর ready বলেহারানোর কিছু নেই: trie একটা file এর copyকয়েকটা dropdown আসে না; পরের key চাপায় ঠিক
খারাপ build (খালি, বিশাল, বা নষ্ট trie)আজেবাজে বা কোনো suggestion নাLive করার আগে যাচাই: আগের build এর সাথে size, golden prefix, blocklist scan; একটা server এ canaryLive ই করো না; ফসকে গেলে আগের version আবার live করো (একটা row)পুরনো snapshot কয়েকদিন রাখা থাকেযাচাইয়ে ধরা পড়লে কিছু না; নাহলে কয়েক মিনিট অদ্ভুত suggestion
Batch pipeline বা log বন্ধনতুন build হয় নাLive version এর বয়স (3 ঘণ্টার বেশি হলে alert)ঠিক করে আবার চালাও; log event গুলো রাখে (Kafka retention), তাই কিছু বাদ পড়ে নাEvent রাখা আছে; count পিছিয়ে থেকে ধরে ফেলেSuggestion পুরনো হয়, কিন্তু চলতে থাকে
Cache নষ্টপ্রতিটা request trie তে হাঁটেHit rate কমে যায়জরুরি কিছু না: process এর cache নিজে ভরে যায়; নষ্ট Redis পাশ কাটানো হয়Cache এ এমন কোনো data নেই যেটা আর কোথাও নেইCPU একটু বেশি, উত্তর একই
Region এর মধ্যে network partitionএকটা region নতুন snapshot আনতে পারে নাRegion গুলোর মধ্যে version এর ফারাকLink ফেরা পর্যন্ত প্রতিটা region নিজের শেষ ভালো snapshot serve করেশুধু পড়ার data, নষ্ট হওয়ার কিছু নেইএক region এ একটু পুরনো suggestion
Duplicate log eventএকটা search দুইবার গোনা হয়Batch এ duplicate event_idBatch event_id ধরে duplicate বাদ দেয়Count ঠিক থাকেকিছু না
Traffic spike (breaking news)অনেক user একই ছোট prefix টাইপ করেসেকেন্ডে request, CPUবারবার আসা prefix CDN সামলায়; replica autoscale (ওদের শুধু snapshot file লাগে)শুধু পড়া, ঝুঁকি নেইEdge সামলালে কিছু না
কারসাজি (bot একটা কথা বারবার search করে)একটা ভুয়া query suggestion এ উঠে আসেঅল্প কয়েকজন user বা IP থেকে spike; আলাদা user এর thresholdRaw search না, আলাদা user গোনো; rate limit; শব্দটা block করোBlocklist আর threshold build এর সময়ই লাগেসরানো পর্যন্ত একটা খারাপ suggestion

সবগুলোর মধ্যে মিল: serve করার দিকটা একটা file এর শুধু-পড়ার copy, তাই প্রায় প্রতিটা failure শেষ হয় "শেষ ভালো version টা serve করো" দিয়ে, আর user তেমন কিছু টের পায় না। Client এর দিকেও autocomplete কে নিরাপদে ব্যর্থ হতে দিতে হবে: request ~300 ms এর বেশি নিলে browser শুধু dropdown দেখায় না, search তো চলছেই।

Reliability আর observability

  • SLI: server এ আর আসল browser এ মাপা suggest latency (p50, p99); success rate; freshness (live trie version এর বয়স); কত ভাগ request edge থেকে উত্তর পেয়েছে।
  • SLO: 30 দিনে 99.9% suggest request সফল আর 99% server এ 10 ms এর নিচে শেষ; live trie কখনো 3 ঘণ্টার বেশি পুরনো না।
  • SLA: সাধারণত নেই; এটা ভেতরের feature। API হিসেবে বিক্রি করলে SLO এর চেয়ে ঢিলা রাখো।
  • Metric: status ধরে request, cache hit rate (process এর ভেতরে আর CDN এ), প্রতিটা server এর trie version (ফারাক ধরতে), build এর সময় আর size, blocklist আর threshold কয়টা query বাদ দিল, prefix এর দৈর্ঘ্য ধরে খালি উত্তরের হার।
  • Product metric: suggestion এর click-through rate, আর একটা বেছে নেওয়ার আগে মানুষ কয় অক্ষর টাইপ করে। Ranking আদৌ ভালো কিনা এগুলোই বলে।
  • Alert: error budget দ্রুত পুড়লে আর "3 ঘণ্টায় একটাও সফল build নেই" হলে page; server গুলোর মধ্যে version এর ফারাক 15 মিনিটের বেশি থাকলে আর build এর size 20% এর বেশি বদলালে ticket।
  • Log: suggest request এর sample রাখো (prefix personal হতে পারে, তাই অল্প দিন রাখো আর কখনো user ID এর সাথে জোড়া দিও না); build আর swap এর পুরো log।
  • Tracing: CDN → LB → server জুড়ে sample trace; বেশি কাজে লাগে যখন latency আমাদের code থেকে না, network থেকে আসে।
  • Health check: trie load হওয়ার আর golden prefix এর self-test পাস করার পরই readiness; liveness শুধু process দেখে।
  • Disaster recovery: snapshot গুলো object storage এ file (version সহ, দ্বিতীয় region এ copy করা); নতুন region একটা নামিয়ে নিলেই serve শুরু করতে পারে। রাখা log থেকে count আবার বানানো যায়।

Security

  • Content safety ই মূল ঝুঁকি। মানুষ যা টাইপ করে সেটা লাখো মানুষকে দেখানো হতে পারে। Blocklist build এর সময় লাগাও, আর জরুরি অবস্থায় সরানোর জন্য serve করার সময়ও (প্রতিটা উত্তরে দেখা হয় এমন ছোট একটা deny-set, তাতে rebuild ছাড়াই কয়েক মিনিটে সরানো যায়)।
  • Privacy: অন্তত N জন আলাদা user (যেমন 20) search না করলে কোনো query suggest করবে না; এতে কেউ একবার টাইপ করা phone number, নাম, ঠিকানা বাইরে থাকে। Phone number, email বা ID number এর মতো দেখতে pattern ও বাদ দাও। Search log এ user ID hash করে রাখা হয়, অল্প দিন রাখা হয়, আর এই pipeline এর জন্য account এর সাথে জোড়া হয় না।
  • কারসাজি: raw search না গুনে আলাদা user গোনো, user আর IP ধরে search submit এ rate limit, আর অল্প কয়েকটা উৎস থেকে হঠাৎ spike এর দিকে নজর রাখো।
  • Input validation: prefix এর দৈর্ঘ্য সীমা (50), control character বাদ, lookup এর আগে Unicode normalise, আর শুধু চেনা lang মানা।
  • Rate limiting: /v1/suggest এ IP আর client ধরে; edge এ caching ও বারবার আসা prefix এর বন্যা থেকে বাঁচায়। আজেবাজে prefix খালি উত্তর হিসেবে cache হয়।
  • Authorization: শুধু staff blocked শব্দ বদলাতে বা version roll করতে পারে; প্রতিটা বদল audit হয়।
  • Encryption: সব জায়গায় TLS; snapshot আর log at rest encrypted।

Trade-off analysis

সিদ্ধান্তবেছে নিয়েছিবিকল্পকেন এটাকখন বিকল্পে যাবে
Serve করার structureপ্রতিটা node এ top-K সহ RAM এ trieElasticsearch/OpenSearch completion suggester (FST)সবচেয়ে সহজ আর দ্রুত পথ, ranking পুরো নিজের হাতে, data memory তে ধরেFuzzy matching ও লাগবে, অনেক field, বা নিজের একটা service চালাতে চাও না
Ranking কখনOffline এ top-K আগে থেকে হিসাবQuery এর সময় subtree থেকে rankingছোট prefix এ প্রতিটা key চাপায় লক্ষ লক্ষ node ঘাঁটতে হতোখুব ছোট data, বা এত personal ranking যেটা আগে হিসাব করা যায় না
Freshnessঘণ্টার batch + ছোট trend streamTrie তে পুরোপুরি real-time updateImmutable snapshot সহজ, lock ছাড়া, rollback সহজযখন কয়েক মিনিটের দেরিতে সত্যিই টাকা যায় (live খেলা, stock ticker)
Data রাখার ধরনRange shard ধরে trie snapshotKey-value store এ সমতল prefix → list tableঘন (prefix ভাগাভাগি), পাঠানোর জন্য একটা fileঘন storage এর চেয়ে সহজ hash sharding আর প্রতি key তে update বেশি দরকার
CachingBrowser + CDN (60 s) + process এর LRU, version সহ keyCDN না; বা আগে shared Redisছোট prefix প্রচুর বার আসে; version সহ key তে মোছামুছি লাগে নাPersonal উত্তর CDN এ যায় না; কয়েক shard জোড়া লাগানো উত্তরে Redis এর দাম ওঠে
গোনাআলাদা user এর threshold + decayসব সময়ের raw countPrivacy, কারসাজি ঠেকানো আর trend, এক নিয়মেখুব ছোট site যেখানে threshold দিলে কিছুই থাকে না

ডানে যত যাও operational খরচ তত বাড়ে: FST service, real-time trie, প্রতি key র store, প্রতিটা চালানোর জন্য একটা করে বাড়তি system। প্রতিটাই আলাদা একটা requirement এ ঠিক সিদ্ধান্ত, আর কোন requirement এ সেটা বলতে পারাই interviewer শুনতে চায়।

শেষ architecture

UsersCDN edge60 s cacheLBshard a-c3 replicasshard d-k3 replicasshard l-z3 replicasRedismerged answersQuery logKafka topicAggregatorhourly batchTrie builderone per shardSnapshotsversionedTrendingstream, 15 minddhakcountsv2boostshort popular prefixes are answered at the edgethe rest go to the shard that owns the prefixbatch builds a trie per shard; a stream job adds fast trendstwo paths: serve precomputed lists fast, rebuild them in the background
Play চাপো। "d" এর মতো ছোট popular prefix এর উত্তর CDN edge ই দেয়। বাকিগুলো load balancer হয়ে সেই shard এ যায় যেটা ওই prefix range এর মালিক; প্রতিটা shard হলো memory তে trie সহ কয়েকটা replica। Offline এ query log যায় ঘণ্টার aggregator আর builder এ, যেটা প্রতিটা shard এর জন্য একটা trie বানিয়ে version সহ snapshot রাখে; দুই build এর মাঝে ছোট একটা stream job দ্রুত বাড়তে থাকা trend কে boost দেয়।

এই design requirement এর সাথে মেলে কারণ প্রতিটা অংশ একটা সংখ্যা বা একটা নিয়মের উত্তর: সেকেন্ডে 63,000 request আর 10 ms এর budget মেটে read পথে database ছাড়া memory র lookup দিয়ে; ছোট prefix এর বিপুল পুনরাবৃত্তি সামলায় CDN; "এক ঘণ্টার মধ্যে fresh" batch pipeline এর অনুমতি দেয়, আর immutable, version করা snapshot এর জন্য সেই pipeline প্রতি ঘণ্টায় নিশ্চিন্তে চালানো যায়, সাথে সাথে rollback সহ; content আর privacy এর নিয়ম লাগে যেখানে data বানানো হয় সেখানেই, জরুরি অবস্থার জন্য serve করার সময়ের deny-set সহ। Sharding আর trend stream আঁকা হয়েছে কারণ ধাপ 5 এ design টা সেদিকেই যায়, নতুন product এর এগুলো লাগে বলে না।

Interview এর আলোচনা

1. Database এ সোজা LIKE 'prefix%' চালালে কী সমস্যা?

উত্তর

Index মিলে যাওয়া row গুলো খুঁজে দেয়, কিন্তু "d" এর মতো ছোট prefix এ সেগুলো লক্ষ লক্ষ, আর top 10 নিতে তবুও popularity ধরে sort করতে হয়। সেকেন্ডে কয়েক দশ হাজার request এ এটা অসম্ভব। প্রতিটা prefix এর top 10 আগে থেকে হিসাব করে রাখলে প্রতিটা request একটা key lookup হয়ে যায়। ছোট site এর জন্য database query ঠিক আছে, আর সেটা বলাও উচিত।

2. Trie কত বড়, memory তে ধরবে?

উত্তর

আন্দাজ: 1 কোটি suggest করার মতো query × ~20 অক্ষর মানে সর্বোচ্চ 20 কোটি node, prefix ভাগাভাগিতে মোটামুটি 6 কোটি। প্রতিটা node এ 4 byte এর 10 টা query ID আর কিছু overhead মিলে মোটামুটি 5 GB, একটা server এ ধরে যায়, তাই প্রতিটা replica পুরোটা রাখতে পারে। না ধরলে compress করো (radix tree, FST), শুধু trie এর উপরের দিকে list রাখো, বা prefix range ধরে shard করো।

3. Breaking news এর জন্য suggestion fresh থাকে কীভাবে?

উত্তর

দুইটা পথ। Decay সহ score এর ঘণ্টার batch সাধারণ trend সামলায়। মিনিটের freshness এর জন্য একটা stream job গত 15 মিনিট গোনে, যে query গুলো স্বাভাবিকের চেয়ে অনেক দ্রুত বাড়ছে সেগুলো ধরে, আর ছোট একটা boost list পাঠায় যেটা server গুলো পরের batch পর্যন্ত উত্তরে মেশায়। আলাদা রাখলে মূল build সহজ আর নিরাপদ থাকে।

4. Traffic না থামিয়ে trie update করবে কীভাবে?

উত্তর

কখনো জায়গায় বদলাবে না। Offline এ পুরো নতুন trie বানাও, version করা file হিসেবে পাঠাও, পুরনোটার পাশে load করো, তারপর একটা atomic pointer swap করো। যে request গুলো পুরনো version এ শুরু হয়েছিল সেগুলো সেখানেই শেষ হয়। Cache key তে version থাকে, তাই swap এর পর কোনো পুরনো উত্তর আসে না, আর rollback মানে আবার swap করা।

5. "dhkaa" এর মতো typo সামলাবে কীভাবে?

উত্তর

ঠিক prefix এর trie এটা পারে না। উপায়: সাধারণ ভুল বানানগুলো ঠিক query র alias হিসেবে যোগ করো (log থেকে শেখা, যেখানে user নিজেই ঠিক করেছে), বা সীমিত edit distance দিয়ে fuzzy matching (FST এর উপর Levenshtein automata, যেমন Lucene এ)। Fuzzy lookup বেশি খরুচে, তাই সাধারণত শুধু তখনই ব্যবহার হয় যখন ঠিক prefix এ খুব কম result আসে।

6. Suggestion personal করবে কীভাবে?

উত্তর

Popularity র জন্য global trie রাখো, আর একসাথে user এর ছোট একটা list আনো (সাম্প্রতিক search, user ধরে রাখা); server এ সহজ একটা scoring নিয়মে দুইটা মেশাও। মেশানো উত্তর আর CDN এ cache করা যায় না, তাই কয়েক অক্ষরের পর থেকে বা শুধু login করা user এর জন্য personal করো, আর global অংশ আলাদা করে cache করো।

7. আমাদের user রা বাংলা আর Banglish এ টাইপ করে। কী বদলাবে?

উত্তর

Unicode normalise (NFC) করো, যাতে আলাদা keyboard এ টাইপ করা একই শব্দ মেলে; প্রতিটা language বা script এর জন্য আলাদা trie বানাও; আর transliteration ভাবো, যাতে "dhaka" টাইপ করলে "ঢাকা" র query ও আসতে পারে। Prefix এর দৈর্ঘ্যের সীমা byte না, অক্ষর গুনবে। Design এর বাকিটা একই।

8. Client কীভাবে সাহায্য করে?

উত্তর

Debounce (শেষ key এর পর ~50–100 ms অপেক্ষা), নতুন key এলে রাস্তায় থাকা request cancel, session জুড়ে উত্তর cache, আর ক্রম ভেঙে আসা response বাদ। এগুলো মিলে প্রায়ই request অর্ধেকের বেশি কমিয়ে দেয়।

সাধারণ ভুল: প্রতিটা key চাপায় database এ query; submit করা search না গুনে key চাপা গোনা; lock নিয়ে জায়গায় trie update; CDN আর Cache-Control ভুলে যাওয়া; আপত্তিকর বা personal suggestion নিয়ে কোনো plan না থাকা; সব সময়ের raw count, ফলে trend কখনো আসে না; আন্দাজ করার আগেই Kafka আর stream processing আঁকা।

Interviewer যা দেখে: সংখ্যা সহ একটা latency budget, ছোট prefix এর কারণে query এর সময় ranking যে অসম্ভব সেটা ধরতে পারা, offline এ বানানো আর online এ serve করার পরিষ্কার ভাগ, নতুন data নিরাপদে চালু করার একটা উপায়, ঠিক layer এ caching আর পুরনো থাকা নিয়ে যুক্তি, আর safety ও privacy নিয়ে সৎ মনোযোগ।

নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।