সরাসরি পর্বে যান
পর্ব ২৭ · ৬৬-এর মধ্যে · মডিউল ৪

Search — inverted index, ranking, autocomplete আর search cluster

Search Systems — Inverted Index, TF-IDF & BM25 Ranking, Autocomplete & Search Clusters

Taught in Bangla, this lesson shows why LIKE '%x%' fails at scale, how an inverted index is built with tokenization and normalization including Bangla text issues, posting-list merging and phrase queries, ranking from TF-IDF to BM25 with worked numbers, immutable segments, merges and near-real-time refresh, sharded search clusters with scatter-gather, keeping the index in sync with the database, autocomplete with a prefix trie, fuzzy matching and facets.

এই পর্বে যা শিখবেন

  • Database-এর LIKE কেন বড় হলে ভেঙে পড়ে, আর search engine কী কী যোগ করে
  • Inverted index কীভাবে বানানো হয় — tokenization, normalization আর বাংলা লেখার বিশেষ সমস্যা
  • Posting list মেলানোর merge algorithm, phrase query আর TF-IDF থেকে BM25 পর্যন্ত হাতে-কলমে হিসাব
  • Segment, merge, near-real-time refresh আর sharded search cluster-এর scatter-gather
  • Primary database-এর সাথে index মিলিয়ে রাখা, আর autocomplete, fuzzy match ও facet-এর নকশা

১ · LIKE '%x%' কেন যথেষ্ট নয় — search engine কী যোগ করে

একটি কাল্পনিক অনলাইন বাজারে ২০ লাখ পণ্য। একজন ক্রেতা সার্চ বক্সে লিখলেন “আম”. সবচেয়ে সরল উপায় SQL-এ WHERE title LIKE '%আম%'. তিনটি সমস্যা:

  • ধীর: শুরুতে % থাকলে B-tree index-এর সাজানো ক্রম কাজে লাগে না (পর্ব ২১), তাই প্রতিটি খোঁজে সব সারির পুরো লেখা পড়তে হয়। ২০ লাখ পণ্য × গড়ে ৬০ অক্ষর = প্রতি খোঁজে ১২ কোটি অক্ষর।
  • ভুল মিল: “আম” অক্ষর দুটি “আমদানি” আর “আমার”-এর ভেতরেও আছে। LIKE অক্ষর মেলায়, শব্দ নয়।
  • ক্রম, ভুল বানান আর ভাগ নেই: কোন পণ্য বেশি মানানসই LIKE জানে না। “রাজশাহি” লিখলে “রাজশাহী” আসে না। আর ফলের পাশে “ফজলি (১২০) · ল্যাংড়া (৮৫)”-এর মতো গোনা দেখানোও তার কাজ নয়।

ডাকঘরে চিঠি বাছাইয়ের কথা ভাবুন। প্রতিটি চিঠি আসার সময়ই এলাকার খোপে রাখা হয়। পরে “মিরপুরের সব চিঠি” চাইলে কাউকে হাজার চিঠি পড়তে হয় না, একটি খোপ খুললেই চলে। একটি চিঠিতে কয়েকটি ঠিকানা থাকলে তার নম্বর কয়েকটি খোপে লেখা হয়। Search engine ঠিক এটাই করে: লেখার সময় একটু বাড়তি কাজ, খোঁজার সময় বিশাল সাশ্রয়।

এই খোপের ব্যবস্থাকে বলে inverted index. এর ওপর একটি search index আরও কয়েকটি জিনিস যোগ করে:

চাহিদাLIKE '%x%'Search engine
খোঁজার খরচসব সারির সব অক্ষরশুধু প্রশ্নের শব্দগুলোর তালিকা
শব্দের রূপ (ট্রেনের → ট্রেন)নাanalyzer, stemming, synonym
প্রাসঙ্গিকতার ক্রমনাTF-IDF, BM25, জনপ্রিয়তা, সময়
ভুল বানান, প্রথম কয়েক অক্ষরনাfuzzy match, autocomplete
Facet (ভাগ অনুযায়ী গোনা)আলাদা GROUP BY, ধীরএকই খোঁজের সাথে
যা ছেড়ে দেয়—transaction, join, সঙ্গে সঙ্গে দেখা (strong consistency)
মূল ধারণা

Search index প্রায় সবসময় primary database-এর পাশে রাখা একটি derived copy — একই ডেটা, খোঁজার জন্য অন্যভাবে সাজানো। সত্যের উৎস database; index হারালে আবার বানানো যায়। পর্ব ৩৫-এর “আলাদা read model”-এর এটি সবচেয়ে চেনা উদাহরণ। ছোট মাপে (কয়েক লাখ ডকুমেন্ট, সরল চাহিদা) PostgreSQL-এর tsvector, MySQL-এর FULLTEXT বা SQLite FTS5-ই যথেষ্ট (DBMS · JSON আর full-text search). মাপ, ranking-এর সূক্ষ্মতা আর facet বাড়লে আলাদা search engine: Lucene-ভিত্তিক Elasticsearch, OpenSearch বা Solr.

২ · Inverted index — tokenization, normalization আর বাংলা লেখা

Index-এ ঢোকার আগে প্রতিটি লেখা একটি analyzer-এর ভেতর দিয়ে যায়। খোঁজার শব্দও হুবহু একই analyzer-এ যায় — নইলে দুই দিকের term মিলবে না।

একটি লেখা থেকে index-এর term

Text analysis pipeline: normalize → tokenize → filter → stem

  1. মূল লেখাঢাকার ট্রেনের টিকিটযেমন লেখা আছে
  2. Unicode NFCড় → ড + ়এক রূপে আনা
  3. Tokenizerঢাকার · ট্রেনের · টিকিটশব্দে ভাঙা
  4. Filterlowercase · stop wordবাদ আর ছোট হাতের অক্ষর
  5. Stem, synonymঢাকা · ট্রেন · টিকিটযে term index-এ যায়
ছবি ২৭.১ — Index-এ যায় শেষের term, আর প্রতিটি term-এর সাথে তার অবস্থান (position).
  • Tokenization: ফাঁকা জায়গা আর যতিচিহ্নে লেখা ভাঙা। বাংলার দাঁড়ি “।”-কেও যতিচিহ্ন ধরতে হয়, নইলে “টিকিট।” একটি আলাদা শব্দ হয়ে যায়।
  • Normalization: একই জিনিসকে এক রূপে আনা — ছোট হাতের অক্ষর, accent বাদ (café → cafe), Unicode-এর একটি নির্দিষ্ট রূপ।
  • Stop word: “আর”, “ও”, the, of-এর মতো প্রায় সব ডকুমেন্টে থাকা শব্দ বাদ। Phrase query-তে এগুলো লাগতে পারে, তাই অনেক ব্যবস্থা রেখে দিয়ে কম ওজন দেয়।
  • Stemming বনাম lemmatization: stemming নিয়ম ধরে শেষাংশ কাটে (ইংরেজিতে Porter-এর ১৯৮০ সালের stemmer সবচেয়ে পরিচিত); lemmatization অভিধান দেখে মূল রূপ খোঁজে (গেলাম, যাবে → যাওয়া)। Stemming সস্তা কিন্তু ভুল করে: খেলনা নিয়মে “-কে” কাটলে “থেকে” হয়ে যেত “থে” — over-stemming.

বাংলা লেখার বিশেষ চ্যালেঞ্জ

বড় হাতের অক্ষর নেই, বিভক্তি অনেক

Lowercase বাংলায় কিছুই করে না, শুধু মিশ্র লেখার ইংরেজি অংশে লাগে। আসল কাজ বিভক্তি: শাড়ি, শাড়ির, শাড়িতে, শাড়িগুলো। -এর, -কে, -গুলো কাটার নিয়ম কাজে দেয়, ভুলও করে।

হালকা stemmer বা n-gram

যুক্তাক্ষর আর Unicode-এর রূপ

“ক্ষ” আসলে ক + হসন্ত (্) + ষ — একাধিক code point, চোখে একটি grapheme cluster. “ড়” লেখা যায় এক code point-এ (U+09DC), আবার ড + নুকতায়। কিছু কিবোর্ড অদৃশ্য ZWJ/ZWNJ বসায়। তাই index আর query — দুই দিকেই একই Unicode normalization (সাধারণত NFC) চালাতে হয়।

NFC, অদৃশ্য অক্ষর বাদ

বাংলা, ইংরেজি আর Banglish একসাথে

“dhaka metro rail er ticket koto” — রোমান হরফে বাংলা, বানানও একাধিক (khobor, khabor). Transliteration-এর আলাদা field বা query log থেকে শেখা প্রতিশব্দ-তালিকা প্রচলিত কৌশল; একক নিয়ম নেই।

লগ দেখে ঠিক করুন

Index-এর প্রতিটি term-এর তালিকাকে বলে posting list: term → [(docId, tf, [positions])]. DocId-গুলো ছোট থেকে বড় সাজানো থাকে, তাই সংখ্যার বদলে পার্থক্য রাখা যায় — [3, 7, 21, 22] হয়ে যায় [3, 4, 14, 1] (delta encoding), আর ছোট সংখ্যা প্রায়ই ১ byte-এই ধরে। Term-গুলো নিজেরাও একটি সাজানো অভিধানে থাকে, যাতে একটি term দ্রুত খুঁজে পাওয়া যায়।

এবার আটটি কাল্পনিক পোস্টে একটি ছোট index: বেশিরভাগ বাংলা, একটি ইংরেজি, একটি Banglish. Analyzer ইচ্ছা করে খেলনা: NFC, ছোট হাতের অক্ষর, কয়েকটি stop word, কয়েকটি বিভক্তি কাটা আর ছয় শব্দের প্রতিশব্দ-তালিকা।

// Mini inverted index: 8 short posts (Bangla, English, Banglish), a TOY Bangla-aware analyzer.
const DOCS = ["ঢাকার মেট্রো রেলের টিকিট", "মেট্রো রেল স্টেশনগুলোর তালিকা",
  "Dhaka Metro Rail ticket price", "ঢাকা থেকে চট্টগ্রাম ট্রেনের টিকিট",
  "বাসের টিকিট ট্রেনের টিকিট লঞ্চের টিকিট", "dhaka metro rail er ticket koto",
  "চট্টগ্রামের বাস আর ট্রেনের সময়সূচি", "নতুন মেট্রো স্টেশনে ভিড়"]; // ড় = U+09DC
const STOP = new Set(["আর", "ও", "থেকে", "er", "the", "of"]);
const SYN = { dhaka: "ঢাকা", metro: "মেট্রো", rail: "রেল", ticket: "টিকিট", train: "ট্রেন", bus: "বাস" };
const stem = w => {                             // toy: strip -গুলোর, -গুলো, -দের, -এর, -কে, -র (after া)
  const s = w.replace(/(গুলোর|গুলো|দের|ের|কে)$|(?<=া)র$/u, ""); return s.length >= 2 ? s : w; };
const analyze = text => text.normalize("NFC").toLowerCase().split(/[\s,।?!]+/).filter(Boolean)
  .flatMap((w, pos) => STOP.has(w) ? [] : [[SYN[w] || stem(w), pos]]);   // [term, position]
const index = new Map();                        // term -> [[docId, [positions]], ...]
DOCS.forEach((text, i) => {
  const id = i + 1, mine = new Map();
  for (const [t, pos] of analyze(text)) (mine.get(t) || mine.set(t, []).get(t)).push(pos);
  for (const [t, ps] of mine) (index.get(t) || index.set(t, []).get(t)).push([id, ps]);
});
const ids = q => (index.get(analyze(q)[0]?.[0]) || []).map(p => p[0]);
function and(a, b) { const r = []; let i = 0, j = 0;      // two sorted lists, one pass
  while (i < a.length && j < b.length) a[i] === b[j] ? (r.push(a[i]), i++, j++) : a[i] < b[j] ? i++ : j++;
  return r; }
function or(a, b) { const r = []; let i = 0, j = 0;
  while (i < a.length || j < b.length) { const x = j >= b.length || (i < a.length && a[i] <= b[j]) ? a[i] : b[j];
    if (a[i] === x) i++; if (b[j] === x) j++; r.push(x); } return r; }
