পাঠ ৪৯ · ৫১-এর মধ্যে · মডিউল ১১
Home / Courses / System Design / সার্চ অটোকমপ্লিট

কেস স্টাডি: সার্চ অটোকমপ্লিট সিস্টেম ডিজাইন করা

Case study: designing search autocomplete
১০ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • কেন অটোকমপ্লিটের লেটেন্সি রিকোয়ারমেন্ট এই কোর্সের অন্য যেকোনো সিস্টেমের চেয়ে কড়া
  • Trie-এর প্রতিটি নোডে প্রি-কম্পিউটেড টপ-K কমপ্লিশন রাখার কৌশল — এবং কেন এটি লাইভ র‍্যাঙ্কিং এড়ায়
  • এই সিস্টেম কেন প্রায়ই পুরোপুরি ইন-মেমরি, রেপ্লিকেটেড স্টেটলেস সার্ভারে (L12, L19) চলে, প্রতি-কোয়েরি ডেটাবেস হিট ছাড়াই
  • Python দিয়ে একটি প্রি-কম্পিউটেড-টপ-K Trie বাস্তবায়ন করা

১ · রিকোয়ারমেন্ট

ফাংশনাল
ইউজার টাইপ করার সাথে সাথে প্রাসঙ্গিকতা/জনপ্রিয়তা অনুযায়ী র‍্যাঙ্ক করা কমপ্লিশন সাজেস্ট করা।
নন-ফাংশনাল
অতি-কম লেটেন্সি (এক কিস্ট্রোক বাজেটের মধ্যে সাড়া দিতে হবে), অত্যন্ত উচ্চ QPS (প্রতিটি ইউজারের প্রতিটি কিস্ট্রোক একটি রিকোয়েস্ট)।

একটি ৪-অক্ষরের সার্চ টার্মে গড়ে ৪টি কিস্ট্রোক-রিকোয়েস্ট হয় — তাই এই সিস্টেমের প্রকৃত QPS আসলে ব্যবহারকারী সংখ্যার চেয়ে বহুগুণ বেশি (L02-এর মতো QPS হিসাব করার সময় "প্রতি সার্চে একাধিক রিকোয়েস্ট" এই ফ্যাক্টরটা মাথায় রাখা জরুরি)। আর প্রতিটি রিকোয়েস্টের জবাব একটি মানুষ সরাসরি টাইপ করতে করতে দেখছে — তাই লেটেন্সি বাজেট এই কোর্সের অন্য যেকোনো সিস্টেমের চেয়ে কড়া।

২ · মূল স্ট্রাকচার — প্রি-কম্পিউটেড টপ-K সহ Trie

DSA কোর্স থেকে পরিচিত TrieTrie (Prefix Tree)একটি ট্রি ডেটা স্ট্রাকচার যেখানে প্রতিটি পাথ (রুট থেকে একটি নোড পর্যন্ত) একটি স্ট্রিং প্রিফিক্স প্রতিনিধিত্ব করে — একই প্রিফিক্সের সব শব্দ একই পাথ শেয়ার করে। — একটি ট্রি যেখানে রুট থেকে যেকোনো নোড পর্যন্ত পথ একটি প্রিফিক্স বোঝায়। সাধারণ Trie শুধু "এই প্রিফিক্স দিয়ে কোন কোন শব্দ শুরু হয়" জানে, কিন্তু অটোকমপ্লিটের জন্য প্রতিটি অনুসন্ধানে সেই সব শব্দ জনপ্রিয়তা অনুযায়ী র‍্যাঙ্ক করা লাগবে — যা প্রতি-কিস্ট্রোকে করলে বেশি ধীর।

সমাধান: প্রতিটি Trie নোডে আগে থেকেই সেই প্রিফিক্সের টপ-K সবচেয়ে জনপ্রিয় সম্পূর্ণ শব্দের একটি ছোট তালিকা সংরক্ষণ করা হয় — বিল্ড টাইমে (বা পর্যায়ক্রমিক ব্যাচ রিফ্রেশে)। একটি লুকআপ তখন শুধু টাইপ করা প্রিফিক্স অনুসরণ করে নিচে নামা এবং সেই নোডের প্রি-কম্পিউটেড লিস্ট ফেরত দেওয়া — কোনো লাইভ স্ক্যান বা সর্টিং নেই।

ইউজার টাইপ করছে Keystroke স্টেটলেস সার্ভার (L12) In-memory Trie প্রি-কম্পিউটেড টপ-K Return list (no live rank) অফলাইন ব্যাচ রিফ্রেশ (L24)
প্রতিটি লুকআপ শুধু Trie ট্র্যাভার্সাল — জনপ্রিয়তা স্কোর আলাদা, ধীর ব্যাচ জব দিয়ে আপডেট হয়।

৩ · ডিপ ডাইভ — বাস্তবায়ন