console.log("doc 4 ->", analyze(DOCS[3]).map(([t, p]) => t + "@" + p).join(" "));
console.log("doc 6 ->", analyze(DOCS[5]).map(([t, p]) => t + "@" + p).join(" "));
let n = 0; for (const ps of index.values()) n += ps.length;
console.log("terms:", index.size, " postings:", n);
for (const t of ["টিকিট", "মেট্রো"]) console.log(t, "->", index.get(t).map(([d, ps]) => d + ":[" + ps + "]").join(" "));
console.log("মেট্রো AND টিকিট ->", and(ids("মেট্রো"), ids("টিকিট")));
console.log("ট্রেন OR বাস     ->", or(ids("ট্রেন"), ids("বাস")));
console.log("metro AND ticket ->", and(ids("metro"), ids("ticket")));
console.log("স্টেশন ->", ids("স্টেশন"), " ভিড় (ড + nukta) ->", ids("ভিড়"));

আউটপুট:

doc 4 -> ঢাকা@0 চট্টগ্রাম@2 ট্রেন@3 টিকিট@4
doc 6 -> ঢাকা@0 মেট্রো@1 রেল@2 টিকিট@4 koto@5
terms: 16  postings: 34
টিকিট -> 1:[3] 3:[3] 4:[4] 5:[1,3,5] 6:[4]
মেট্রো -> 1:[1] 2:[0] 3:[1] 6:[1] 8:[1]
মেট্রো AND টিকিট -> [ 1, 3, 6 ]
ট্রেন OR বাস     -> [ 4, 5, 7 ]
metro AND ticket -> [ 1, 3, 6 ]
স্টেশন -> [ 2 ]  ভিড় (ড + nukta) -> [ 8 ]

Position থেকে যায়: doc 4-এ stop word “থেকে” (অবস্থান ১) বাদ, কিন্তু বাকিরা আসল অবস্থান রাখে — “ট্রেনের” হলো ট্রেন@3. Doc 6-এ “er” বাদ, আর dhaka, metro, rail, ticket প্রতিশব্দে বাংলা term হয়েছে। তাই ইংরেজি doc 3, Banglish doc 6 আর বাংলা doc 1 একই তালিকায়: মেট্রো AND টিকিট = [1, 3, 6], রোমান হরফের প্রশ্নেও তাই।

সংখ্যা: ১৬টি term, ৩৪টি posting; doc 5-এ টিকিট তিনবার ([1,3,5]) — ranking-এ কাজে লাগবে। NFC: doc 8-এর “ভিড়” এক code point-এর ড় দিয়ে লেখা, প্রশ্নটি ড + নুকতায়; NFC দুটিকে এক করেছে বলেই [8]. খেলনার সীমা: “স্টেশন” পেল শুধু [2] — doc 8-এর “স্টেশনে” নেই। ভালো stemmer, উপসর্গ খোঁজ বা n-gram এই ফাঁক ভরে।

৩ · Query চালানো — posting list মেলানো আর phrase query

AND মানে দুই তালিকার ছেদ, OR মানে মিলন, NOT মানে বাদ। তালিকাগুলো সাজানো, তাই merge sort-এর merge ধাপের মতো (DSA · merge sort) দুই আঙুলে একবার হাঁটলেই হয়: যেদিকের মান ছোট সেই আঙুল এগোয়, সমান হলে মিল। খরচ O(p₁ + p₂) — তালিকার দৈর্ঘ্য, ডকুমেন্টের মোট লেখা নয়।

index-এর দুটি তালিকা[ ] = এখন যেখানে আঙুল

            
প্রতিটি ধাপের পর
কাজ
ফল (AND)
মোট তুলনা
  1. প্রথম পোস্ট। “ঢাকার মেট্রো রেলের টিকিট” হলো ঢাকা@0, মেট্রো@1, রেল@2, টিকিট@3. প্রতিটি term-এর তালিকার শেষে একটি entry: মেট্রো → 1:[1], টিকিট → 1:[3]. পুরো index নতুন করে বানাতে হয় না।
  2. আরও দুটি। Doc 2-এ মেট্রো আছে, টিকিট নেই। ইংরেজি doc 3-এর “Metro”, “ticket” প্রতিশব্দে বাংলা হয়ে দুই তালিকাতেই ঢুকল। DocId বাড়ে বলে তালিকা নিজেই সাজানো।
  3. সব পোস্ট শেষ। দুই তালিকায় ৫টি করে entry; doc 5-এ টিকিট তিনবার: 5:[1,3,5]. এবার “মেট্রো AND টিকিট” — দুই তালিকার মাথায় দুটি আঙুল।
  4. প্রথম তুলনা। 1 = 1, মিল! দুই আঙুলই এগোল। কোনো পোস্টের লেখা পড়তে হয়নি, শুধু দুটি সংখ্যা তুলনা।
  5. ছোটটি এগোয়। 2 < 3, তাই মেট্রো-র আঙুল এগোল। তারপর 3 = 3 — মিল। ফল 1, 3; মোট ৩টি তুলনা।
  6. এবার টিকিট-এর আঙুল দৌড়ায়। 6 > 4, 6 > 5 — দুবার এগোনো; তারপর 6 = 6, মিল। ফল 1, 3, 6; মোট ৬টি তুলনা।
  7. এক তালিকা শেষ, কাজও শেষ। টিকিট-এর তালিকা ফুরিয়েছে, তাই মেট্রো-র 8 না দেখেই থামা। উত্তর [1, 3, 6] — প্রোগ্রামের সাথে মিলল। OR হলে একই হাঁটায় দুই তালিকার সব মান নেওয়া হতো।

কয়েকটি তালিকা হলে কোনটি আগে? সবচেয়ে ছোটটি (সবচেয়ে বিরল term) — ৫০টি entry দিয়ে শুরু করলে মধ্যবর্তী ফল ৫০-এর বেশি হয় না। তালিকা খুব অসমান হলে (৫০ বনাম ৮ লাখ) লম্বাটিতে skip pointer বা লাফিয়ে-খোঁজা (galloping, binary search-এর আত্মীয়): খরচ মোটামুটি O(pছোট × log pবড়)।

Phrase query: “মেট্রো রেল” হুবহু চাইলে আগে AND করে doc বাছা হয়, তারপর position দেখা হয় — রেল-এর অবস্থান = মেট্রো-র অবস্থান + ১ কি না। উপরের index-এ doc 1, 2, 3, 6 — চারটিই মেলে। কিন্তু “রেল টিকিট”-এ doc 6 মেলে না: রেল@2, টিকিট@4, কারণ বাদ যাওয়া “er”-এর অবস্থান রয়ে গেছে। অনেক engine-এ slop দিয়ে বলা যায় “মাঝে একটি ফাঁক চলবে”। দাম: position রাখলে index অনেকটা বড় হয়।

৪ · Ranking — TF-IDF থেকে BM25, আর লেখার বাইরের সংকেত

AND-এ লাখ ডকুমেন্ট মিললে প্রথম পাতায় কোন ১০টি? Relevance ranking-এর দুটি পুরোনো অন্তর্দৃষ্টি: (১) একটি ডকুমেন্টে শব্দটি যত বেশিবার — term frequency, tf — তত প্রাসঙ্গিক; (২) যে শব্দ অল্প ডকুমেন্টে আছে, সে বেশি তথ্য বহন করে — inverse document frequency, IDF. আমাদের সংগ্রহে “টিকিট” ৮টির ৫টিতে, “ট্রেন” ৩টিতে; তাই ট্রেন মিলে যাওয়া বেশি দামি। দুটি মিলিয়ে TF-IDF: score = tf × log(N ÷ df).

সরল TF-IDF-এর দুটি দুর্বলতা। প্রথমত, tf সরলরেখায় বাড়ে: “টিকিট টিকিট টিকিট…” লিখে কেউ ওপরে উঠে যেতে পারে। দ্বিতীয়ত, লম্বা ডকুমেন্টে স্বাভাবিকভাবেই শব্দ বেশিবার আসে। BM25 দুটিই সামলায়:

score(D, Q) = Σq ∈ Q IDF(q) × tf × (k1 + 1) ÷ (tf + K)
K = k1 × (1 − b + b × |D| ÷ avgdl) · IDF(q) = ln(1 + (N − n + 0.5) ÷ (n + 0.5))
  • N = মোট ডকুমেন্ট, n = যতগুলোতে term-টি আছে, |D| = ডকুমেন্টের দৈর্ঘ্য (term-এর সংখ্যা), avgdl = গড় দৈর্ঘ্য।
  • k1 (সাধারণত ১.২ থেকে ২.০): tf-এর সম্পৃক্তি। tf যত বাড়ুক, ভগ্নাংশটি k1 + 1-এর বেশি হয় না — keyword ঠেসে দেওয়ার লাভ সীমিত।
  • b (০ থেকে ১, সাধারণত ০.৭৫): দৈর্ঘ্যের শাস্তি। b = ০ হলে দৈর্ঘ্য উপেক্ষা; b = ১ হলে পুরো মাত্রায় দৈর্ঘ্য দিয়ে ভাগ।
  • IDF-এর রূপ: মূল Robertson–Spärck Jones রূপ ln((N − n + 0.5) ÷ (n + 0.5)) অর্ধেকের বেশি ডকুমেন্টে থাকা term-এ ঋণাত্মক (N = ৮, n = ৫-এ −০.৪৫২); Lucene-এর মতো engine “1 +” যোগ করে তা ঠেকায়। কিছু implementation লবের (k1 + 1)-ও বাদ দেয় — সবার একই গুণক, ক্রম বদলায় না। সংখ্যা মেলানোর আগে রূপটি দেখে নিন।

IDF-এর ধারণাটি Karen Spärck Jones-এর ১৯৭২ সালের একটি প্রবন্ধের, আর ১৯৬০-এর দশকে Gerard Salton-এর SMART ব্যবস্থা (প্রথমে Harvard-এ, পরে Cornell-এ) term-এর ওজন দিয়ে ডকুমেন্ট সাজানোর পথ দেখায়। BM25 (“Best Matching”) এসেছে Stephen Robertson, Spärck Jones ও সহকর্মীদের probabilistic relevance কাঠামো থেকে; London-এর City University-র Okapi ব্যবস্থায়, ১৯৯০-এর দশকের মাঝামাঝি TREC-এর পরীক্ষায় এটি পরিচিতি পায় বলে সাধারণত উল্লেখ করা হয়। Lucene আর Elasticsearch ২০১৬ সালের দিকে ডিফল্ট ranking হিসেবে BM25 নেয় (সংস্করণভেদে আলাদা)।

নিচের stepper-এ প্রশ্ন “ট্রেন টিকিট”, আর অংশ ২-এর doc 5 — “বাসের টিকিট ট্রেনের টিকিট লঞ্চের টিকিট” — হাতে হিসাব করা হলো:

প্রশ্ন: ট্রেন টিকিট · doc 5: বাস, টিকিট, ট্রেন, টিকিট, লঞ্চ, টিকিট
এখন যা হিসাব
সূত্রে বসানো
ফল
জমা স্কোর (doc 5)
  1. সংগ্রহের পরিসংখ্যান। Analyze-এর পরে ৮টি পোস্টে মোট ৩৬টি term, গড় দৈর্ঘ্য ৪.৫। Doc 5-এর দৈর্ঘ্য ৬ — গড়ের চেয়ে লম্বা, একটু শাস্তি পাবে।
  2. দৈর্ঘ্যের গুণক। K = ১.২ × (০.২৫ + ০.৭৫ × ৬ ÷ ৪.৫) = ১.২ × ১.২৫ = ১.৫। গড় দৈর্ঘ্যে K হতো ১.২; চার term-এর doc 4-এ ১.১।
  3. ট্রেন কতটা বিরল। ট্রেন আছে ৩টি পোস্টে: IDF = ln(১ + ৫.৫ ÷ ৩.৫) = ln ২.৫৭১ ≈ ০.৯৪৪। পাঁচটিতে থাকা টিকিট-এর IDF মাত্র ০.৪৯২।
  4. ট্রেন-এর অংশ। tf = ১: ১ × ২.২ ÷ (১ + ১.৫) = ০.৮৮০; গুণ করে ০.৯৪৪ × ০.৮৮০ ≈ ০.৮৩১।
  5. টিকিট-এর অংশ। tf = ৩: ৩ × ২.২ ÷ (৩ + ১.৫) ≈ ১.৪৬৭ — ০.৮৮০-এর তিন গুণ নয়, মাত্র ১.৬৭ গুণ: সম্পৃক্তি। অংশ ০.৪৯২ × ১.৪৬৭ ≈ ০.৭২২।
  6. মোট। ০.৮৩১ + ০.৭২২ ≈ ১.৫৫৩। ছোট doc 4 (“ঢাকা থেকে চট্টগ্রাম ট্রেনের টিকিট”) পায় ১.৫০৫ — খুব কাছাকাছি। সরল tf × idf-এ (একই IDF, কিন্তু সম্পৃক্তি আর দৈর্ঘ্যের হিসাব ছাড়া) ফাঁক অনেক বড়: ২.৪২২ বনাম ১.৪৩৭।
// BM25 over the same 8 posts and the same toy analyzer. k1 = 1.2, b = 0.75.
const DOCS = ["ঢাকার মেট্রো রেলের টিকিট", "মেট্রো রেল স্টেশনগুলোর তালিকা",
  "Dhaka Metro Rail ticket price", "ঢাকা থেকে চট্টগ্রাম ট্রেনের টিকিট",
  "বাসের টিকিট ট্রেনের টিকিট লঞ্চের টিকিট", "dhaka metro rail er ticket koto",
  "চট্টগ্রামের বাস আর ট্রেনের সময়সূচি", "নতুন মেট্রো স্টেশনে ভিড়"];
const STOP = new Set(["আর", "ও", "থেকে", "er", "the", "of"]);
const SYN = { dhaka: "ঢাকা", metro: "মেট্রো", rail: "রেল", ticket: "টিকিট", train: "ট্রেন", bus: "বাস" };
const stem = w => { const s = w.replace(/(গুলোর|গুলো|দের|ের|কে)$|(?<=া)র$/u, ""); return s.length >= 2 ? s : w; };
const analyze = t => t.normalize("NFC").toLowerCase().split(/[\s,।?!]+/).filter(Boolean)
  .flatMap(w => STOP.has(w) ? [] : [SYN[w] || stem(w)]);
const docs = DOCS.map(analyze), N = docs.length, k1 = 1.2, b = 0.75;
const avgdl = docs.reduce((s, d) => s + d.length, 0) / N;
const df = t => docs.filter(d => d.includes(t)).length;
const idf = t => Math.log(1 + (N - df(t) + 0.5) / (df(t) + 0.5));   // Lucene-style, never negative
function bm25(terms, d) {                            // returns per-term contributions
  const K = k1 * (1 - b + b * d.length / avgdl);
  return terms.map(t => { const tf = d.filter(x => x === t).length; return idf(t) * tf * (k1 + 1) / (tf + K); });
}
function rank(query, detail) {
  const terms = analyze(query), rows = [];
  docs.forEach((d, i) => { const parts = bm25(terms, d), s = parts.reduce((a, x) => a + x, 0);
    const tfidf = terms.reduce((a, t) => a + d.filter(x => x === t).length * idf(t), 0);
    if (s > 0) rows.push([i + 1, d.length, parts, s, tfidf]); });
  rows.sort((x, y) => y[3] - x[3] || x[0] - y[0]);
  console.log((detail ? "" : "\n") + `query "${query}"  (N=${N}, avgdl=${avgdl})`);
  if (detail) console.log(terms.map(t => `${t}: df=${df(t)} idf=${idf(t).toFixed(3)}`).join("   "));
  console.log(detail ? "doc len  " + terms.join("   ") + "   BM25   tf*idf" : "doc len  BM25");
  for (const [id, len, parts, s, ti] of rows) console.log(String(id).padStart(3), String(len).padStart(3), " ",
    (detail ? parts.map(p => p.toFixed(3)).join("  ") + "  " : "") + s.toFixed(3) + (detail ? "  " + ti.toFixed(3) : ""));
}
rank("ট্রেন টিকিট", true);
rank("metro rail ticket", false);

আউটপুট:

query "ট্রেন টিকিট"  (N=8, avgdl=4.5)
ট্রেন: df=3 idf=0.944   টিকিট: df=5 idf=0.492
doc len  ট্রেন   টিকিট   BM25   tf*idf
  5   6   0.831  0.722  1.553  2.422
  4   4   0.989  0.516  1.505  1.437
  7   4   0.989  0.000  0.989  0.944
  1   4   0.000  0.516  0.516  0.492
  3   5   0.000  0.471  0.471  0.492
  6   5   0.000  0.471  0.471  0.492

query "metro rail ticket"  (N=8, avgdl=4.5)
doc len  BM25
  1   4   1.758
  3   5   1.605
  6   5   1.605
  2   4   1.242
  5   6   0.722
  4   4   0.516
  8   4   0.516

প্রতি term-এর অংশ: stepper-এর সংখ্যাই এসেছে (doc 5: ০.৮৩১ + ০.৭২২ = ১.৫৫৩)। শুধু ট্রেন থাকা doc 7 (০.৯৮৯) শুধু টিকিট থাকা doc 1-এর (০.৫১৬) প্রায় দ্বিগুণ — বিরল শব্দের ওজন বেশি। পাঁচ term-এর doc 3 আর 6 চার term-এর doc 1-এর নিচে (০.৪৭১ বনাম ০.৫১৬), অথচ সরল tf × idf-এ তিনটিই ০.৪৯২ — দৈর্ঘ্যের হিসাব নেই।

রোমান হরফের প্রশ্ন: “metro rail ticket”-এর তিনটি শব্দই পেল doc 1, 3, 6; ছোট doc 1 (১.৭৫৮) ওপরে। টিকিট ছাড়াই doc 2 পেল ১.২৪২ — আংশিক মিলও ক্রমে জায়গা পায়।

লেখার বাইরের সংকেত

শুধু লেখার মিল দিয়ে ভালো ক্রম হয় না। বাস্তবে আরও কয়েকটি সংকেত মেশানো হয়:

  • জনপ্রিয়তা: বিক্রি, ক্লিক, rating.
  • নতুনত্ব: সংবাদে সময়ের সাথে কমে যাওয়া (time decay) ওজন।
  • ব্যক্তিগত প্রসঙ্গ: অবস্থান — চট্টগ্রাম থেকে “বাস” খুঁজলে স্থানীয় রুট আগে; ভাষা; আগের আচরণ।

সাধারণ নকশা দুই ধাপের। প্রথমে BM25-এর মতো সস্তা স্কোরে প্রতিটি shard থেকে কয়েকশো প্রার্থী; তারপর একটি learning-to-rank মডেল সেগুলোকে বহু সংকেতে আবার সাজায়। News feed-এর ranking পর্ব ৫৬-এ দেখবেন। আর ranking ভালো হলো কি না মাপতে লাগে লেবেল করা প্রশ্নের তালিকা (precision আর recall), ক্লিকের হার আর A/B test — অনুমান নয়।

৫ · বড় index-এর ভেতর — immutable segment, merge আর near-real-time

সাজানো, সংকুচিত posting list-এর মাঝখানে একটি নতুন docId ঢোকানো খুব দামি। তাই Lucene-ধাঁচের engine index-কে কখনো জায়গায় বদলায় না। নতুন ডকুমেন্ট আগে memory-র একটি buffer-এ জমে; কিছুক্ষণ পরপর সেই buffer থেকে একটি ছোট, সম্পূর্ণ, আর কখনো না-বদলানো segment তৈরি হয়। একটি index মানে অনেকগুলো segment, আর খোঁজ প্রতিটি segment-এ চলে ফল মেলায়। পেছনে merge ছোট segment-গুলো জুড়ে বড় segment বানায়। পর্ব ১৯-এর LSM-tree-এর সাথে মিলটা স্পষ্ট।

  • Refresh আর near-real-time: buffer থেকে segment খোলাকে Elasticsearch বলে refresh; লেখার সময়ে ডিফল্ট প্রায় ১ সেকেন্ড পরপর (সংস্করণভেদে আলাদা)। তাই নতুন লেখা সঙ্গে সঙ্গে নয়, refresh-এর পরে খোঁজে আসে।
  • টেকসই হওয়া আলাদা: refresh মানেই disk-এ নিরাপদ নয়। Crash-এর জন্য engine একটি নিজস্ব log (Elasticsearch-এ translog) রাখে, আর মাঝে মাঝে commit-এ fsync করে — write-ahead log-এর একই চিন্তা।
  • Delete মানে tombstone: segment বদলানো যায় না, তাই মুছে ফেলা ডকুমেন্ট শুধু চিহ্নিত হয় (segment-প্রতি একটি bitset); খোঁজ তাদের এড়িয়ে যায়, আর merge-এর সময় আসলে বাদ পড়ে। Update মানে পুরোনোটি মোছা আর নতুনটি যোগ।
  • কেন এই কষ্ট: না-বদলানো ফাইল পড়তে lock লাগে না, OS-এর page cache-এ নিশ্চিন্তে থাকে, ভালোভাবে সংকুচিত হয়, আর segment-প্রতি cache (যেমন filter-এর ফল) বৈধ থাকে।
Refresh intervalলাভদাম
ছোট (প্রায় ১ সেকেন্ড)নতুন লেখা দ্রুত খোঁজে আসেঅনেক ছোট segment, বেশি merge, খোঁজে বেশি segment ঘোরা
বড় (৩০ সেকেন্ড)indexing দ্রুত, merge কমফল বেশি পুরোনো
প্রথম বড় load-এর সময় বন্ধসবচেয়ে দ্রুত bulk loadশেষে চালু করে একবার refresh না করলে কিছুই খোঁজে আসে না

Merge বিনা মূল্যে নয়: একই ডেটা কয়েকবার নতুন করে লেখা হয় (write amplification), disk আর CPU খায়, আর মুছে ফেলা ডকুমেন্ট merge না হওয়া পর্যন্ত জায়গা দখল করে থাকে। যে index-এ খুব ঘনঘন update হয় (যেমন প্রতি সেকেন্ডে পণ্যের stock), সেখানে stock-এর মতো দ্রুত-বদলানো মান সার্চ index-এর বাইরে রাখা প্রায়ই ভালো নকশা।

৬ · Search cluster — shard, replica আর scatter-gather

একটি যন্ত্রে আর না ধরলে index ভাগ করতে হয় (পর্ব ২৩)। দুটি পথ:

Document-partitioned (local index)Term-partitioned (global index)
ভাগপ্রতিটি shard কিছু ডকুমেন্টের পুরো ছোট indexপ্রতিটি যন্ত্র কিছু term-এর পুরো posting list
একটি queryসব shard-এ যায়, প্রতিটি নিজের top-k দেয়শুধু প্রশ্নের term-গুলোর যন্ত্রে যায়
বহু-শব্দের ANDপ্রতিটি shard-এর ভেতরে, ফেরত শুধু top-kবিশাল তালিকা যন্ত্রে যন্ত্রে পাঠাতে হয়
নতুন ডকুমেন্টএকটি shard ছোঁয়তার প্রতিটি term-এর যন্ত্র ছোঁয়
দুর্বলতাfan-out, তাই tail latencyজনপ্রিয় term-এ hot spot

Elasticsearch, OpenSearch, Solr — সবাই প্রথম পথে চলে। ডকুমেন্ট কোন shard-এ যাবে, তা ঠিক করে hash(routing key) mod shard-সংখ্যা; routing key ডিফল্টে ডকুমেন্টের id. এই mod-এর কারণে প্রাথমিক shard-এর সংখ্যা index তৈরির সময়েই ঠিক হয়; পরে বদলাতে split বা পুরো reindex লাগে (সংস্করণভেদে আলাদা; পর্ব ১৬-এর consistent hashing-এর সাথে তুলনা করুন)। Routing key বিক্রেতা হলে query একটি shard-এই সারে, কিন্তু বড় বিক্রেতা hot key হয়ে যায়। প্রতিটি shard-এর এক বা একাধিক replica (পর্ব ২২): যন্ত্র বিকল হলেও খোঁজ চলে, পড়ার চাপও ভাগ হয়।

একটি query-র যাত্রা: query, তারপর fetch

  1. যেকোনো node coordinating node হিসেবে প্রশ্ন নেয় আর প্রতিটি shard-এর একটি কপিতে পাঠায় — scatter-gather.
  2. প্রতিটি shard নিজের ভেতরে খুঁজে শুধু নিজের সেরা top-k-এর (docId, স্কোর) ফেরত দেয়, পুরো ডকুমেন্ট নয়।
  3. Coordinator সাজানো তালিকাগুলো মেলায় — k-way merge, বড় মাপে একটি heap দিয়ে (DSA · heap আর priority queue).
  4. বিজয়ী ১০টির পুরো ডকুমেন্ট (শিরোনাম, দাম, ছবি) সংশ্লিষ্ট shard থেকে আনা হয় — fetch ধাপ।