নিচের কোড একটি ছোট Trie বানায় যেখানে প্রতিটি নোডে সর্বোচ্চ ৩টি (টপ-৩) কমপ্লিশন প্রি-কম্পিউটেড থাকে। শব্দ যোগ করার সময় (insert) সেই শব্দের পাথের প্রতিটি নোডে টপ-৩ তালিকা আপডেট হয়; খোঁজার সময় (search) শুধু সেই প্রি-কম্পিউটেড তালিকা ফেরত দেওয়া হয়।

Python
TOP_K = 3

class TrieNode:
    def __init__(self):
        self.children = {}
        self.top_completions = []   # [(word, popularity), ...] — সাজানো, সর্বোচ্চ TOP_K

def _update_top_k(node, word, popularity):
    # আগের এন্ট্রি (যদি থাকে) সরিয়ে নতুন করে যোগ করে জনপ্রিয়তা অনুযায়ী সাজানো
    node.top_completions = [(w, p) for w, p in node.top_completions if w != word]
    node.top_completions.append((word, popularity))
    node.top_completions.sort(key=lambda pair: -pair[1])
    node.top_completions = node.top_completions[:TOP_K]

def insert(root, word, popularity):
    node = root
    for ch in word:
        node = node.children.setdefault(ch, TrieNode())
        _update_top_k(node, word, popularity)   # পাথের প্রতিটি প্রিফিক্স নোডে আপডেট

def search(root, prefix):
    node = root
    for ch in prefix:
        if ch not in node.children:
            return []
        node = node.children[ch]
    return node.top_completions

# নমুনা শব্দ ও জনপ্রিয়তা স্কোর (কল্পিত সার্চ-লগ থেকে আসতে পারত)
sample_words = [
    ("cat", 50), ("car", 80), ("card", 65), ("care", 40), ("cart", 30),
    ("dog", 90), ("door", 20), ("dorm", 10),
]

root = TrieNode()
for word, popularity in sample_words:
    insert(root, word, popularity)

for prefix in ["ca", "car", "do"]:
    results = search(root, prefix)
    print(f"prefix '{prefix}' -> {results}")

    
কোডটি চালিয়ে দেখুন — "ca" প্রিফিক্সে car (৮০), card (৬৫), cat (৫০) — জনপ্রিয়তার ক্রমে, যদিও cat বর্ণানুক্রমে আগে আসত এবং সবার আগে ইনসার্ট হয়েছিল। একইভাবে "do" প্রিফিক্সে dog (৯০) সবার আগে আসে, যদিও ইনসার্ট হয়েছিল সবার শেষে। এটি প্রমাণ করে ফলাফলের ক্রম শুধু জনপ্রিয়তার উপর নির্ভর করছে, ইনসার্শন-অর্ডারের উপর নয়।

৪ · ট্রেড-অফ

প্রি-কম্পিউটেশন লুকআপকে অতি-দ্রুত করে, কিন্তু ফলাফল সবসময় সাম্প্রতিকতম নাও হতে পারে — একটি নতুন ট্রেন্ডিং সার্চ টার্ম টপ-K লিস্টে ঢুকতে পরবর্তী ব্যাচ রিফ্রেশ (L24) পর্যন্ত অপেক্ষা করতে হয়। আরও বেশি TOP_K রাখলে ফলাফল বৈচিত্র্যময় হয় কিন্তু প্রতিটি নোডের মেমরি খরচ বাড়ে — লক্ষ লক্ষ নোডের একটি Trie-তে এটি গুরুত্বপূর্ণ। যেহেতু পুরো Trie সাধারণত প্রতিটি স্টেটলেস সার্ভারে (L12) ইন-মেমরি রেপ্লিকেটেড থাকে (এই ডেটাসেট তুলনামূলক ছোট এবং রিড-হেভি, ঠিক L19-এর ক্যাশিং নীতির মতো), নতুন কোনো সার্ভার যোগ করা সহজ — কিন্তু প্রতিটি সার্ভারকে Trie-এর একটি সম্পূর্ণ কপি রাখতে হয়, যা ছোট থাকা অবস্থাতেই সম্ভব।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ কেন প্রতিটি Trie নোডে টপ-K প্রি-কম্পিউট করে রাখাটা প্রতি-কোয়েরি সব ম্যাচিং শব্দ খুঁজে লাইভ সর্ট করার চেয়ে ভালো, যখন উভয়ই সঠিক ফলাফল দেয়?

লাইভ সর্টিং-এ একটি জনপ্রিয় প্রিফিক্সের (যেমন "a") হাজার হাজার ম্যাচিং শব্দ থাকতে পারে — প্রতিটি কিস্ট্রোকে এত শব্দ সংগ্রহ করে সর্ট করা (O(m log m), m=ম্যাচ সংখ্যা) খুব ধীর, বিশেষত যখন এটি লক্ষ লক্ষ কিস্ট্রোকে প্রতি সেকেন্ডে ঘটছে। প্রি-কম্পিউটেশন এই ব্যয়বহুল কাজটি একবার (বা পর্যায়ক্রমে) করে রাখে, আর প্রতিটি লুকআপ হয় শুধু একটি Trie ট্র্যাভার্সাল (প্রিফিক্সের দৈর্ঘ্যের সমানুপাতিক) — সময়ের ট্রেড-অফটা রাইট-টাইম থেকে রিড-টাইমে সরিয়ে দেওয়া, যা রিড-হেভি ওয়ার্কলোডে সবসময় লাভজনক।