নিচের simulator-এ ২০,০০০টি মিলে যাওয়া ডকুমেন্ট hash দিয়ে ৪টি shard-এ ভাগ। প্রতিটি shard নিজের ফল স্কোরের ক্রমে রাখে; coordinator k-way merge করে। প্রথমে সেরা ৫টি, তারপর পাতা ১, ১০, ১০০ আর ১,০০০-এর (প্রতি পাতায় ১০টি) খরচ:

// Scatter-gather: 20,000 matching docs spread over 4 shards by hash(id). Each shard sends
// back its own best (from + size) hits; the coordinator merges the sorted lists (k-way merge).
function rng(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
function fnv1a(s) { let h = 0x811c9dc5; for (const c of s) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; } return h; }
const rand = rng(27), S = 4, shards = Array.from({ length: S }, () => []);
for (let i = 1; i <= 20000; i++) {                  // score stands for a BM25 score
  const d = { id: "doc-" + i, score: Math.round(rand() * 1e6) / 1e5 };
  d.shard = fnv1a(d.id) % S; shards[d.shard].push(d);
}
const better = (a, b) => b.score - a.score || (a.id < b.id ? -1 : 1);
shards.forEach(s => s.sort(better));                 // each shard keeps hits in score order
function search(from, size) {
  const lists = shards.map(s => s.slice(0, from + size)), pos = lists.map(() => 0), out = [];
  while (out.length < from + size) {                 // take the best head among the S lists
    let best = -1;
    lists.forEach((l, i) => { if (pos[i] < l.length && (best < 0 || better(l[pos[i]], lists[best][pos[best]]) < 0)) best = i; });
    if (best < 0) break;
    out.push(lists[best][pos[best]++]);
  }
  return { hits: out.slice(from), sent: lists.reduce((n, l) => n + l.length, 0) };
}
console.log("docs per shard:", shards.map(s => s.length).join(", "));
const best5 = search(0, 5);
console.log("top-5:", best5.hits.map(d => `${d.id}@S${d.shard} ${d.score}`).join(" | "));
const all = shards.flat().sort(better).slice(0, 5).map(d => d.id).join();
console.log("same as one global sort?", all === best5.hits.map(d => d.id).join());
console.log("page  from   hits sent to coordinator   kept");
for (const page of [1, 10, 100, 1000]) {
  const r = search((page - 1) * 10, 10);
  console.log(String(page).padStart(4), String((page - 1) * 10).padStart(6), String(r.sent).padStart(18), String(r.hits.length).padStart(10));
}

আউটপুট:

docs per shard: 4999, 5000, 5001, 5000
top-5: doc-10150@S1 9.99907 | doc-14348@S2 9.9982 | doc-13977@S1 9.9978 | doc-12847@S0 9.99718 | doc-15329@S2 9.99585
same as one global sort? true
page  from   hits sent to coordinator   kept
   1      0                 40         10
  10     90                400         10
 100    990               4000         10
1000   9990              20000         10

ভাগ সমান: FNV-1a hash ২০,০০০টি id প্রায় হুবহু চার ভাগ করেছে (৪,৯৯৯ থেকে ৫,০০১)। Merge ঠিক: সেরা ৫টি এসেছে তিনটি আলাদা shard থেকে (S0, S1, S2; S3-এর কেউ নেই), আর ফল এক জায়গায় সব সাজানোর সাথে হুবহু এক — প্রতিটি shard-এর top-k থেকেই বিশ্বব্যাপী top-k পাওয়া যায়।

গভীর পাতার দাম (deep pagination): পাতা ১০০ দেখাতে প্রতিটি shard-কে from + size = ১,০০০টি পাঠাতে হয়, কারণ সে জানে না তার কোনগুলো সবার মধ্যে ৯৯১ থেকে ১,০০০ নম্বরে পড়বে। ৪,০০০টি এলো, কাজে লাগল ১০টি; পাতা ১,০০০-এ সব shard সব ডকুমেন্টই পাঠাল (২০,০০০)। তাই অনেক engine-এ from + size-এর সীমা থাকে (Elasticsearch-এ লেখার সময়ে ডিফল্ট ১০,০০০), আর গভীর পাতায় cursor-ধাঁচের search_after (পর্ব ৮-এর keyset pagination-এর মতো)।

Tail latency: প্রতিটি query সব shard-এর উত্তরের অপেক্ষা করে, তাই সবচেয়ে ধীরটিই মোট সময় ঠিক করে (পর্ব ৪)। একটি shard ১% অনুরোধে ধীর হলে, স্বাধীন ধরে, অন্তত একটি ধীর shard ছোঁয়ার সম্ভাবনা 1 − 0.99N: N = ৪-এ প্রায় ৩.৯%, ২০-এ ১৮.২%, ১০০-এ ৬৩.৪%. প্রতিকার: অপ্রয়োজনে বেশি shard না বানানো (লেখার সময়ে Elastic-এর নির্দেশিকা প্রতি shard মোটামুটি ১০–৫০ GB-এর কথা বলে, সংস্করণভেদে আলাদা), দেরি হলে আরেক replica-তে hedged request, আর সময়সীমা পেরোলে আংশিক ফল দেখানো।

Distributed IDF: প্রতিটি shard IDF হিসাব করে নিজের ডকুমেন্ট দেখে। Routing এলোমেলো আর shard বড় হলে পার্থক্য সামান্য; কিন্তু shard ছোট হলে, বা এক বিক্রেতার সব পণ্য এক shard-এ থাকলে, একই ডকুমেন্টের স্কোর shard-ভেদে বদলায়, আর ক্রম অদ্ভুত হয়। প্রতিকার: আগে একটি বাড়তি round trip-এ সব shard থেকে df জোগাড় করা (Elasticsearch-এ dfs_query_then_fetch), যার দাম বাড়তি latency.

৭ · Primary database আর search index-কে মিলিয়ে রাখা

পণ্যের দাম বদলাল database-এ। সার্চে নতুন দাম কীভাবে পৌঁছাবে? সবচেয়ে সহজ ভাবনা dual write: অ্যাপ আগে database-এ লেখে, তারপর search index-এ। তিনটি চেনা বিপদ:

  • আংশিক ব্যর্থতা: database-এ commit হলো, index-এ লেখা ব্যর্থ বা মাঝপথে process বন্ধ — index চিরকাল পুরোনো।
  • ক্রম উল্টে যাওয়া: দুই সার্ভার একই পণ্যের দুটি বদল (৫০০ টাকা, তারপর ৪৫০) পাঠাল, index-এ পৌঁছাল উল্টো ক্রমে — পুরোনো দামই জিতে রইল।
  • দুই জায়গায় transaction নেই: database আর search engine-কে একসাথে commit করানোর সহজ উপায় নেই (পর্ব ২৫)।

পরিণত নকশা হলো change data capture (CDC): database-এর নিজের log (PostgreSQL-এর WAL, MySQL-এর binlog) পড়ে প্রতিটি commit হওয়া পরিবর্তন একটি event stream-এ পাঠানো, আর একটি indexer সেখান থেকে index হালনাগাদ করে। নিচের ছবিতে লেখার পথ বাঁয়ে, পড়ার পথ ডানে:

লেখা থেকে খোঁজ পর্যন্ত — CDC দিয়ে search index

App → primary DB → CDC → event stream → indexer → sharded search cluster ← query service

CDC দিয়ে search index হালনাগাদ, আর query service থেকে sharded cluster-এ খোঁজ লেখার পথ · write পড়ার পথ · read অ্যাপ সার্ভিস নতুন পোস্ট, দাম বদল Primary DB সত্যের উৎস · transaction CDC connector log পড়ে event বানায় Event stream (log) id ধরে ক্রম · পর্ব ৩৩ Search indexer analyze · version · bulk ব্যবহারকারী সার্চ বক্সে টাইপ Query service parse · analyze · cache Autocomplete trie, memory-তে Query log → batch top-k আবার গোনা Search cluster: ৪টি shard × (P + R) P = primary, R = replica · প্রতিটি shard একটি ছোট index S0 S1 S2 S3 P + R P + R P + R P + R লেখা থেকে খোঁজে আসা = CDC + stream + indexer + refresh-এর দেরি commit WAL / binlog change event partition = id bulk upsert /suggest query log নতুন trie /search: scatter-gather
ছবি ২৭.২ — অ্যাপ লেখে শুধু database-এ। বাকি সব database-এর log থেকে আসে, তাই কোনো commit হারায় না, আর একই ডকুমেন্টের বদল ক্রমেই পৌঁছায়।
  • ক্রম আর idempotency: stream-এ partition ঠিক হয় ডকুমেন্টের id দিয়ে, তাই একই পণ্যের event ক্রমে আসে (পর্ব ৩৩)। প্রতিটি লেখায় database থেকে আসা একটি version (যেমন log-এর অবস্থান); index পুরোনো version-এর লেখা ফিরিয়ে দেয়, তাই একই event দুবার এলেও ক্ষতি নেই — idempotency.
  • Outbox: টেবিলের সারি-বদলের বদলে অর্থবহ event চাইলে (“পণ্য প্রকাশিত হলো”) একই transaction-এ একটি outbox টেবিলে লেখা হয়, আর CDC সেটি পড়ে — outbox pattern, পর্ব ২৫-এ বিস্তারিত।
  • Eventual consistency: মোট দেরি ধরা যাক ১ থেকে ৫ সেকেন্ড। ব্যবহারকারী নিজের নতুন পোস্ট নিজের পাতায় database থেকে দেখবেন (read-your-writes), সার্চে কয়েক সেকেন্ড পরে। খুব জরুরি লেখায় refresh-এর অপেক্ষা করানো যায় (যেমন Elasticsearch-এর refresh=wait_for), কিন্তু সবখানে করলে indexing ধীর হয়।
  • Reindex: analyzer বা mapping বদলালে পুরোনো segment কাজে লাগে না। নতুন index বানান database-এর snapshot থেকে, snapshot-এর log-অবস্থান থেকে stream আবার চালান, মিলে গেলে একটি alias এক মুহূর্তে নতুন index-এ সরান (index alias). পুরোনোটি কিছুদিন রাখুন, ফিরে যাওয়ার জন্য।
  • মুছে ফেলাও পৌঁছাতে হবে: database-এ মোছা পোস্ট index-এ থেকে গেলে মুছে ফেলা ব্যক্তিগত তথ্য সার্চে বেরিয়ে আসে — গোপনীয়তার ঝুঁকি। তাই delete event, lag-এর metric আর মাঝে মাঝে দুই দিকের ডকুমেন্ট-সংখ্যা মেলানো নকশার অংশ।

৮ · Autocomplete — prefix trie, top-k আর latency বাজেট

সার্চ বক্সে “dha” লিখতেই নিচে “dhaka weather”, “dhanmondi lake” — autocomplete (typeahead). এটি আলাদা একটি সার্ভিস, কারণ চাহিদা আলাদা:

  • প্রতিটি অক্ষরে একটি অনুরোধ: client ধরা যাক ১০০–২০০ ms থেমে থাকলে তবেই পাঠায় (debouncing), পুরোনো অনুরোধ বাতিল করে আর আগের উত্তর মনে রাখে।
  • বাজেট খুব কম: মানুষের কাছে প্রায় ০.১ সেকেন্ডের মধ্যে উত্তর “তাৎক্ষণিক” লাগে বলে usability-র পুরোনো নিয়ম আছে। মোবাইল network-এর round trip-ই এর বড় অংশ খায়, তাই সার্ভারের ভাগ মোটামুটি কয়েক থেকে কয়েক দশক ms (latency budget).
  • উৎস ডকুমেন্ট নয়, মানুষের প্রশ্ন: পরামর্শ আসে query log থেকে — কতজন কী খুঁজেছেন, সময়ের সাথে ওজন কমিয়ে। আপত্তিকর শব্দ, ফোন নম্বরের মতো ব্যক্তিগত তথ্য আর খুব কমজনের খোঁজা প্রশ্ন বাদ দিতে হয়, নইলে একজনের ব্যক্তিগত খোঁজ অন্যের পর্দায় ভেসে ওঠে।