প্র ০২ এই সিস্টেমের জনপ্রিয়তা স্কোর অফলাইন ব্যাচ জব (L24) দিয়ে রিফ্রেশ হয়, লাইভ স্ট্রিম দিয়ে নয়। কখন এই সিদ্ধান্ত ভুল হতে পারে?

একটি হঠাৎ ব্রেকিং নিউজ বা ভাইরাল ইভেন্টের ক্ষেত্রে — একটি নতুন সার্চ টার্ম মিনিটের মধ্যে অত্যন্ত জনপ্রিয় হয়ে উঠতে পারে, কিন্তু ব্যাচ জব যদি প্রতি কয়েক ঘণ্টায় একবার চলে, ততক্ষণ Trie সেটা প্রতিফলিত করবে না। এই পরিস্থিতির জন্য বাস্তব সিস্টেম প্রায়ই একটি হাইব্রিড পদ্ধতি ব্যবহার করে — মূল Trie ব্যাচ-রিফ্রেশড, কিন্তু একটি ছোট, দ্রুত-আপডেট-হওয়া "ট্রেন্ডিং" স্তর আলাদাভাবে ওভারলে করা হয়।

প্র ০৩ পুরো Trie প্রতিটি স্টেটলেস অ্যাপ সার্ভারে ইন-মেমরি রেপ্লিকেটেড রাখাটা কেন এখানে যুক্তিসঙ্গত, যখন L14/L17-এ আমরা শিখেছি বড় ডেটাসেট শার্ড করা উচিত?

শার্ডিং তখন দরকার হয় যখন ডেটাসেট একক মেশিনের মেমরি/ডিস্কে আঁটে না। কিন্তু একটি সার্চ অটোকমপ্লিট Trie — এমনকি লক্ষ লক্ষ শব্দ নিয়েও — তুলনামূলক ছোট (প্রতিটি নোডে মাত্র কয়েকটি এন্ট্রি), তাই এটি সহজেই একটি একক সার্ভারের মেমরিতে পুরোপুরি আঁটে। এই ক্ষেত্রে প্রতিটি সার্ভারে সম্পূর্ণ কপি রাখা (L19-এর ক্যাশিং নীতির মতো) শার্ডিং-এর জটিলতা (ক্রস-শার্ড কোয়েরি, রিব্যালেন্সিং) এড়িয়ে সর্বোচ্চ পঠন-গতি দেয় — ডেটাসেট ছোট ও রিড-হেভি হলে এটাই সঠিক পছন্দ।

অনুশীলন

  1. পরিবর্তন করুন: কোড সেলে sample_words-এ একটি নতুন শব্দ ("cart", 95) (উচ্চ জনপ্রিয়তা সহ, বিদ্যমান "cart" ৩০-এর বদলে) যোগ করে আবার চালান। "ca" এবং "car" প্রিফিক্সের ফলাফল কীভাবে বদলায়?

    _update_top_k ফাংশন প্রথমে পুরনো "cart" এন্ট্রি সরিয়ে নতুন জনপ্রিয়তা (৯৫) দিয়ে যোগ করে, তাই cart এখন সবচেয়ে জনপ্রিয় হয়ে যাবে — "car" প্রিফিক্সে ফলাফল হবে [("cart", 95), ("car", 80), ("card", 65)], এবং "ca" প্রিফিক্সেও cart টপ-৩-এ চলে আসবে, cat-কে সরিয়ে দিয়ে।

  2. হিসাব করুন: একটি প্রিফিক্স যদি গড়ে ৫টি কিস্ট্রোক-রিকোয়েস্ট তৈরি করে (ইউজার ৫ অক্ষর টাইপ করা পর্যন্ত প্রতি অক্ষরে একটি রিকোয়েস্ট), এবং ১ কোটি (১,০০,০০,০০০) দৈনিক সার্চ হয়, তাহলে মোট দৈনিক অটোকমপ্লিট রিকোয়েস্ট কত হবে এবং গড় QPS কত?

    মোট রিকোয়েস্ট/দিন = ১,০০,০০,০০০ × ৫ = ৫,০০,০০,০০০। গড় QPS = ৫,০০,০০,০০০ / ৮৬,৪০০ ≈ ৫৭৮.৭। লক্ষ্য করুন এটি "১ কোটি সার্চ/দিন"-এর সরল QPS হিসাবের (~১১৫.৭) চেয়ে ৫ গুণ বেশি — কারণ প্রতিটি সার্চ একাধিক কিস্ট্রোক-রিকোয়েস্টে বিভক্ত, যা L02-এর ক্যাপাসিটি অনুমানে সবসময় বিবেচনায় রাখা জরুরি।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

পূর্ববর্তী পাঠ
কেস স্টাডি: ভিডিও স্ট্রিমিং প্ল্যাটফর্ম ডিজাইন করা