মূল কাঠামো trie (DSA · trie): প্রতিটি edge একটি অক্ষর, একটি node মানে একটি prefix. সাধারণ trie-তে prefix-এর node-এ পৌঁছে নিচের পুরো subtree ঘুরে সেরা ৩টি খুঁজতে হয়। সিস্টেম নকশার কৌশল: প্রতিটি node-এ তার সেরা k-টি পরামর্শ আগে থেকেই রেখে দেওয়া। তখন খোঁজ মানে শুধু prefix-এর দৈর্ঘ্য পর্যন্ত হাঁটা, আর রাখা তালিকাটি পড়া। দাম: memory বাড়ে, আর একটি গণনা বদলালে পথের সব node হালনাগাদ করতে হয়।

প্রতিটি node-এ সেরা ৩টি রাখা trie (নিচের প্রোগ্রামের ১৮টি প্রশ্ন)
বক্সে লেখা
হাঁটা node
১ম পরামর্শ
২য় পরামর্শ
৩য় পরামর্শ
উত্তর কোথা থেকে
  1. “dha” — তিন অক্ষর, তিনটি node. d → h → a হেঁটে node-এ রাখা তালিকা: dhaka weather (৯২০), dhanmondi lake (৯০০), dhakar khobor (৮৮০)। এত ছোট prefix-এ অসংখ্য মানুষ একই অনুরোধ করেন, তাই উত্তর স্বল্প TTL-এ CDN বা browser-এর cache থেকেই আসতে পারে।
  2. “dhak” — চারটি node. dhanmondi lake বাদ (তার পথ “dhan”), উঠে এলো dhaka metro rail (৮৭০)। Subtree ঘুরতে হয়নি; তালিকা node-এ আগেই লেখা ছিল।
  3. “dhaka” — তালিকা একই। “dhak” দিয়ে শুরু সব প্রশ্ন “dhaka” দিয়েও শুরু — Banglish “dhakar khobor”-ও। Client চাইলে আগের উত্তর রেখে অনুরোধটিই বাদ দিতে পারে।
  4. “dhaka␣” — ফাঁকা জায়গাও একটি অক্ষর। এবার dhakar khobor বাদ (তার ষষ্ঠ অক্ষর r); তালিকা: weather, metro rail, university (৬৪০)।
  5. “dhaka m” — মাত্র দুটি। এই prefix-এর নিচে আছে শুধু metro rail (৮৭০) আর metro rail ticket (৪৫০); কম থাকলে কমই দেখায়।
  6. “dhk” — ভুল বানান। d → h-এর পরে k-এর edge নেই, খোঁজ দুই node-এ থামে। তখন fuzzy: একটি অক্ষর যোগে (edit distance ১) “dhak” হয়, তার তালিকা দেখানো যায় (অংশ ৯)। বক্স ফাঁকা রাখা হয় না।
// Autocomplete trie: each node stores its top-3 completions, so a lookup walks only the prefix.
// Seed data (assumed): how often each query was searched in a week.
const LOG = [["bangla news", 980], ["data science course", 950], ["dhaka weather", 920],
  ["dhanmondi lake", 900], ["dhakar khobor", 880], ["dhaka metro rail", 870],
  ["bangladesh cricket score", 760], ["dhaka university", 640], ["ajker khobor", 560],
  ["cox's bazar hotel", 520], ["dhaka metro rail ticket", 450], ["chittagong to dhaka bus", 410],
  ["dhaka to chittagong train", 380], ["bus ticket online", 350], ["ajker abohawa", 330],
  ["chittagong port", 330], ["dhaka traffic", 300], ["dhal recipe", 240]];
const K = 3, root = { kids: new Map(), top: [] };
let nodes = 1, refs = 0;
const better = (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1);   // count desc, then A-Z
function insert(q, count) {
  let node = root;
  for (const ch of q) {
    if (!node.kids.has(ch)) { node.kids.set(ch, { kids: new Map(), top: [] }); nodes++; }
    node = node.kids.get(ch);
    node.top.push([q, count]); node.top.sort(better);
    if (node.top.length > K) node.top.pop();
  }
}
LOG.forEach(([q, c]) => insert(q, c));
function suggest(prefix) {                      // walk len(prefix) nodes, read the stored list
  let node = root;
  for (const ch of prefix) { node = node.kids.get(ch); if (!node) return "(no node: try fuzzy)"; }
  return node.top.map(([q, c]) => `${q} (${c})`).join(" | ");
}
const subtree = n => 1 + [...n.kids.values()].reduce((s, k) => s + subtree(k), 0);
for (const p of ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m", "dhk"])
  console.log(JSON.stringify(p).padEnd(10), "->", suggest(p));
(function count(n) { refs += n.top.length; n.kids.forEach(count); })(root);
const chars = LOG.reduce((s, [q]) => s + q.length, 0);
console.log(`queries ${LOG.length}, characters ${chars}, trie nodes ${nodes}, stored top-k refs ${refs}`);
console.log(`memory if 64 B per node + 8 B per ref: ${((nodes * 64 + refs * 8) / 1024).toFixed(1)} KB`);
console.log(`without stored top-k, "d" must visit its whole subtree: ${subtree(root.kids.get("d"))} nodes`);

আউটপুট:

"d"        -> data science course (950) | dhaka weather (920) | dhanmondi lake (900)
"dh"       -> dhaka weather (920) | dhanmondi lake (900) | dhakar khobor (880)
"dha"      -> dhaka weather (920) | dhanmondi lake (900) | dhakar khobor (880)
"dhak"     -> dhaka weather (920) | dhakar khobor (880) | dhaka metro rail (870)
"dhaka"    -> dhaka weather (920) | dhakar khobor (880) | dhaka metro rail (870)
"dhaka "   -> dhaka weather (920) | dhaka metro rail (870) | dhaka university (640)
"dhaka m"  -> dhaka metro rail (870) | dhaka metro rail ticket (450)
"dhk"      -> (no node: try fuzzy)
queries 18, characters 295, trie nodes 218, stored top-k refs 265
memory if 64 B per node + 8 B per ref: 15.7 KB
without stored top-k, "d" must visit its whole subtree: 110 nodes

লাইনগুলো stepper-এর সাথে হুবহু মেলে; “d”-এর প্রথম পরামর্শ data science course (৯৫০) “dh” লিখতেই বাদ পড়ে। Prefix ভাগাভাগি: ১৮টি প্রশ্নে ২৯৫টি অক্ষর, অথচ node ২১৮টি (root-সহ) — একই শুরু একবারই রাখা। দাম: ২৬৫টি reference, ধরা অনুমানে প্রায় ১৫.৭ KB. লাভ: তালিকা না রাখলে শুধু “d”-এর জন্য ১১০টি node ঘুরতে হতো, রাখলে ১টি। লাখো প্রশ্নে এই পার্থক্যই latency বাজেট বাঁচায়।

মাপ বাড়লে: cache, হালনাগাদ আর ভাগ

  • Hot prefix cache: জনপ্রিয়তা Zipf-ধাঁচের, আর এক-দুই অক্ষরের prefix হাতে গোনা। এদের উত্তর কয়েক মিনিটের TTL-এ CDN-এ রাখা যায় (পর্ব ১৭; cache-এর কৌশল পর্ব ২৮-এ)।
  • Offline বনাম online হালনাগাদ: সাধারণত query log থেকে ঘণ্টায় বা দিনে একবার batch কাজে (পর্ব ৩৬) পুরো নতুন trie বানিয়ে এক মুহূর্তে বদল। হঠাৎ জনপ্রিয় খবরের জন্য stream থেকে (পর্ব ৩৩) ছোট একটি “trending” তালিকা, খোঁজের সময় মিশিয়ে। প্রতিটি গণনায় trie-র পথ বদলানো দামি, lock-এর ঝামেলাও আনে।
  • ভাগ: ছোট trie প্রতিটি সার্ভারে পুরোটা (replica). বড় হলে প্রথম অক্ষরে ভাগ করলে “d” বা “s”-এ hot spot; বিকল্প prefix-এর hash, বা prefix → top-k তালিকা একটি key-value store-এ। Lucene-এর completion suggester একটি সংকুচিত automaton (FST) memory-তে রাখে।

৯ · Fuzzy matching আর faceted search

Fuzzy matching: “dhk”, “rajshahi”, “রাজশাহি” — ভুল বা ভিন্ন বানানেও ফল চাই। মাপকাঠি edit distance (Levenshtein, ১৯৬৫): ন্যূনতম কতবার একটি অক্ষর যোগ, বাদ বা বদলালে এক শব্দ আরেকটি হয়; Damerau-র রূপে পাশাপাশি দুই অক্ষর অদলবদলও এক ধাপ। হিসাবটি DP (DSA · edit distance), কিন্তু লাখো term-এর প্রতিটির সাথে DP চালানো অসম্ভব। তাই engine সীমা টানে: সর্বোচ্চ ১–২টি edit, প্রথম এক-দুই অক্ষর হুবহু, আর সম্ভাব্য term automaton দিয়ে অভিধানে খোঁজা (Lucene-এর fuzzy query, লেখার সময়ে, Levenshtein automaton ব্যবহার করে)। Elasticsearch-এর fuzziness: AUTO ছোট শব্দে ০, মাঝারিতে ১, লম্বায় ২টি edit দেয় (সংস্করণভেদে আলাদা)।

আরেক কৌশল n-gram: “dhaka”-র trigram dha, hak, aka; বেশি trigram মেলা term কাছের প্রার্থী। এটি বাংলার বানান-ভিন্নতাতেও (সরকারি/সরকারী) কাজে লাগে, তবে অক্ষর গুনতে হয় grapheme cluster ধরে। সতর্কতা: সবসময় fuzzy চালালে ফল এলোমেলো হয়; সাধারণত হুবহু মিল কম হলে তবেই fuzzy, আর হুবহু মিলের স্কোর বেশি।

Faceted search: “শাড়ি” খুঁজলে বাঁ পাশে “জামদানি (১২০) · তাঁত (৮৫) · সিল্ক (৪০)”, “দাম ১,০০০–৩,০০০ (৯৫)” (সংখ্যাগুলো কাল্পনিক)। মিলে যাওয়া প্রতিটি ডকুমেন্টের ক্ষেত্রের মান গুনে এগুলো আসে। দ্রুত গোনার জন্য engine প্রতিটি field-এর মান column-আকারে আলাদা রাখে (doc values), যাতে লাখো ডকুমেন্টের “category” পুরো ডকুমেন্ট না খুলেই পড়া যায়।

  • Filter বনাম query: “শুধু ঢাকা জেলা”, “স্টকে আছে” — হ্যাঁ/না প্রশ্ন, স্কোরে প্রভাব নেই। তাই filter-এর ফল bitset হিসেবে cache করা যায়, আর পরের খোঁজে আবার কাজে লাগে।
  • বহু shard-এ গোনা: প্রতিটি shard নিজের সেরা মানগুলো পাঠায়, তাই “সেরা ১০টি ব্র্যান্ড”-এর সংখ্যা কখনো আনুমানিক হয় (Elasticsearch এর একটি ত্রুটির মাপও জানায়)। অনেক আলাদা মানের field-এ (যেমন বিক্রেতার id) facet দামি।

আরও গভীরে: পুরো ক্ষেত্রের মানক পাঠ্য Manning, Raghavan ও Schütze-র Introduction to Information Retrieval (২০০৮); BM25-এর তত্ত্ব আর রূপভেদ Robertson ও Zaragoza-র “The Probabilistic Relevance Framework: BM25 and Beyond” (২০০৯)। সব মিলে যাওয়া ডকুমেন্ট স্কোর না করেই top-k বের করার কৌশল — WAND (Broder ও সহকর্মী, ২০০৩) আর Block-Max WAND (Ding ও Suel, ২০১১) — আধুনিক engine-এর গতির বড় কারণ। আর সাম্প্রতিক বছরগুলোতে embedding-ভিত্তিক dense retrieval আর BM25 মিলিয়ে “hybrid search” প্রচলিত হচ্ছে।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • Inverted index আর B-tree index-এর পার্থক্য? B-tree একটি পুরো মান বা তার শুরু দিয়ে সারি খোঁজে; inverted index প্রতিটি শব্দ থেকে ডকুমেন্টের সাজানো তালিকায় যায়, তাই লেখার মাঝের শব্দেও দ্রুত।
  • BM25-এ k1 আর b কী করে? k1 tf-এর লাভকে সম্পৃক্ত করে (ঊর্ধ্বসীমা k1 + 1); b দৈর্ঘ্যের শাস্তি ঠিক করে (০ = উপেক্ষা, ১ = পুরো)।
  • ১০ কোটি ডকুমেন্ট, ২০টি shard — একটি query কীভাবে চলে? Coordinator সব shard-এ পাঠায়, প্রতিটি নিজের top-k (id, স্কোর) দেয়, heap-এ merge, তারপর বিজয়ীদের fetch; fan-out-এর জন্য tail latency আর deep pagination সামলাতে হয়।
  • Autocomplete-এর trie-তে প্রতিটি node-এ top-k কেন? খোঁজ O(prefix-এর দৈর্ঘ্য), subtree ঘুরতে হয় না; দাম memory আর হালনাগাদের খরচ।
  • Dual write কেন বিপজ্জনক? আংশিক ব্যর্থতা আর উল্টো ক্রমে index পুরোনো থেকে যায়; database-এর log থেকে CDC, id ধরে ক্রম আর version-সহ idempotent লেখা।

১০ · AI-এর যুগে search-এর নকশা যাচাই

“আমাদের সাইটে সার্চ বানাতে চাই” — AI প্রায়ই সোজা বলে “Elasticsearch, Kafka আর Redis বসান, ML দিয়ে ranking করুন।” আসল প্রশ্নগুলো তাতে থাকে না: কতগুলো ডকুমেন্ট, কোন ভাষা, কতটা দেরি চলবে, কী মুছতে হবে। সাধারণ ভুলগুলো:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
৫০ হাজার পণ্যের জন্য আলাদা cluster, stream আর ML rankingover-engineering; database-এর full-text-ই যথেষ্ট হতে পারেডকুমেন্ট-সংখ্যা, QPS আর চাহিদা লিখে নিজে হিসাব
বাংলার analyzer নিয়ে নীরব, বা ইংরেজির stemmerবিভক্তি, NFC আর Banglish বাদ পড়েনিজের ডেটার ২০টি আসল প্রশ্নে ফল দেখুন
“অ্যাপ থেকে DB আর index-এ একসাথে লিখুন”dual write: আংশিক ব্যর্থতা, উল্টো ক্রমFailure-mode পর্যালোচনা: মাঝপথে crash হলে কী থাকে?
Search index-কেই সত্যের উৎস বানানোtransaction নেই, refresh-এর দেরি, reindex লাগে“index হারালে কোথা থেকে ফিরবে?” জিজ্ঞেস করুন
BM25 বা IDF-এর ভুল সংখ্যাlog-এর ভিত্তি, IDF-এর রূপ, (k1 + 1) গুলিয়ে ফেলেছোট সংগ্রহে নিজে হিসাব বা অংশ ৪-এর প্রোগ্রাম
Refresh, shard-এর আকার বা from + size-এর সীমা বানিয়ে বলাসংস্করণভেদে আলাদা, প্রায়ই ভুল মনে রাখাঅফিসিয়াল documentation আর নিজের load test

AI-কে সংখ্যা, ভাষা আর ব্যর্থতার প্রশ্ন দিয়ে বাঁধুন, যাতে উত্তর যাচাই করা যায়:

তুমি একজন অভিজ্ঞ search engineer. একটি কাল্পনিক বাংলা সংবাদ সাইটে ২০ লাখ লেখা আছে, প্রতিদিন প্রায় ২,০০০টি নতুন যোগ হয়, লেখা মাঝে মাঝে সংশোধন বা মুছে ফেলা হয়। প্রশ্ন আসে বাংলায়, ইংরেজিতে আর Banglish-এ। Peak-এ সেকেন্ডে প্রায় ৩০০টি সার্চ, p99 লক্ষ্য ৩০০ ms; নতুন লেখা ১০ সেকেন্ডের মধ্যে সার্চে আসা চাই। সত্যের উৎস PostgreSQL. ১. PostgreSQL-এর full-text দিয়েই কি হবে, না আলাদা search engine লাগবে? সংখ্যা দিয়ে যুক্তি দাও। ২. বাংলা, ইংরেজি আর Banglish-এর জন্য analyzer-এর প্রতিটি ধাপ বলো: Unicode normalization, tokenization, বিভক্তি, transliteration. ৩. Database থেকে index হালনাগাদের পথ দাও (dual write নয়), ক্রম, version আর delete কীভাবে সামলাবে সহ। ৪. Shard আর replica কয়টি, কেন — হিসাবের প্রতিটি ধাপ দেখাও। ৫. কোনো পণ্যের ডিফল্ট মান বা সীমা বললে সূত্র দাও; নিশ্চিত না হলে বলো "জানি না"।

AI-এর search-পরিকল্পনা হাতে পেলে — যাচাইয়ের চেকলিস্ট

AI দেয় দ্রুত খসড়া, যাচাইয়ের দায় আপনার।
অঙ্ক নিজে মেলান, নিজের ডেটায় চালিয়ে দেখুন, ব্যর্থতার পথ ভাবুন।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। সূত্র মুখস্থ নয়, প্রতিটি নকশার পেছনের trade-off চেনাই আসল লক্ষ্য।

প্র ০১Boolean খোঁজ থেকে ranked খোঁজ — TF-IDF, BM25 আর PageRank আলাদা আলাদা কোন সমস্যার সমাধান করেছিল?

প্রথম দিকের গ্রন্থাগার আর আইনি নথির খোঁজ ছিল Boolean: “ক AND খ NOT গ” — মিললে আছে, না মিললে নেই, আর ফলের কোনো ক্রম নেই। প্রশিক্ষিত গ্রন্থাগারিক জটিল প্রশ্ন লিখতেন; সাধারণ মানুষের জন্য ফল হয় শূন্য, নয় হাজারখানেক।

১৯৬০-এর দশকে Gerard Salton-এর SMART ব্যবস্থা প্রশ্ন আর ডকুমেন্টকে term-এর ওজনের vector হিসেবে দেখে কাছাকাছিগুলো আগে সাজায়। ১৯৭২ সালে Karen Spärck Jones দেখান, যে শব্দ অল্প ডকুমেন্টে আছে সে বেশি নির্দিষ্ট — IDF. দুটি মিলে TF-IDF প্রথম সমস্যার সমাধান করল: কোন ডকুমেন্ট বেশি মানানসই, তার একটি মাপ।

কিন্তু tf সরলরেখায় বাড়ায় keyword ঠেসে দেওয়া লাভজনক হলো, আর লম্বা ডকুমেন্ট অকারণে এগিয়ে থাকল। Robertson, Spärck Jones ও সহকর্মীদের probabilistic কাঠামো থেকে আসা BM25 — City University London-এর Okapi ব্যবস্থায়, ১৯৯০-এর দশকে NIST-এর TREC প্রতিযোগিতার পরীক্ষায় পরিচিত — tf-কে সম্পৃক্ত করে আর দৈর্ঘ্য দিয়ে ভাগ করে। দ্বিতীয় সমস্যা মিটল: লেখার মিলের একটি টেকসই, সহজে টিউন করা স্কোর।

Web এসে তৃতীয় সমস্যা আনল: যে কেউ যা খুশি লিখতে পারে, তাই শুধু লেখা দেখে ভালো পাতা চেনা যায় না। Brin ও Page-এর ১৯৯৮ সালের প্রবন্ধে বর্ণিত PageRank link-এর কাঠামো থেকে পাতার একটি query-নিরপেক্ষ মান হিসাব করে: অনেক ভালো পাতা যাকে link করে, সে সম্ভবত ভালো। অর্থাৎ লেখার বাইরের সংকেত।

আজকের ব্যবস্থা তিনটিকেই স্তরে স্তরে রাখে: BM25-এর মতো সস্তা স্কোরে প্রার্থী বাছাই, তারপর জনপ্রিয়তা, নতুনত্ব, অবস্থান আর ক্লিকের মতো শত সংকেতে learning-to-rank, আর সাম্প্রতিক বছরগুলোতে embedding-ভিত্তিক মিল। সংকেত যত বাড়ে, মাপার দায়ও তত বাড়ে — লেবেল করা প্রশ্ন আর A/B test ছাড়া “ভালো ranking” শুধু অনুমান।

মূল উপলব্ধি: প্রতিটি নতুন ranking কৌশল আগেরটিকে মুছে দেয়নি, আগেরটির একটি নির্দিষ্ট দুর্বলতা সারিয়েছে। নকশায় তাই স্তর রাখুন: সস্তা আর ব্যাখ্যাযোগ্য স্কোর নিচে, দামি আর বুদ্ধিমান স্কোর ওপরে, অল্প প্রার্থীর ওপর।

প্র ০২Lucene কেন index-কে কখনো জায়গায় বদলায় না, বরং immutable segment বানিয়ে পরে merge করে? এই নকশার দাম কী?

Doug Cutting ১৯৯০-এর দশকের শেষ দিকে Lucene লেখেন; ২০০১ সালের দিকে এটি Apache-র প্রকল্প হয়, আর আজ Elasticsearch, OpenSearch ও Solr-এর ভিত্তি। এর কেন্দ্রীয় সিদ্ধান্ত: একবার লেখা segment আর কখনো বদলাবে না।

কেন? একটি posting list সাজানো আর delta-সংকুচিত। মাঝখানে একটি docId ঢোকালে পরের সব byte সরাতে হয়, আর একই সময়ে অন্য thread পড়ছে। না-বদলানো segment-এ এই সমস্যা নেই:

  • পাঠকদের lock লাগে না — যে segment খোলা আছে, তা কেউ বদলাবে না।
  • OS-এর page cache segment ফাইল নিশ্চিন্তে রাখে; ফাইল বদলায় না বলে cache কখনো বাতিল হয় না।
  • Segment-প্রতি হিসাব (filter-এর bitset, field-এর cache) segment যতদিন আছে ততদিন বৈধ।
  • সংকোচন আক্রমণাত্মক হতে পারে, কারণ আর কখনো জায়গা খালি রাখতে হবে না।

পরে Lucene-এ near-real-time reader আসে (প্রায় ২০০৯ সালের দিকের সংস্করণে), যা commit-এর অপেক্ষা না করে memory buffer থেকে নতুন segment খুলে খোঁজে দেয় — Elasticsearch-এর “refresh” এরই ওপর দাঁড়িয়ে।

দাম: (১) Merge-এর খরচ: একই ডকুমেন্ট জীবনে কয়েকবার নতুন করে লেখা হয়; ভিড়ের সময় merge পিছিয়ে পড়লে segment জমে, আর খোঁজ ধীর হয়। (২) Delete জায়গা ছাড়ে না merge না হওয়া পর্যন্ত; ঘনঘন update-এ index ফুলে ওঠে। (৩) Update দামি: একটি field বদলাতে পুরো ডকুমেন্ট নতুন করে index করতে হয়। (৪) Refresh ঘন হলে অনেক ছোট segment.

এই চিন্তা Lucene-এর একার নয়। Bigtable (২০০৬), LevelDB আর Cassandra-র LSM-tree একই পথে চলে: memory-তে জমানো, সাজিয়ে না-বদলানো ফাইলে লেখা, পেছনে জোড়া দেওয়া (পর্ব ১৯)। পার্থক্য শুধু ফাইলের ভেতরে: সেখানে key-value, এখানে posting list.

মূল উপলব্ধি: Immutability দিয়ে concurrency, caching আর সংকোচন কেনা হয়, আর দাম দেওয়া হয় পেছনের merge-এ। নকশার সময় তাই জানতে হয় ডেটা কতটা ঘনঘন বদলায় — খুব দ্রুত বদলানো মান এমন index-এর বাইরে রাখাই প্রায়ই ভালো।

প্র ০৩Document-partitioned বনাম term-partitioned index — বড় web search কেন সাধারণত প্রথমটি বেছে নেয়, আর তার fan-out-এর tail latency কীভাবে সামলায়?

কাগজে term-partitioned index আকর্ষণীয়: “ঢাকা মেট্রো” খুঁজলে শুধু “ঢাকা” আর “মেট্রো”-র যন্ত্রে যেতে হয়, সব যন্ত্রে নয়। কিন্তু বাস্তবে তিনটি সমস্যা বড় হয়ে দাঁড়ায়।

  • ছেদ করবে কে? “ঢাকা”-র তালিকায় হয়তো কোটি entry; AND করতে একটি যন্ত্রকে আরেকটির বিশাল তালিকা network দিয়ে আনতে হয়।
  • ভার সমান নয়: জনপ্রিয় term-এর যন্ত্র সবসময় ব্যস্ত, বিরল term-এর যন্ত্র অলস।
  • লেখা ছড়িয়ে যায়: একটি নতুন ডকুমেন্টের ৫০০টি আলাদা শব্দ মানে প্রায় ৫০০টি যন্ত্রে লেখা।

Document-partitioned নকশায় প্রতিটি shard স্বয়ংসম্পূর্ণ: ছেদ, ranking সব ভেতরে, ফেরত শুধু ১০টি (id, স্কোর)। একটি shard বিকল হলে বাকিদের থেকে আংশিক ফল দেওয়া যায়। Barroso, Dean ও Hölzle-এর ২০০৩ সালের প্রকাশিত বর্ণনা অনুযায়ী Google-এর তখনকার web search-এর index ডকুমেন্ট ধরে বহু “index shard”-এ ভাগ ছিল, আর প্রতিটি shard চালাত একদল replica — ভাগ দিয়ে মাপ, replica দিয়ে throughput.

দাম fan-out: প্রতিটি query সব shard-এর অপেক্ষা করে। একটি shard ১% অনুরোধে ধীর হলে ১০০টি shard-এর query-র ৬৩%-এ অন্তত একটি ধীর shard পড়ে। Dean ও Barroso-র “The Tail at Scale” (২০১৩) এর প্রতিকারগুলো সাজিয়ে বলে: hedged request (একটু দেরি হলে আরেক replica-কে একই অনুরোধ), tied request (দুই replica-কে পাঠিয়ে একজন শুরু করলে অন্যজনকে বাতিল), সময়সীমা পেরোলে আংশিক ফল, আর ধীর যন্ত্রকে সাময়িক বাদ দেওয়া। Elasticsearch-এর মতো engine-এ replica বাছাই নিজেই latency দেখে মানিয়ে নেয় (adaptive replica selection; সংস্করণভেদে আলাদা)।

আরও একটি সূক্ষ্মতা: প্রতিটি shard-এর IDF স্থানীয়, তাই ছোট বা বাঁকা shard-এ স্কোর বিকৃত হয়। বড় ও এলোমেলো shard-এ পার্থক্য সামান্য; দরকারে বাড়তি এক round trip-এ বৈশ্বিক df আনা হয়।

মূল উপলব্ধি: প্রতিটি query-কে সব shard-এ পাঠানো শুনতে অপচয় মনে হয়, কিন্তু তাতে network-এ চলে শুধু ছোট top-k, আর ভার সমান থাকে। তার দাম tail latency — সেটি ভাগের নকশা বদলে নয়, replica আর অনুরোধের কৌশলে সামলানো হয়।

প্র ০৪বাংলায় ভালো সার্চ বানানো কেন কঠিন, আর একটি কাল্পনিক বাংলা সংবাদ বা e-commerce সাইট বাস্তবে কোন পথে এগোতে পারে?

কঠিন হওয়ার কারণ চারটি স্তরে।

  • এনকোডিং: Unicode-এর বাংলা ব্লক U+0980 থেকে U+09FF. তবু চোখে এক লেখা ভেতরে একাধিক রূপে থাকতে পারে — ড়, ঢ়, য়-এর এক বা দুই code point; যুক্তাক্ষরের আকার ঠিক রাখতে বসানো অদৃশ্য ZWJ/ZWNJ. আর পুরোনো অনেক নথি Bijoy-এর মতো ASCII-ভিত্তিক font-এ — দেখতে বাংলা, ভেতরে ইংরেজি অক্ষর; Unicode-এ রূপান্তর ছাড়া সেগুলো খোঁজাই যায় না।
  • শব্দের রূপ: বিশেষ্যে -এর, -কে, -তে, -গুলো, -টা; ক্রিয়ায় অসংখ্য রূপ (করলাম, করছি, করবেন)। নিয়মভিত্তিক stemmer কখনো বেশি কাটে, কখনো কম; ভালো lemmatizer-এর জন্য অভিধান আর ভাষার সম্পদ লাগে, যা ইংরেজির তুলনায় এখনো সীমিত।
  • বানানের ভিন্নতা: সরকারি/সরকারী, বাড়ি/বাড়ী — দুই রূপই চালু।
  • লিপির মিশ্রণ: মানুষ phonetic কিবোর্ডে রোমান হরফে ভেবে বাংলা লেখেন, আবার প্রায়ই সরাসরি Banglish-এই খোঁজেন — khobor, khabor, khabar.

একটি বাস্তবসম্মত পথ:

  1. Index আর query দুই দিকেই একই normalization: NFC, অদৃশ্য অক্ষর বাদ, Latin অংশ ছোট হাতের।
  2. একই লেখা কয়েকটি field-এ: হালকা stemmer-সহ বাংলা, grapheme-ভিত্তিক character n-gram, আর transliteration. খোঁজে সবগুলোতে মিলিয়ে, হুবহু মিলের ওজন বেশি।
  3. Query log থেকে বানান-ভিন্নতা আর প্রতিশব্দের তালিকা, মানুষের পর্যালোচনাসহ।
  4. Lucene-ভিত্তিক কিছু engine-এ বাংলার analyzer আছে বলে ডকুমেন্টেশনে উল্লেখ পাওয়া যায় (সংস্করণভেদে আলাদা) — ব্যবহারের আগে নিজের ডেটায় যাচাই।
  5. মাপুন: ২০০–৫০০টি আসল প্রশ্নে লেবেল করা উত্তর, precision ও recall, আর “শূন্য ফল” আসা প্রশ্নের হার। প্রতিটি পরিবর্তন এই মাপে পাস করলে তবেই চালু।

মূল উপলব্ধি: ভাষার সমস্যা কোনো একটি জাদুকরী analyzer-এ মেটে না। কয়েকটি সরল কৌশল পাশাপাশি রাখা আর আসল ব্যবহারকারীর প্রশ্নে মেপে মেপে উন্নতি করাই কাজের পথ।

প্র ০৫Search index-কেই কি সত্যের উৎস বানানো যায়? Dual write, CDC আর পুরো reindex — কোনটি কখন, আর “সার্চে আমার পোস্ট আসছে না” অভিযোগ কীভাবে সামলাবেন?

Search engine-এর নকশা খোঁজার জন্য: ঘন refresh, সংকুচিত segment, আর লেখার পরে কিছুটা দেরিতে দেখা। বেশিরভাগ ক্ষেত্রে এতে বহু-ডকুমেন্টের transaction নেই, অনন্য-মান (unique constraint) রক্ষার নিয়ম নেই, আর analyzer বা mapping বদলালে পুরো index নতুন করে বানাতে হয়। তাই সাধারণ পরামর্শ: সত্যের উৎস একটি database, index তার কপি। ব্যতিক্রম log বা metric-এর মতো ডেটা, যা শুধু যোগ হয় আর হারালে বড় ক্ষতি নেই; সেখানে engine-টিই প্রধান ভান্ডার হতে পারে।

Dual write কখন চলে? ছোট সিস্টেমে, যেখানে মাঝে মাঝে একটি পুরোনো ফল সহ্য করা যায়, আর রাতে একটি মিলিয়ে-দেখা কাজ ভুল সারায়। অনেক সার্ভার একই ডকুমেন্ট বদলালে উল্টো ক্রমের বিপদ বাড়ে; তখন database থেকে আসা version নম্বর লাগবেই।

CDC কখন? যখন প্রতিটি commit নিশ্চিতভাবে পৌঁছাতে হবে। Debezium-এর মতো open-source টুল WAL বা binlog পড়ে event stream-এ দেয়; stream-এ id ধরে partition, ফলে একই ডকুমেন্টের বদল ক্রমে আসে। Indexer at-least-once পায়, তাই প্রতিটি লেখা id আর version দিয়ে idempotent. লেখার মুহূর্তে অর্থবহ event চাইলে outbox (পর্ব ২৫)।

পুরো reindex কখন? analyzer বদল, নতুন field, বা index নষ্ট হলে। ধাপ: database-এর snapshot থেকে নতুন index; snapshot-এর log-অবস্থান থেকে stream আবার চালানো; দুই index-এর সংখ্যা আর নমুনা মিলিয়ে দেখা; তারপর alias এক মুহূর্তে বদল — ব্যবহারকারী টেরই পান না।

“আমার পোস্ট সার্চে নেই”: আগে মাপুন — CDC lag, stream-এ জমা event, indexer-এর ত্রুটি, refresh. পণ্যের দিক থেকে: লেখকের নিজের পাতা database থেকে দেখান, প্রয়োজনে “সার্চে আসতে কয়েক সেকেন্ড লাগতে পারে” লিখুন, আর lag-এর একটি SLO রাখুন (যেমন ৯৯% লেখা ১০ সেকেন্ডের মধ্যে)।

মূল উপলব্ধি: Index-কে ভাবুন একটি cache-এর মতো, যা যেকোনো সময় database থেকে আবার বানানো যায়। তখন পুরোনো ফল একটি মাপা দেরি, তথ্য হারানো নয়।

অনুশীলন

কাগজ-কলমে করুন, তারপর উত্তর মেলান। শেষ প্রশ্নে আগে অনুমান লিখুন, তারপর অংশ ৮-এর প্রোগ্রাম বদলে চালিয়ে দেখুন।

  1. হাতে posting list বানান: তিনটি ডকুমেন্ট — D1: “রাজশাহীর আম মিষ্টি”, D2: “আমের দাম কম”, D3: “রাজশাহী থেকে আম আর লিচু”। নিয়ম: stop word “থেকে” আর “আর”; শব্দের শেষে “-এর” বা ী/া-এর পরে “-র” থাকলে কেটে ফেলুন; অবস্থান ০ থেকে গুনুন, stop word-এরও অবস্থান আছে। (ক) প্রতিটি term-এর posting list লিখুন, অবস্থানসহ। মোট কতগুলো term আর posting? (খ) রাজশাহী AND আম, (গ) phrase “রাজশাহী আম”, (ঘ) আম OR লিচু — উত্তর কী?
    আম      → D1:[1]  D2:[0]  D3:[2]
    কম      → D2:[2]
    দাম     → D2:[1]
    মিষ্টি   → D1:[2]
    রাজশাহী → D1:[0]  D3:[0]
    লিচু    → D3:[4]

    (ক) “রাজশাহীর” → রাজশাহী, “আমের” → আম; D3-এ “থেকে” (১) আর “আর” (৩) বাদ, কিন্তু আম@২, লিচু@৪ নিজের অবস্থান রাখে। মোট ৬টি term, ৯টি posting.

    (খ) [D1, D3] ∩ [D1, D2, D3] = [D1, D3]. (গ) D1-এ রাজশাহী@০, আম@১ — পাশাপাশি, মিলল। D3-এ রাজশাহী@০, আম@২ — মাঝে “থেকে”, মিলল না। উত্তর [D1]; slop ১ দিলে D3-ও। (ঘ) [D1, D2, D3] ∪ [D3] = [D1, D2, D3].

    লক্ষ করুন, “দাম” শব্দের ভেতরে “আম” নেই: দাম-এ আছে আ-কার (া, U+09BE), আর “আম”-এ স্বরবর্ণ আ (U+0986). বাংলায় LIKE-এর অক্ষর-মিলও তাই চোখের মিলের সাথে সবসময় মেলে না।

  2. একটি BM25 term-স্কোর হিসাব করুন: একটি সংগ্রহে N = ১০,০০০ ডকুমেন্ট; “ইলিশ” আছে n = ১০০টিতে। একটি ডকুমেন্টের দৈর্ঘ্য |D| = ১৫০ term, গড় avgdl = ১০০, তাতে “ইলিশ” tf = ৩ বার। k1 = ১.২, b = ০.৭৫, আর IDF = ln(1 + (N − n + 0.5) ÷ (n + 0.5)). (ক) এই ডকুমেন্টে “ইলিশ”-এর BM25 অংশ কত? (খ) একই tf, কিন্তু |D| = ৫০ হলে? (গ) একটি স্প্যাম ডকুমেন্টে |D| = ১৫০, tf = ৩০ হলে? এই term থেকে স্কোরের ঊর্ধ্বসীমা কত?
    • IDF = ln(1 + 9900.5 ÷ 100.5) = ln(1 + 98.51) = ln 99.51 ≈ ৪.৬০০.
    • (ক) K = 1.2 × (0.25 + 0.75 × 150 ÷ 100) = 1.2 × 1.375 = ১.৬৫। tf-অংশ = 3 × 2.2 ÷ (3 + 1.65) = 6.6 ÷ 4.65 ≈ ১.৪১৯। স্কোর ≈ ৪.৬০০ × ১.৪১৯ ≈ ৬.৫২৯.
    • (খ) K = 1.2 × (0.25 + 0.375) = ০.৭৫; tf-অংশ = 6.6 ÷ 3.75 = ১.৭৬; স্কোর ≈ ৮.০৯৬ — একই tf ছোট ডকুমেন্টে বেশি অর্থবহ।
    • (গ) tf-অংশ = 30 × 2.2 ÷ (30 + 1.65) = 66 ÷ 31.65 ≈ ২.০৮৫; স্কোর ≈ ৯.৫৯৩. tf দশ গুণ, স্কোর মাত্র প্রায় ১.৪৭ গুণ। ঊর্ধ্বসীমা IDF × (k1 + 1) = ৪.৬০০ × ২.২ ≈ ১০.১২১, tf যত বড়ই হোক।

    মূল Robertson–Spärck Jones IDF (“1 +” ছাড়া) দিলে IDF ≈ ৪.৫৯০ — বিরল term-এ পার্থক্য সামান্য, সাধারণ term-এ বড়।

  3. একটি সংবাদ সাইটের autocomplete নকশা করুন: একটি কাল্পনিক বাংলা সংবাদ সাইটে ধরা যাক দিনে ৫০ লাখ সার্চ, প্রতিটিতে debounce-এর পরে গড়ে ৪টি suggest অনুরোধ, peak গড়ের ৩ গুণ। গত ৩০ দিনে ছাঁকাইয়ের পরে ২০ লাখ আলাদা প্রশ্ন, গড় দৈর্ঘ্য ১৮ অক্ষর। Data flow, storage আর latency বাজেট দিন; হঠাৎ একটি বড় খবর এলে কী হবে, তাও বলুন।

    চাপ: ৫০ লাখ × ৪ ÷ ৮৬,৪০০ ≈ সেকেন্ডে ২৩১টি গড়ে, peak-এ প্রায় ৬৯৪টি। Memory-র trie-তে একটি lookup মাইক্রোসেকেন্ডের ঘরে, তাই চাপটি ছোট; কয়েকটি replica লাগে availability আর কাছাকাছি থাকার জন্য, ক্ষমতার জন্য নয়।

    Storage: মোট অক্ষর ২০ লাখ × ১৮ = ৩.৬ কোটি। ধরা যাক prefix ভাগাভাগিতে অর্ধেক node বাঁচে: ১.৮ কোটি node. প্রতি node ধরা যাক ৬৪ byte, আর top-5-এর ৫টি reference × ৮ byte — মোট ১.৮ কোটি × ১০৪ ≈ ১.৯ GB. একটি সার্ভারের memory-তে ধরে; কমাতে: trie-র গভীরতা ধরা যাক ২০ অক্ষরে সীমিত, অল্প খোঁজা প্রশ্ন বাদ, বা FST-এর মতো সংকুচিত রূপ।

    Data flow: query service → query log (event stream, পর্ব ৩৩) → ঘণ্টায় একবার batch (পর্ব ৩৬): গণনা, সময়ের সাথে ওজন কমানো, আপত্তিকর ও ব্যক্তিগত তথ্য ছাঁকা, ন্যূনতম আলাদা ব্যবহারকারীর সীমা → নতুন trie snapshot → object storage (পর্ব ২৬) → প্রতিটি autocomplete সার্ভার টেনে নিয়ে এক মুহূর্তে বদল।

    Latency বাজেট (ধরা যাক মোট ১০০ ms): network round trip ৫০–৭০ ms, সার্ভার ৫ ms-এর কম, বাকিটা পর্দায় দেখানো। এক-দুই অক্ষরের prefix CDN-এ কয়েক মিনিটের TTL-এ।

    বড় খবর: ঘণ্টার batch-এর অপেক্ষা চলবে না। Stream থেকে গত ৫–১০ মিনিটের গণনায় (count-min sketch-এর মতো কাঠামো, পর্ব ৪৫) ছোট একটি trending তালিকা, খোঁজের সময় মিশিয়ে — মানুষের নজরদারিসহ, যাতে গুজব বা আপত্তিকর শব্দ না ছড়ায়।

  4. Trie-র আউটপুট অনুমান করুন: অংশ ৮-এর autocomplete_trie.js-এ prefix-এর তালিকায় "b", "ba", "c" আর "dhaka t" বসালে প্রতিটির উত্তর কী হবে? তারপর ধরুন আজ “dhaka traffic”-এর গণনা ৩০০ থেকে বেড়ে ১,০০০ হলো — “d” আর “dhaka ” (space-সহ)-এর তালিকা কী হবে, আর online হালনাগাদে কতগুলো node বদলাতে হবে? আগে লিখে রাখুন, তারপর চালিয়ে মেলান।
    "b"        -> bangla news (980) | bangladesh cricket score (760) | bus ticket online (350)
    "ba"       -> bangla news (980) | bangladesh cricket score (760)
    "c"        -> cox's bazar hotel (520) | chittagong to dhaka bus (410) | chittagong port (330)
    "dhaka t"  -> dhaka to chittagong train (380) | dhaka traffic (300)
    • “ba”-তে দুটি: bus শুরু হয় “bu” দিয়ে, তাই “b”-তে থাকলেও “ba”-তে নেই।
    • “c”: cox's bazar (৫২০) দুই chittagong-এর ওপরে।
    • গণনা ১,০০০ হলে: “d” → dhaka traffic (1000) | data science course (950) | dhaka weather (920); “dhaka ” → dhaka traffic (1000) | dhaka weather (920) | dhaka metro rail (870). LOG-এ গণনা বদলে চালালে প্রোগ্রাম এটিই দেখায়।
    • কত node: “dhaka traffic” ১৩ অক্ষর, তাই পথের ১৩টি node-এর top-3 আবার দেখতে হবে (প্রোগ্রামের insert একই প্রশ্ন দুবার রাখত, তাই online হালনাগাদে পুরোনো entry সরিয়ে নতুনটি বসাতে হয়)। লাখো গণনা প্রতি সেকেন্ডে বদলালে এটিই বড় খরচ — তাই batch-এ পুনর্গঠন আর আলাদা trending তালিকা।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমCDC দিয়ে হালনাগাদ হওয়া একটি search ব্যবস্থায় পণ্যের দাম বদল থেকে নতুন দাম খোঁজে আসা পর্যন্ত ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 💾 অ্যাপ primary database-এ দাম বদলের transaction commit করে
  2. 📜 CDC connector database-এর log (WAL বা binlog) থেকে পরিবর্তনটি পড়ে event বানায়
  3. 📨 Event-টি পণ্যের id অনুযায়ী event stream-এর একটি partition-এ জমা হয়
  4. 🧩 Indexer নতুন version-সহ ডকুমেন্টটি search cluster-এ লেখে (memory buffer-এ)
  5. 🔎 Refresh-এ নতুন segment খোলে — এবার খোঁজে নতুন দাম আসে

একটি টেবিলের title কলামে B-tree index আছে। WHERE title LIKE '%dhaka%' সাধারণত ধীর কেন?

  • B-tree index থাকলে LIKE সবসময় দ্রুত, তাই এটি ধীর হয় না
  • শুরুতে % থাকায় সাজানো ক্রমের কোনো সীমা থেকে খোঁজা যায় না, তাই সব সারি পড়তে হয়
  • শেষে % থাকায় index ব্যবহার করা যায় না
  • LIKE সবসময় case-sensitive, তাই index কাজ করে না

BM25-এ k1 = ১.২ রেখে b = ০ বসালে কী বদলায়?

  • tf-এর সম্পৃক্তি বন্ধ হয়, স্কোর tf-এর সাথে সরলরেখায় বাড়ে
  • IDF শূন্য হয়ে যায়, সব term-এর ওজন সমান
  • ডকুমেন্টের দৈর্ঘ্য আর স্কোরে প্রভাব ফেলে না
  • লম্বা ডকুমেন্ট সবচেয়ে বেশি শাস্তি পায়

একটি document-partitioned search cluster-এ প্রতিটি query ২০টি shard-এর সবগুলোর উত্তরের অপেক্ষা করে। প্রতিটি shard স্বাধীনভাবে ১% অনুরোধে ধীর হলে কত শতাংশ query অন্তত একটি ধীর shard-এর কবলে পড়ে?

  • প্রায় ১%
  • প্রায় ১৮%
  • ঠিক ২০%
  • প্রায় ৬৩%

Lucene-ধাঁচের engine-এ segment কখনো বদলায় না। তাহলে একটি ডকুমেন্ট মুছলে কী হয়?

  • Segment ফাইলের ভেতর থেকে সঙ্গে সঙ্গে ডকুমেন্টটি কেটে বাদ দেওয়া হয়
  • পুরো index নতুন করে বানানো হয়
  • মোছা যায় না, পরের reindex পর্যন্ত খোঁজে আসতেই থাকে
  • একটি tombstone চিহ্ন বসে, খোঁজ তাকে এড়িয়ে যায়, আর merge-এ সত্যিই বাদ পড়ে

একটি autocomplete trie-র প্রতিটি node-এ তার সেরা ৩টি পরামর্শ আগে থেকে রাখা আছে। একটি prefix-এর উত্তর দেওয়ার খরচ মূলত কিসের ওপর নির্ভর করে?

  • ওই prefix দিয়ে শুরু হওয়া সব প্রশ্নের সংখ্যার ওপর
  • Trie-র মোট প্রশ্ন-সংখ্যার log-এর ওপর
  • শুধু prefix-এর দৈর্ঘ্য আর k-এর ওপর
  • গত ৩০ দিনের query log-এর আকারের ওপর

একটি সেবা প্রতিটি পণ্য-বদল আগে database-এ, তারপর search index-এ লেখে (dual write). দুটি সার্ভার একই পণ্যের দুটি দাম-বদল প্রায় একসাথে পাঠায়। সবচেয়ে বড় ঝুঁকি কী, আর তার প্রতিকার?

  • Index-এ লেখা উল্টো ক্রমে পৌঁছে পুরোনো দাম থেকে যেতে পারে; database-এর log থেকে CDC আর version-সহ লেখা
  • Search index ধীর হয়ে যাবে; বেশি shard যোগ
  • কোনো ঝুঁকি নেই, কারণ database-এ transaction আছে
  • Refresh interval ১ সেকেন্ড থেকে কমানো

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. LIKE '%x%' সব লেখা পড়ে আর শব্দ বোঝে না; search engine-এর inverted index শব্দ থেকে ডকুমেন্টের সাজানো তালিকায় যায়, আর ranking, fuzzy, autocomplete, facet যোগ করে।
  2. Index আর query — দুই দিকে একই analyzer: NFC, token, stop word, stem. বাংলায় বিভক্তি, nukta-র দুই রূপ, অদৃশ্য অক্ষর আর Banglish আলাদা নকশা চায়।
  3. AND = সাজানো তালিকার merge, O(p₁ + p₂), ছোট তালিকা আগে; position দিয়ে phrase. BM25 = IDF × সম্পৃক্ত tf × দৈর্ঘ্যের ছাড় (k1, b); তার ওপরে জনপ্রিয়তা, নতুনত্ব আর learning-to-rank.
  4. বড় মাপে immutable segment, merge, ~১ সেকেন্ডের refresh, delete = tombstone; cluster-এ document-partitioned shard, scatter-gather আর top-k merge — fan-out-এর tail latency আর deep pagination মাথায় রাখুন।
  5. Index একটি কপি: CDC, id ধরে ক্রম আর version দিয়ে sync, ফল eventually consistent. Autocomplete: প্রতিটি node-এ top-k রাখা trie, hot prefix cache, batch-এ পুনর্গঠন আর trending-এর আলাদা পথ।

পরের পর্বে: মডিউল ৪ এখানে শেষ — ডেটা কীভাবে রাখা, ভাগ, কপি আর খোঁজা হয়। এবার মডিউল ৫-এ caching. পর্ব ২৮-এ দেখবেন Caching কৌশল — cache-aside, read-through, write-through আর write-back: কখন কোনটি, আর প্রতিটি কী ভুলে পুরোনো বা হারানো ডেটা দেখায়।

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

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান