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

Sentence embedding আর semantic search

Sentence Embeddings and Semantic Search — Pooling, Bi- vs Cross-Encoders, InfoNCE, BM25 and Hybrid Search on Bangla

Taught in Bangla, this lesson builds sentence vectors with masked mean pooling and matches sentence-transformers, contrasts bi-encoders and cross-encoders, and computes the InfoNCE loss with in-batch negatives in NumPy. It writes BM25 from scratch and compares it with multilingual e5-small and MiniLM on 60 hand-written Bangla questions using recall@k, MRR and bootstrap intervals, then tests reciprocal rank fusion and the e5 query/passage prefixes in a real desktop run.

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

  • Token-এর vector থেকে mask-সহ mean pooling করে বাক্যের vector বানানো, আর CLS-এর সঙ্গে পার্থক্য
  • Bi-encoder আর cross-encoder-এর নকশা ও খরচের হিসাব
  • In-batch negative আর temperature-সহ InfoNCE loss হাতে আর NumPy-তে
  • Normalise, cosine বনাম dot, আর e5-এর “query: ”/“passage: ” উপসর্গ
  • BM25 নিজে লিখে, দুটি বহুভাষিক model আর RRF-এর সঙ্গে recall@k, MRR আর bootstrap ব্যবধানে তুলনা করা

১ · Token-এর vector থেকে বাক্যের একটি vector — pooling আর mask

বাজারঘরের হেল্প সেন্টারে প্রতিদিন শত শত প্রশ্ন আসে: “রিফান্ড কত দিনে পাব?”, “refund kotodin e pabo”, “How long does a refund take?” তিনটি একই প্রশ্ন, অথচ একটি শব্দও মিলছে না। একজন অভিজ্ঞ কর্মী তিনটিকেই একই তাকের নথির দিকে পাঠান। আমরা চাই প্রতিটি লেখার জন্য একটি “ঠিকানা”, যাতে একই উদ্দেশ্যের লেখা কাছাকাছি ঠিকানায় থাকে।

AI-এর ভিত্তি কোর্সের পর্ব ৬৯-এ এমন একটি model (multilingual-e5-small) সরাসরি ব্যবহার করেছেন, আর recall@k ও MRR হাতে গুনেছেন। এখানে আমরা ভেতরে ঢুকব: vector কীভাবে তৈরি হয়, কীভাবে শেখানো হয়, আর কোথায় শব্দ মেলানো খোঁজ এখনো জেতে।

পর্ব ৪৪-এ দেখেছেন, transformer প্রতিটি token-এর জন্য একটি contextual vector দেয়। ১৩টি token-এর বাক্যে output-এর shape (13, 384). Semantic search-এ দরকার পুরো বাক্যের একটি vector, যাকে বলে sentence embedding. সেখানে পৌঁছানোর ধাপটির নাম pooling. দুটি পরিচিত পথ:

  • CLS pooling: প্রথম বিশেষ token-এর vector নেওয়া। Model যদি এভাবে শেখানো হয়, তবেই এটি অর্থবহ।
  • Mean pooling: সব আসল token-এর vector-এর গড়। Sentence-BERT ধারায় এটিই সবচেয়ে প্রচলিত (পর্ব ৪৮-এ এর উল্লেখ আছে)।

Batch-এ বাক্যগুলোর দৈর্ঘ্য সমান করতে ছোট বাক্যে pad বসে। Pad-এর ঘরেও model কিছু না কিছু vector দেয়। তাই গড় নেওয়ার সময় attention mask দিয়ে pad বাদ দিতে হয়: mean = ∑t mt·ht ÷ ∑t mt, যেখানে ht হলো t নম্বর token-এর vector আর mt আসল token-এ ১, pad-এ ০.

৩ মাত্রার vector; সংখ্যাগুলো বানানো
ধাপ
মান
  1. Input. Transformer-এর output-এর তিনটি আসল token আর একটি pad. Mask বলে কোনটি আসল।
  2. গুণ. প্রতিটি সারিকে তার mask দিয়ে গুণ। Pad-এর সারি পুরোটা ০ হয়ে গেল।
  3. যোগ. সারিগুলো যোগ, আর আলাদা করে mask-এর যোগফল: আসল token ৩টি।
  4. ভাগ. যোগফলকে ৩ দিয়ে ভাগ। এটাই বাক্যের vector, shape (3,).
  5. ভুল পথ. Mask ভুলে গেলে ৪ দিয়ে ভাগ হয়, আর pad-এর [9, 9, 9] গড়কে টেনে নেয়। কোনো error আসে না, শুধু vector ভুল।
  6. Normalise. দৈর্ঘ্য দিয়ে ভাগ করলে দৈর্ঘ্য ১. এরপর dot product আর cosine একই সংখ্যা দেয় (অংশ ৪).

একই হিসাব দুটি বাক্যের batch-এ, NumPy-তে। শেষে দেখুন, mask ভুললে দুই বাক্য কতটা “কাছে” দেখায়।

import numpy as np
np.set_printoptions(precision=3, suppress=True)
# batch-এ ২টি বাক্য, প্রতিটি ৪ ঘরে pad করা: shape (2, 4, 3)
H = np.array([[[1.0, 0.0, 2.0], [3.0, 1.0, 0.0], [2.0, 2.0, 1.0], [9.0, 9.0, 9.0]],
              [[0.0, 1.0, 1.0], [2.0, 0.0, 3.0], [8.0, 8.0, 8.0], [8.0, 8.0, 8.0]]])
mask = np.array([[1, 1, 1, 0],
                 [1, 1, 0, 0]])                # ১ = আসল token, ০ = pad
cls = H[:, 0]                                  # প্রথম ঘরের vector
naive = H.mean(axis=1)                         # pad-ও গড়ে ঢুকে যায়
m = mask[:, :, None].astype(float)             # (2, 4, 1): broadcasting-এর জন্য
mean = (H * m).sum(axis=1) / m.sum(axis=1)     # শুধু আসল token-এর গড়
print("CLS:\n", cls)
print("mask ছাড়া গড়:\n", naive)
print("mask-সহ গড়:\n", mean)
def unit(x):
    return x / np.linalg.norm(x, axis=-1, keepdims=True)
print("দৈর্ঘ্য আগে:", np.linalg.norm(mean, axis=1), " পরে:", np.linalg.norm(unit(mean), axis=1))
print("cos, mask-সহ :", round(float(unit(mean)[0] @ unit(mean)[1]), 3))
print("cos, mask ছাড়া:", round(float(unit(naive)[0] @ unit(naive)[1]), 3))
a, b = mean[0], 10 * mean[1]                   # দ্বিতীয়টিকে ১০ গুণ লম্বা করলাম
print("dot:", round(float(a @ b), 3), " cos:", round(float(unit(a) @ unit(b)), 3))

আউটপুট (পাতার Python-এ চালানো):

CLS:
 [[1. 0. 2.]
 [0. 1. 1.]]
mask ছাড়া গড়:
 [[3.75 3.   3.  ]
 [4.5  4.25 5.  ]]
mask-সহ গড়:
 [[2.  1.  1. ]
 [1.  0.5 2. ]]
দৈর্ঘ্য আগে: [2.449 2.291]  পরে: [1. 1.]
cos, mask-সহ : 0.802
cos, mask ছাড়া: 0.99
dot: 45.0  cos: 0.802

Mask-সহ cosine ০.৮০২, mask ছাড়া ০.৯৯. দুই বাক্যের pad-এর বড় সংখ্যাগুলো দুটি গড়কেই একই দিকে টেনেছে, তাই সব বাক্য একে অন্যের কাছে দেখায়। শেষ লাইনে দ্বিতীয় vector ১০ গুণ লম্বা করলে dot ৪৫ হয়ে যায়, কিন্তু cosine একই থাকে ০.৮০২: cosine শুধু দিক দেখে, দৈর্ঘ্য দেখে না।

সত্যিকারের model-এ কি একই হিসাব চলে? multilingual-e5-small-এর last_hidden_state থেকে নিজে mask-সহ গড় নিয়ে sentence-transformers-এর ফলের সঙ্গে মেলালাম। সত্যিকারের run (CPU-তে; version অংশ ৬-এ):

~/bazarghar-dl$ uv run python pool.py
input_ids (3, 13) | mask row sums [13, 11, 12]
['<s>', '▁que', 'ry', ':', '▁রি', 'ফা', 'ন্ড', '▁কত', '▁দিনে', '▁পা', 'ব', '?', '</s>']
['<s>', '▁que', 'ry', ':', '▁refund', '▁koto', 'din', '▁e', '▁pa', 'bo', '</s>', '<pad>', '<pad>']
H (3, 13, 384) | max |own mean - sentence-transformers| = 5.22e-08
cos(own mask mean, no-mask mean) per sentence: [1.0, 0.998, 0.9986]
cos matrix, mask mean:
 [[1.    0.846 0.886]
 [0.846 1.    0.859]
 [0.886 0.859 1.   ]]
cos matrix, CLS:
 [[1.    0.918 0.948]
 [0.918 1.    0.93 ]
 [0.948 0.93  1.   ]]
(1): Pooling({... 'pooling_mode': 'mean' ...})
(2): Normalize()   # … SentenceTransformer-এর গঠন, ছাঁটা

চারটি কথা। এক, নিজের গড় আর library-র ফলের পার্থক্য ৫.২২ × ১০−৮, অর্থাৎ library ঠিক এই হিসাবই করে; model-এর গঠনে লেখা আছে pooling_mode: mean আর তারপর Normalize. দুই, tokenizer “রিফান্ড”-কে তিন টুকরো করেছে, আর Banglish বাক্যে ২টি pad বসেছে। তিন, এখানে pad কম, তাই mask ছাড়া গড়ও প্রায় একই (০.৯৯৮); লম্বা আর ছোট বাক্য একসঙ্গে থাকলে পার্থক্য বাড়ে। চার, এই model mean pooling দিয়ে শেখানো; CLS নিলে তিন ভাষার cosine আরও বেশি (০.৯১৮–০.৯৪৮) দেখায়, কিন্তু উঁচু মান মানেই ভালো নয়, কারণ অন্য সব বাক্যও তখন কাছে আসতে পারে। Model যে pooling-এ শেখানো, সেটিই ব্যবহার করুন; model card আর modules.json তা বলে দেয় (পর্ব ৫৩)।

২ · Bi-encoder বনাম cross-encoder — কে কখন পড়ে

প্রশ্ন আর নথির মিল মাপার দুটি নকশা আছে।

  • Bi-encoder: প্রশ্ন আর নথি আলাদা আলাদা একই encoder দিয়ে যায়; দুটি vector, তারপর একটি cosine. নথির vector আগেই একবার বানিয়ে রাখা যায়।
  • Cross-encoder: প্রশ্ন আর নথি জুড়ে একটি input, “[CLS] প্রশ্ন [SEP] নথি [SEP]”. Self-attention-এ প্রশ্নের প্রতিটি token নথির প্রতিটি token দেখে, আর শেষে একটি score আসে। নির্ভুলতা প্রায়ই বেশি, কিন্তু আগে থেকে কিছু হিসাব করে রাখা যায় না।
Bi-encoder আর cross-encoder Bi-encoderCross-encoder প্রশ্ননথি প্রশ্ন + নথি encoder+ pool encoder+ pool একটি encoderসব tokenএকে অন্যকে দেখে qd (আগেই) cos(q, d)score প্রতি প্রশ্নে ১ বার encoderপ্রতি জোড়ায় ১ বার
Bi-encoder-এ নথির vector আগেই রাখা যায়; cross-encoder-এ প্রতিটি প্রশ্ন-নথি জোড়ায় পুরো model চালাতে হয়।

খরচের হিসাবটাই আসল পার্থক্য। আমাদের run-এ (অংশ ৬) e5-small CPU-তে একটি নথি encode করতে গড়ে প্রায় ২৯ ms নিয়েছে। ধরুন ১,০০,০০০টি নথি।

  • Bi-encoder: নথিগুলো একবারই encode: ১,০০,০০০ × ২৯ ms ≈ ৪৮ মিনিট, আগেভাগে। প্রতিটি প্রশ্নে একবার encode (প্রায় ২৯ ms) আর ১,০০,০০০ × ৩৮৪টি গুণ-যোগ, অর্থাৎ প্রায় ৩.৮ কোটি; একই যন্ত্রে NumPy-তে float32-এ এই গুণ মেপে পেয়েছি প্রায় ৪ ms.
  • Cross-encoder: প্রতিটি প্রশ্নে ১,০০,০০০টি জোড়া, প্রতিটিতে একটি পুরো forward pass. একই গতি ধরলে প্রতি প্রশ্নে প্রায় ৪৮ মিনিট। এটি অচল।

তাই বাস্তবে দুটি একসঙ্গে: bi-encoder (বা BM25) দিয়ে হাজারের মধ্যে প্রথম ৫০–১০০টি, তারপর শুধু সেগুলোতে cross-encoder দিয়ে নতুন ক্রম। একে বলে reranking; Nogueira আর Cho-র ২০১৯ সালের কাজ BERT দিয়ে এই ধাপকে পরিচিত করে। বিস্তারিত আর সত্যিকারের reranker আসবে পর্ব ৬৯-এ (RAG — chunking, retrieval, reranking আর উত্তর তৈরি)। লাখ লাখ vector-এ দ্রুত খোঁজার কৌশল পর্ব ৬৮-এ (Vector index — FAISS, approximate nearest neighbor আর vector database). মাঝামাঝি একটি পথও আছে: ColBERT (Khattab ও Zaharia, ২০২০) প্রতিটি token-এর vector রাখে আর খোঁজার সময় token-স্তরে মেলায়; জায়গা বেশি লাগে।

৩ · Contrastive শেখা — Sentence-BERT, in-batch negative আর InfoNCE

সাধারণ BERT-এর vector গড় করলেই কি ভালো sentence embedding পাওয়া যায়? Reimers আর Gurevych-এর Sentence-BERT প্রবন্ধ (EMNLP ২০১৯) জানায়, তাঁদের পরীক্ষায় BERT-এর গড় vector বা CLS vector বাক্যের মিল মাপায় বেশ দুর্বল ছিল, কিছু ক্ষেত্রে GloVe-এর গড়ের চেয়েও খারাপ। সমাধান: একই encoder দুই বাক্যে চালিয়ে (siamese গঠন), জোড়ার label দিয়ে fine-tune করা, যাতে মিলের বাক্য কাছে আসে। এভাবে শেখার সাধারণ নাম contrastive learning; মূল ধারণা আর triplet loss আছে পর্ব ১৩-এ।

Retrieval-এর জন্য এখন বহুল প্রচলিত loss হলো InfoNCE (নামটি van den Oord ও সহকর্মীদের ২০১৮ সালের প্রবন্ধ থেকে)। একটি batch-এ B জোড়া (প্রশ্ন qi, সঠিক নথি di). প্রতিটি প্রশ্নের জন্য batch-এর বাকি B − ১টি নথিকে ভুল উত্তর ধরা হয়: এগুলো in-batch negative, আলাদা করে খুঁজতে হয় না। Henderson ও সহকর্মীরা (২০১৭) উত্তর-সুপারিশে আর DPR (Karpukhin ও সহকর্মী, ২০২০) প্রশ্নোত্তরের retrieval-এ এই কৌশল ব্যবহার করেন।

Li = −log [ exp(sii/τ) ÷ ∑j exp(sij/τ) ]

এখানে sij = cos(qi, dj), আর τ হলো temperature, একটি ছোট ধনাত্মক সংখ্যা। লক্ষ্য করুন, এটি B শ্রেণির cross-entropy, যেখানে “সঠিক শ্রেণি” হলো নিজের জোড়া। e5-এর model card লিখেছে, তাদের InfoNCE-তে τ = ০.০১; সেই কারণে e5-এর cosine প্রায় সবসময় ০.৭–১-এর ঘরে থাকে, আর মানের চেয়ে ক্রম বেশি অর্থবহ।

S[i][j] = cos(প্রশ্ন i, নথি j); কর্ণে সঠিক জোড়া
ধাপ
মান
  1. Similarity. ৩টি প্রশ্ন × ৩টি নথি। q1-এর সঠিক নথি d1 (০.৮), কিন্তু d2-ও কাছে (০.৭): একটি কঠিন negative.
  2. Temperature. τ = ০.১ দিয়ে ভাগে পার্থক্য ১০ গুণ বড়: ০.১-এর ফারাক এখন ১.
  3. Exp. Softmax-এর লব আর হর। d3 প্রায় কিছুই যোগ করে না।
  4. Loss. সঠিক নথির সম্ভাবনা ০.৭৩০৬, loss ০.৩১৪. Gradient d1-কে q1-এর দিকে টানবে আর d2-কে ঠেলবে।
  5. Batch. q2 সহজ (০.০০৩), q3 মাঝারি। Batch-এর loss তিনটির গড় ০.১৫০.
  6. τ = ১. একই S-এ সম্ভাবনা মাত্র ০.৪১৬, গড় loss ০.৮২৬. বড় τ-তে সব নথি প্রায় সমান দেখায়; ছোট τ পার্থক্য ধারালো করে।

এবার একটু বড় batch, এলোমেলো vector-এ, তিনটি τ দিয়ে।

import numpy as np
np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(42)
def unit(x):
    return x / np.linalg.norm(x, axis=1, keepdims=True)
B, d = 4, 8
Qv = unit(rng.normal(size=(B, d)))                 # ৪টি প্রশ্নের vector
Dv = unit(Qv + 0.6 * rng.normal(size=(B, d)))      # প্রতিটির সঠিক নথি: একটু সরানো
def infonce(Q, D, tau):
    S = Q @ D.T / tau                               # (B, B): সারি প্রশ্ন, কলাম নথি
    S = S - S.max(axis=1, keepdims=True)            # exp যেন overflow না করে
    logp = S - np.log(np.exp(S).sum(axis=1, keepdims=True))
    return -np.diag(logp).mean(), np.exp(np.diag(logp))
print("cos matrix (কর্ণে সঠিক জোড়া):\n", Qv @ Dv.T)
for tau in (1.0, 0.1, 0.05):
    L, p = infonce(Qv, Dv, tau)
    print(f"tau={tau:<4} loss={L:.3f}  P(সঠিক)={p}")
R = unit(rng.normal(size=(B, d)))                   # এলোমেলো নথি, কোনো মিল নেই
print("এলোমেলো নথি, tau=1:", round(float(infonce(Qv, R, 1.0)[0]), 3), " log B =", round(float(np.log(B)), 3))
# একটি ধাপ: সঠিক নথিকে প্রশ্নের দিকে একটু টানলে loss কমে কি?
D2 = unit(Dv + 0.3 * (Qv - Dv))
print("টানার পরে, tau=0.1:", round(float(infonce(Qv, D2, 0.1)[0]), 3))

আউটপুট (পাতার Python-এ চালানো):

cos matrix (কর্ণে সঠিক জোড়া):
 [[ 0.844 -0.048  0.909  0.263]
 [ 0.118  0.706  0.32  -0.029]
 [ 0.371  0.292  0.696  0.059]
 [-0.148  0.692  0.166  0.398]]
tau=1.0  loss=1.123  P(সঠিক)=[0.329 0.368 0.343 0.269]
tau=0.1  loss=1.037  P(সঠিক)=[0.342 0.976 0.945 0.05 ]
tau=0.05 loss=1.856  P(সঠিক)=[0.214 1.    0.998 0.003]
এলোমেলো নথি, tau=1: 1.53  log B = 1.386
টানার পরে, tau=0.1: 0.342

τ = ১-এ সব সম্ভাবনা ০.২৫-এর (চার ভাগের এক) কাছে: model প্রায় কিছুই আলাদা করছে না। τ = ০.১-এ সারি ২ আর ৩ প্রায় নিশ্চিত, কিন্তু সারি ১-এ নথি ৩ (০.৯০৯) সঠিক নথির (০.৮৪৪) চেয়ে কাছে, আর সারি ৪-এ নথি ২ (০.৬৯২) সঠিকটির (০.৩৯৮) চেয়ে অনেক কাছে। τ = ০.০৫-এ এই ভুল সারিগুলোর শাস্তি আরও বাড়ে, সারি ৪-এর সম্ভাবনা ০.০০৩, তাই গড় loss বেড়ে ১.৮৫৬. ছোট τ মানে ভুল ক্রমের ওপর কড়া চাপ, যা gradient-কে ঠিক সেই জোড়াগুলোর দিকে মনোযোগী করে। এলোমেলো নথিতে loss ১.৫৩, log B = ১.৩৮৬-এর কাছে: আন্দাজের মাত্রা। সঠিক নথিগুলোকে প্রশ্নের দিকে একটু টানতেই τ = ০.১-এ loss ১.০৩৭ থেকে ০.৩৪২. প্রশিক্ষণ মানে লক্ষ লক্ষ জোড়ায় এই টানাটানি, gradient দিয়ে encoder-এর weight বদলে।

Batch বড় হলে কী হয়: negative বাড়ে, loss-এর কাজ কঠিন হয়, আর শেখা সাধারণত ভালো হয় বলে অনেক প্রবন্ধে জানানো হয়েছে; দামটি GPU memory-তে। ঝুঁকিও আছে: batch-এর অন্য একটি নথি আসলে সঠিক উত্তর হতে পারে (false negative), যেমন একই নীতির দুটি নথি। আলাদা করে খুঁজে আনা কাছাকাছি-কিন্তু-ভুল নথিকে বলে hard negative; এগুলো যোগ করলে model সূক্ষ্ম পার্থক্য শেখে। SimCSE (Gao ও সহকর্মী, ২০২১) দেখায়, শুধু dropout-এর দুটি ভিন্ন রূপকে জোড়া ধরেও কাজ চলে।

৪ · Normalise, cosine না dot — আর “query:” / “passage:”

Cosine = a·b ÷ (‖a‖‖b‖). দুই vector-এর দৈর্ঘ্য ১ হলে হর ১, তাই cosine আর dot product হুবহু সমান। এজন্য index বানানোর আগে সব vector normalise করে রাখা হয়; তারপর খোঁজ মানে শুধু একটি matrix গুণ (AI-এর ভিত্তি কোর্সের পর্ব ৩৩-এ এর ভিত্তি)। কিন্তু model যদি normalise না করা vector দেয়, আর আপনি dot দিয়ে খোঁজেন, তবে লম্বা vector-এর নথি সব প্রশ্নে এগিয়ে যায়। কিছু model ইচ্ছে করে dot-এর জন্য শেখানো হয় (dot-এ শেখানো DPR-এর মতো); কোন মাপে শেখানো, model card দেখে সেটিই ব্যবহার করুন।

দ্বিতীয় বিষয় asymmetric search: প্রশ্ন ছোট (“রিফান্ড কবে?”), নথি লম্বা অনুচ্ছেদ। দুটি একই রকম লেখা নয়। e5 পরিবার এটি সামলায় উপসর্গ দিয়ে: card অনুযায়ী প্রশ্নের আগে "query: ", নথির আগে "passage: "; card স্পষ্ট বলে, না দিলে মান কমবে। দুটি বাক্যের মিল মাপার মতো symmetric কাজে দুই দিকেই "query: ". অন্য model-এর নিয়ম ভিন্ন হতে পারে, যেমন কিছু model নির্দেশনার বাক্য চায়, কিছু model কিছুই চায় না।

Card-এর কথা আমাদের বাংলা প্রশ্নে কতটা খাটে, তা মেপে দেখেছি। সত্যিকারের run, অংশ ৬-এর একই ৪০টি নথি আর ৬০টি প্রশ্নে:

~/bazarghar-dl$ uv run python run.py      # অংশ ৬-এর একই run, ছাঁটা
MiniLM-L12: dim 384, params 117.7M, max_seq 128, pooling mean, docs 20.8 ms/doc CPU
  MiniLM unnormalised doc norms: min 1.63 max 4.17

system                     recall@1  recall@5  MRR@10
e5-small                      0.517     0.817  0.651
e5-small no-prefix            0.550     0.850  0.677
MiniLM-L12                    0.067     0.308  0.172
MiniLM-L12 dot-unnorm         0.050     0.233  0.125

e5-small - e5-small no-prefix: -0.026 [-0.080, +0.027]  wins 7 losses 12 ties 41

দুটি অবাক করা ফল। এক, e5-small-এ উপসর্গ বাদ দিলে এই ৬০টি প্রশ্নে MRR@১০ সামান্য বেশি (০.৬৭৭ বনাম ০.৬৫১), কিন্তু paired ব্যবধান [−০.০৮০, +০.০২৭] শূন্য পেরিয়ে যায়: ১২টি প্রশ্নে উপসর্গ ছাড়া ভালো, ৭টিতে খারাপ, ৪১টিতে সমান। তাই card-এর কথা এখানে ভুল প্রমাণিত হয়নি, আবার সমর্থনও পায়নি; ছোট, বানানো সংগ্রহে এটাই স্বাভাবিক। আমরা card-এর নিয়মই রাখছি, কারণ model ওভাবেই শেখানো। দুই, MiniLM-এর গঠনে কোনো Normalize ধাপ নেই; নথির vector-এর দৈর্ঘ্য ১.৬৩ থেকে ৪.১৭. Normalise না করে সরাসরি dot নিলে MRR@১০ ০.১৭২ থেকে ০.১২৫-এ নামে, কারণ লম্বা vector-এর নথি সব প্রশ্নে এগিয়ে আসে। MiniLM-এর নিজের মান কেন এত কম, তা অংশ ৬-এ।

৫ · BM25 — শব্দ মেলানোর খোঁজ, নিজে লেখা

Embedding-এর সঙ্গে তুলনার জন্য একটি শক্ত baseline দরকার। শব্দভিত্তিক খোঁজে বহু বছর ধরে মানদণ্ড BM25; এটি Stephen Robertson ও সহকর্মীদের Okapi ব্যবস্থা থেকে, ১৯৯০-এর দশকের TREC মূল্যায়নে পরিচিতি পায়। Inverted index আর ranking-এর ব্যবস্থাগত দিক আছে সিস্টেম ডিজাইন কোর্সের পর্ব ২৭-এ। সূত্রটি:

score(q, d) = ∑w ∈ q IDF(w) · tf·(k₁ + 1) ÷ (tf + k₁·(1 − b + b·|d| ÷ avgdl))

এখানে tf হলো নথি d-তে শব্দ w কতবার আছে, |d| নথির দৈর্ঘ্য (শব্দ-সংখ্যা), avgdl সব নথির গড় দৈর্ঘ্য, আর IDF(w) = ln(১ + (N − df + ০.৫) ÷ (df + ০.৫)), যেখানে N মোট নথি আর df হলো কয়টি নথিতে শব্দটি আছে। k₁ (সাধারণত ১.২–২) ঠিক করে, একই শব্দ বারবার থাকলে score কত দ্রুত থেমে যায়; b (সাধারণত ০.৭৫) লম্বা নথিকে কতটা শাস্তি দেবে। TF-IDF-এর সঙ্গে পার্থক্য এই দুটি: tf-এর প্রভাব একটা সীমায় থামে, আর দৈর্ঘ্যের সমন্বয় আছে। IDF-এর এই রূপটি Lucene-এ ব্যবহৃত; মূল সূত্রে “১ +” নেই, তাই খুব সাধারণ শব্দে IDF ঋণাত্মক হতে পারত।

import math
docs = {"bh04": "ঢাকা সিটির ভেতরে ডেলিভারি চার্জ ৬০ টাকা, ১৫০০ টাকার বেশি অর্ডারে ফ্রি",
        "bh05": "গাজীপুর নারায়ণগঞ্জ সাভার আর কেরানীগঞ্জে ডেলিভারি চার্জ ৯০ টাকা",
        "bh06": "ঢাকা আর আশপাশের বাইরে সব জেলায় ডেলিভারি চার্জ ১২০ টাকা",
        "bh07": "ঢাকা সিটিতে ১ থেকে ২ দিনে আর অন্যান্য জেলায় ৩ থেকে ৫ দিনে ডেলিভারি হয়",
        "bh09": "ক্যাশ অন ডেলিভারি সর্বোচ্চ ২০০০০ টাকার অর্ডার পর্যন্ত চলে"}
tok = lambda s: s.replace(",", " ").replace("?", " ").split()
D = {k: tok(v) for k, v in docs.items()}
N, avgdl = len(D), sum(len(d) for d in D.values()) / len(D)
df = {}
for d in D.values():
    for w in set(d):
        df[w] = df.get(w, 0) + 1
def idf(w):
    return math.log((N - df[w] + 0.5) / (df[w] + 0.5) + 1)
def bm25(query, k1=1.5, b=0.75):
    scores = {}
    for k, d in D.items():
        s = 0.0
        for w in tok(query):
            tf = d.count(w)
            if tf:
                s += idf(w) * tf * (k1 + 1) / (tf + k1 * (1 - b + b * len(d) / avgdl))
        scores[k] = round(s, 3)
    return sorted(scores.items(), key=lambda x: -x[1])
print("avgdl =", avgdl, " idf(ডেলিভারি) =", round(idf("ডেলিভারি"), 3), " idf(সিটির) =", round(idf("সিটির"), 3))
for q in ["ঢাকা সিটির চার্জ কত?", "ঢাকায় ডেলিভারি চার্জ কত?", "রংপুরে ডেলিভারি চার্জ কত?"]:
    print(q, bm25(q)[:3])

আউটপুট (পাতার Python-এ চালানো):

avgdl = 11.0  idf(ডেলিভারি) = 0.087  idf(সিটির) = 1.386
ঢাকা সিটির চার্জ কত? [('bh04', 2.367), ('bh06', 1.124), ('bh05', 0.587)]
ঢাকায় ডেলিভারি চার্জ কত? [('bh05', 0.682), ('bh06', 0.653), ('bh04', 0.601)]
রংপুরে ডেলিভারি চার্জ কত? [('bh05', 0.682), ('bh06', 0.653), ('bh04', 0.601)]

“ডেলিভারি” ৫টি নথির ৫টিতেই আছে, তাই IDF মাত্র ০.০৮৭; “সিটির” আছে একটিতে, IDF ১.৩৮৬. প্রথম প্রশ্নে “ঢাকা” আর “সিটির” মিলে bh04 পরিষ্কারভাবে প্রথম। দ্বিতীয় প্রশ্নে “ঢাকায়” লিখতেই কোনো নথির সঙ্গে মিলল না, কারণ নথিতে আছে “ঢাকা”, “সিটিতে”. শুধু সাধারণ শব্দ মিলেছে, আর সবচেয়ে ছোট নথি bh05 জিতেছে। তৃতীয় প্রশ্নের ফল হুবহু দ্বিতীয়টির মতো: “রংপুরে” কোথাও নেই, তাই BM25-এর চোখে দুই প্রশ্ন একই। বাংলার বিভক্তি (-য়, -এ, -র, -তে) শব্দভিত্তিক খোঁজের বড় শত্রু; stemming বা character n-gram কিছুটা সাহায্য করে (AI-এর ভিত্তি কোর্সের পর্ব ৬৭)।

৬ · সত্যিকারের তুলনা — BM25 বনাম দুটি model, ব্যবধানসহ

ডেটা. এই মডিউলের সব পর্বে একই সংগ্রহ: বাজারঘরের ৪০টি হেল্প-নথি (৩৫টি বাংলা, ৩টি ইংরেজি, ২টি Banglish), ফেরত, রিফান্ড, জেলাভিত্তিক ডেলিভারি চার্জ, পেমেন্ট, COD, ওয়ারেন্টি ইত্যাদি নিয়ে। নথি, নীতি আর সংখ্যা সবই কোর্সের জন্য হাতে বানানো; কোনো আসল প্রতিষ্ঠানের নয়। একটি নথি (bh40) পুরোনো, “archived” চিহ্নিত, ইচ্ছে করে রাখা। প্রশ্ন ৬০টি, হাতে লেখা, প্রতিটির সঠিক (gold) নথিসহ; দুটি প্রশ্নের gold নথি দুটি। প্রশ্নের ধরন: সাধারণ বাংলা ২৪, জায়গার নাম ৬, সংখ্যা ৫, “না”-বাচক ৫, বাংলা প্রশ্নে ইংরেজি বা Banglish নথি ৭, ইংরেজি প্রশ্ন ৪, Banglish প্রশ্ন ৯.

Checkpointলাইসেন্স (card)মাত্রা, সর্বোচ্চ tokenউপসর্গ, pooling
intfloat/multilingual-e5-smallMIT৩৮৪, ৫১২“query: ”/“passage: ”, mean
sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2Apache 2.0৩৮৪, ১২৮নেই, mean; card-এর ভাষার তালিকায় bn নেই

দুটি model একই ভিত্তি model (Multilingual-MiniLM-L12-H384) থেকে শুরু, তাই parameter সংখ্যাও সমান; পার্থক্য শেখার ডেটা আর পদ্ধতিতে। তৃতীয় একটি বড় model (multilingual-e5-base, ফাইল প্রায় ১.১ GB) চালানোর পরিকল্পনা ছিল; নামানোর গতি খুব কম থাকায় বাদ দিয়েছি, তার কোনো সংখ্যা এখানে নেই। লেখার সময়ের version: Python 3.14.4, torch 2.15.0 (CPU), transformers 5.22.2, sentence-transformers 6.2.0; model-এর revision পিন করা, সব কাজ CPU-তে। Recall@k-এর সংজ্ঞা recall@k = প্রথম k-এ gold নথির ভাগ; MRR@১০-এ প্রথম ১০-এর বাইরে হলে ০. ব্যবধান আসে paired bootstrap থেকে: একই প্রশ্নগুলো সব system-এর জন্য একসঙ্গে টানা। মূল অংশ, সংক্ষিপ্ত:

m = SentenceTransformer(name, revision=rev, device="cpu")
P = m.encode(["passage: " + t for t in texts], normalize_embeddings=True)  # (40, 384)
Qe = m.encode(["query: " + q for q in qs], normalize_embeddings=True)     # (60, 384)
ranks = np.argsort(-(Qe @ P.T), axis=1, kind="stable")                  # cosine = dot
idx = rng.integers(0, 60, size=(10000, 60))     # একই প্রশ্ন-সূচক সব system-এ: paired
lo, hi = np.percentile(d[idx].mean(1), [2.5, 97.5])   # d = দুই system-এর প্রশ্নভিত্তিক পার্থক্য
~/bazarghar-dl$ uv run python run.py
python 3.14.4, torch 2.15.0+cpu, transformers 5.22.2, sentence-transformers 6.2.0
docs 40 {'bn': 35, 'en': 3, 'banglish': 2} | questions 60 {'para': 24, 'dist': 6, 'xl': 7, 'num': 5, 'neg': 5, 'en': 4, 'bl': 9}
BM25 k1=1.5 b=0.75, avg doc len 38.1 tokens, queries with zero score everywhere: 1
e5-small: dim 384, params 117.7M, max_seq 512, pooling mean, docs 28.7 ms/doc CPU
MiniLM-L12: dim 384, params 117.7M, max_seq 128, pooling mean, docs 20.8 ms/doc CPU
  MiniLM unnormalised doc norms: min 1.63 max 4.17

system                     recall@1  recall@5  MRR@10
BM25                          0.392     0.667  0.502
e5-small                      0.517     0.817  0.651
MiniLM-L12                    0.067     0.308  0.172
RRF BM25+e5-small             0.483     0.717  0.598
RRF BM25>0+e5-small           0.483     0.750  0.612

95% bootstrap CI (10,000 resamples of the 60 questions, seed 42)
BM25                   MRR 0.502 [0.394, 0.612]  recall@5 0.667 [0.550, 0.783]
e5-small               MRR 0.651 [0.552, 0.746]  recall@5 0.817 [0.717, 0.917]
MiniLM-L12             MRR 0.172 [0.106, 0.247]  recall@5 0.308 [0.200, 0.425]
paired differences in MRR
e5-small - BM25: +0.149 [+0.047, +0.253]  wins 26 losses 12 ties 22
MiniLM-L12 - BM25: -0.330 [-0.442, -0.217]  wins 5 losses 40 ties 15
e5-small - MiniLM-L12: +0.479 [+0.375, +0.580]  wins 48 losses 4 ties 8

recall@5 by question type
system            para  dist   num   neg    xl    en    bl
n                   24     6     5     5     7     4     9
BM25              0.83  0.50  1.00  1.00  0.00  0.75  0.44
e5-small          0.96  0.50  0.60  1.00  0.43  1.00  0.89
MiniLM-L12        0.21  0.50  0.40  0.60  0.00  0.75  0.28

e5-small স্পষ্টভাবে BM25-এর চেয়ে ভালো: MRR@১০-এ +০.১৪৯, ব্যবধান [+০.০৪৭, +০.২৫৩] শূন্য ছোঁয় না; ২৬টি প্রশ্নে জয়, ১২টিতে হার। MiniLM BM25-এরও অনেক নিচে (০.১৭২)। লেখার সময়ে এর card-এর ভাষার তালিকায় bn নেই। Tokenizer একই, তাই বাংলা লেখা ঠিকই টুকরো হয়; কিন্তু শেখার সময় বাংলার জোড়া না থাকলে vector অর্থের কাছাকাছি সাজে না। ইংরেজি প্রশ্নে এর recall@৫ ০.৭৫, বাংলা প্রশ্নে ০.২১. “Multilingual” নাম দেখে নয়, card-এর তালিকায় নিজের ভাষা দেখে model বাছুন। ধরনভিত্তিক ছবি: BM25 সংখ্যা আর “না”-বাচক প্রশ্নে ১.০০, কারণ নির্দিষ্ট শব্দ মিলেছে, কিন্তু ভাষা-পেরোনো প্রশ্নে ০. e5 ভাষা-পেরোনোয় ০.৪৩, Banglish-এ ০.৮৯, অথচ সংখ্যায় ০.৬০. প্রতিটি ধরনে মাত্র ৪–৯টি প্রশ্ন, তাই এই ভাগগুলো দিক দেখায়, প্রমাণ নয়। পুরো ফলই একটি run, একটি বানানো সংগ্রহ, আর প্রশ্নগুলো লেখকের নিজের লেখা; এটি সাধারণ benchmark নয়।

৭ · Hybrid search — reciprocal rank fusion

BM25-এর score ০ থেকে ১৪, e5-এর ০.৭৮ থেকে ০.৯০: দুটি যোগ করা অর্থহীন। Reciprocal rank fusion (RRF; Cormack, Clarke আর Büttcher, SIGIR ২০০৯) শুধু অবস্থান দেখে। প্রতিটি নথির score = ∑ ১/(k + rank), যেখানে rank প্রতিটি তালিকায় তার অবস্থান (১ থেকে শুরু), আর k একটি ধ্রুবক; প্রবন্ধে k = ৬০. Rank ১ আর rank ২-এর পার্থক্য ছোট রাখে বলে একটি তালিকার একটি ভুল পুরো ফল উল্টে দিতে পারে না। এভাবে দুই খোঁজ মেলানোকে বলে hybrid search.

Gold নথি bh05; সংখ্যা ৬ ঘরে
ধাপ
মান
  1. দুই তালিকা. BM25 “ডেলিভারি চার্জ” শব্দে ঢাকার নথি (bh04) আর পুরোনো নথি (bh40) আগে এনেছে; e5 সঠিক নথি bh05 প্রথমে এনেছে।
  2. bh05. দুই তালিকার অবদান যোগ: ০.০৩২২৬৬.
  3. bh04. একটিতে প্রথম, অন্যটিতে চতুর্থ: ০.০৩২০১৮. সামান্য পিছিয়ে।
  4. সমান. bh28 আর bh40 উল্টো অবস্থানে, তাই score হুবহু সমান; সমান হলে ক্রম ঠিক করে বাস্তবায়নের নিয়ম।
  5. ফল. সঠিক নথি প্রথমে। দুই তালিকা মোটামুটি একমত হলে RRF দুটির ভালো দিক নেয়।
  6. ফাঁদ. আরেকটি প্রশ্নে BM25 মাত্র একটি নথিতে কিছু মিলিয়েছে; বাকিরা score ০ নিয়েও তালিকায় থাকে আর RRF-এর পয়েন্ট পায়। তাই score ০-এর নথি বাদ দিয়ে তালিকা বানানো উচিত।

এবার সব ৬০টি প্রশ্নে, পাতাতেই। নিচের ফাইলে প্রতিটি প্রশ্নের জন্য সত্যিকারের run-এর BM25 আর e5-small-এর প্রথম ১০টি নথির নম্বর (bh বাদ দিয়ে) আর BM25-এ কয়টি নথির score ০-এর বেশি, তা দেওয়া আছে। Model এখানে চলছে না; শুধু ক্রম জোড়া লাগানো আর মাপা।

import numpy as np
import pandas as pd
cols = ["type", "gold", "bm25", "npos", "e5"]
df = pd.read_csv("ranks.tsv", sep="\t", header=None, names=cols, dtype=str)
for c in ["gold", "bm25", "e5"]:
    df[c] = df[c].str.split(r"[ ,]")         # "04 06" → ["04", "06"]
df["bm25+"] = [l[:int(n)] for l, n in zip(df.bm25, df.npos)]   # শুধু score > 0
def rrf(a, b, k=60):
    s = {}
    for lst in (a, b):
        for pos, d in enumerate(lst):        # pos 0 মানে rank 1
            s[d] = s.get(d, 0) + 1 / (k + pos + 1)
    return sorted(s, key=lambda d: -s[d])
df["rrf"] = [rrf(a, b) for a, b in zip(df.bm25, df.e5)]
df["rrf>0"] = [rrf(a, b) for a, b in zip(df["bm25+"], df.e5)]
def recall(g, lst, k):
    return len(set(g) & set(lst[:k])) / len(g)
def rr(g, lst):                              # MRR@10: প্রথম ১০-এ না থাকলে ০
    hit = [i for i, d in enumerate(lst[:10]) if d in g]
    return 1 / (hit[0] + 1) if hit else 0.0
names = ["bm25", "e5", "rrf", "rrf>0"]
rows = {}
for s in names:
    df[s + "@5"] = [recall(g, l, 5) for g, l in zip(df.gold, df[s])]
    rows[s] = {"recall@1": np.mean([recall(g, l, 1) for g, l in zip(df.gold, df[s])]),
               "recall@5": df[s + "@5"].mean(),
               "MRR@10": np.mean([rr(g, l) for g, l in zip(df.gold, df[s])])}
print(pd.DataFrame(rows).T.round(3))
print(df.groupby("type")[[s + "@5" for s in names]].mean().round(2))

আউটপুট (পাতার Python-এ চালানো):

       recall@1  recall@5  MRR@10
bm25      0.392     0.667   0.502
e5        0.517     0.817   0.651
rrf       0.475     0.717   0.603
rrf>0     0.475     0.783   0.614
      bm25@5  e5@5  rrf@5  rrf>0@5
type                              
bl      0.44  0.89   0.56     0.89
dist    0.50  0.50   0.50     0.50
en      0.75  1.00   1.00     0.75
neg     1.00  1.00   1.00     1.00
num     1.00  0.60   0.60     0.80
para    0.83  0.96   0.92     0.92
xl      0.00  0.43   0.14     0.29

ফলটি আশা জাগানোর মতো নয়, আর সেটাই শেখার বিষয়। Hybrid (rrf) e5-এর চেয়ে খারাপ: MRR@১০ ০.৬০৩ বনাম ০.৬৫১. ধরন ভাগ করলে কারণ দেখা যায়। ভাষা-পেরোনো (xl) প্রশ্নে BM25 শূন্য, তাই মিশ্রণ e5-এর ঠিক উত্তরকে নিচে নামায় (০.৪৩ থেকে ০.১৪)। Banglish (bl)-এ score ০-এর নথিগুলো তালিকা ভরিয়ে ফেলে; সেগুলো বাদ দিলে (rrf>0) ০.৫৬ থেকে ০.৮৯-এ ফেরে, আর সংখ্যার প্রশ্নে (num) BM25-এর সাহায্যে ০.৬০ থেকে ০.৮০. তবু মোটের ওপর rrf>0 (০.৬১৪) e5-কে ছাড়ায়নি। এখানে শুধু প্রথম ১০টি নথি থেকে মেশানো হয়েছে; ডেস্কটপে পুরো তালিকা দিয়ে মান সামান্য আলাদা (০.৫৯৮ আর ০.৬১২, অংশ ৬)। শিক্ষা: দুটি খোঁজ মেশালে দুর্বলটির ভুলও ঢোকে; ধরনভিত্তিক মাপ ছাড়া hybrid চালু করবেন না, আর weighted RRF বা শুধু কিছু ধরনের প্রশ্নে BM25 ব্যবহারের মতো বিকল্প আলাদা যাচাই-ডেটায় পরীক্ষা করুন।

৮ · ভাষা, লিপি আর ব্যর্থতার জায়গা — সংখ্যা, “না” আর জেলার নাম

গড় সংখ্যা লুকিয়ে রাখে কোথায় ভুল হচ্ছে। সত্যিকারের run থেকে বাছাই করা কয়েকটি প্রশ্ন; প্রতিটিতে প্রথম ৩টি নথি আর score, * মানে gold, শেষে gold-এর অবস্থান:

[04 dist] রংপুরে পার্সেল পাঠাতে কত টাকা লাগে?  gold bh06
   BM25       bh02 5.998  bh07 4.648  bh29 2.738   gold rank 13
   e5-small   bh33 0.808  bh09 0.807  bh38 0.804   gold rank 13
   MiniLM-L12 bh33 0.795  bh16 0.786  bh06* 0.757   gold rank 3
[05 dist] গুলশানে জিনিস পাঠাতে কত টাকা লাগে?  gold bh04
   BM25       bh02 5.998  bh07 4.648  bh29 2.738   gold rank 8
   e5-small   bh26 0.832  bh03 0.830  bh28 0.828   gold rank 11
   MiniLM-L12 bh33 0.764  bh16 0.750  bh14 0.739   gold rank 12
[08 num] দশ হাজার টাকার ফোন কি হাতে পেয়ে টাকা দিয়ে নেওয়া যাবে?  gold bh09
   BM25       bh19 6.716  bh16 6.250  bh40 3.953   gold rank 4
   e5-small   bh34 0.826  bh12 0.816  bh20 0.815   gold rank 13
   MiniLM-L12 bh16 0.865  bh06 0.852  bh33 0.844   gold rank 35
[42 num] ৫ কেজির পার্সেল সিলেটে পাঠাতে কত লাগবে?  gold bh06
   BM25       bh07 5.787  bh06* 3.200  bh02 2.616   gold rank 2
   e5-small   bh12 0.838  bh15 0.822  bh38 0.821   gold rank 18
   MiniLM-L12 bh16 0.821  bh33 0.804  bh14 0.801   gold rank 4
[43 num] কত টাকার অর্ডারে ঢাকায় ফ্রি ডেলিভারি?  gold bh04
   BM25       bh40 9.607  bh33 7.003  bh04* 5.872   gold rank 3
   e5-small   bh40 0.900  bh04* 0.894  bh06 0.886   gold rank 2
   MiniLM-L12 bh14 0.812  bh16 0.809  bh33 0.803   gold rank 8
[15 neg] ফোন পানিতে পড়ে নষ্ট, ওয়ারেন্টিতে ঠিক করে দেবে?  gold bh13
   BM25       bh13* 3.015  bh17 3.015  bh36 2.801   gold rank 1
   e5-small   bh15 0.813  bh13* 0.811  bh30 0.805   gold rank 2
   MiniLM-L12 bh16 0.828  bh14 0.796  bh06 0.770   gold rank 16
[38 neg] উপন্যাস পড়ে ভালো লাগেনি, ফেরত দেওয়া যাবে?  gold bh36
   BM25       bh36* 9.750  bh18 3.848  bh37 3.640   gold rank 1
   e5-small   bh37 0.808  bh36* 0.806  bh01 0.797   gold rank 2
   MiniLM-L12 bh36* 0.653  bh16 0.628  bh07 0.623   gold rank 1
[36 xl] ছাত্রদের জন্য কোনো ছাড় আছে?  gold bh34
   BM25       bh23 2.138  bh21 2.112  bh19 1.921   gold rank 34
   e5-small   bh34* 0.834  bh36 0.813  bh37 0.807   gold rank 1
   MiniLM-L12 bh36 0.719  bh16 0.710  bh06 0.707   gold rank 26
[58 xl] এক্সপ্রেস ডেলিভারির খরচ কত?  gold bh08
   BM25       bh29 4.530  bh02 4.494  bh03 3.461   gold rank 12
   e5-small   bh28 0.866  bh05 0.839  bh06 0.836   gold rank 19
   MiniLM-L12 bh40 0.655  bh04 0.655  bh26 0.597   gold rank 19
[53 bl] jama size e hoy nai, change korte chai  gold bh25
   BM25       bh34 6.031  bh10 5.290  bh01 0.000   gold rank 26
   e5-small   bh34 0.822  bh10 0.808  bh25* 0.805   gold rank 3
   MiniLM-L12 bh16 0.441  bh19 0.420  bh36 0.400   gold rank 11
[00 para] জিনিস পছন্দ না হলে কত দিনের মধ্যে ফেরত দিতে পারব?  gold bh01
   BM25       bh01* 8.689  bh25 6.419  bh02 5.393   gold rank 1
   e5-small   bh01* 0.841  bh02 0.841  bh37 0.839   gold rank 1
   MiniLM-L12 bh16 0.845  bh14 0.819  bh06 0.798   gold rank 16

প্রতিটি থেকে একটি করে শিক্ষা।

  • জেলা আর এলাকার নাম: “রংপুরে” প্রশ্নের উত্তর আছে bh06-এ, যেখানে লেখা “সব জেলায়”; রংপুর যে সেই দলে, তা জানতে বাইরের জ্ঞান লাগে। BM25 আর e5 দুটিতেই rank ১৩. “গুলশান” যে ঢাকা সিটির ভেতরে, তা কোনো নথিতে নেই; rank ৮ আর ১১. Embedding-এর ভূগোল-জ্ঞানের ওপর ভরসা না করে নথিতে জেলা আর এলাকার তালিকা লিখুন, অথবা প্রশ্ন থেকে জায়গা চিনে metadata দিয়ে ছাঁকুন (পর্ব ৬৮)।
  • সংখ্যা: “দশ হাজার টাকার ফোন” প্রশ্নে e5-এ সঠিক COD নথি ১৩ নম্বরে; “৫ কেজির পার্সেল সিলেটে” প্রশ্নে ১৮ নম্বরে, অথচ BM25-এ ২. Embedding “২০,০০০-এর নিচে কি না” ধরনের তুলনা করে না। আর উত্তরটি নিজেই হিসাব: ১২০ + ৪ × ২০ = ২০০ টাকা; এটি খোঁজার নয়, উত্তর তৈরির বা একটি হিসাবের tool-এর কাজ (পর্ব ৬৯ আর পর্ব ৭১, Tool use আর function calling — LLM থেকে কাজ)।
  • পুরোনো নথি: “কত টাকার অর্ডারে ঢাকায় ফ্রি ডেলিভারি?” প্রশ্নে BM25 আর e5 দুটিই archived bh40-কে (১,০০০ টাকা, পুরোনো নিয়ম) প্রথমে আনে। Embedding তারিখ বা status দেখে না; status দিয়ে আগেই ছাঁকতে হয়, নইলে ভুল তথ্য সোজা উত্তরে ঢোকে।
  • “না” আর শর্ত: “ফোন পানিতে পড়ে নষ্ট, ওয়ারেন্টিতে ঠিক করে দেবে?” প্রশ্নে e5 প্রথমে আনে দাবি করার নিয়ম (bh15); আসল উত্তর “পানি ঢোকা ওয়ারেন্টির বাইরে” আছে দুই নম্বরের bh13-এ। বিষয় মেলে, শর্ত মেলে না।
  • ভাষা আর লিপি: ভাষা-পেরোনো খোঁজে “ছাত্রদের জন্য কোনো ছাড়” প্রশ্নে e5 Banglish নথি bh34 প্রথমে আনে, ভালো। কিন্তু “এক্সপ্রেস ডেলিভারির খরচ” প্রশ্নে ইংরেজি নথি “Express delivery” ১৯ নম্বরে। আবার Banglish প্রশ্নে e5 প্রায়ই দুটি Banglish নথি (bh10, bh34) আগে আনে, বিষয় না মিললেও: লিপির মিল একটি টান তৈরি করে। MiniLM-এ একই নথি bh16 অনেক প্রশ্নে ওপরে ওঠে, যা model ভাষাটি না জানার আরেকটি লক্ষণ।

প্রতিকারের সারকথা: নথির লেখা উন্নত করা (জেলার তালিকা, প্রতিটি ভাষায় সারাংশ), metadata আর status দিয়ে ছাঁকা, সংখ্যার কাজ হিসাবের ধাপে দেওয়া, cross-encoder দিয়ে reranking, আর প্রতিটি ধরনের প্রশ্ন eval set-এ রেখে নিয়মিত মাপা।

আরও গভীরে: Sentence-BERT-এর মূল training ছিল NLI জোড়ায় classification আর STS-এ regression; পরে retrieval-এ in-batch negative-এর InfoNCE (sentence-transformers-এ MultipleNegativesRankingLoss নামে) প্রধান হয়ে ওঠে। DPR প্রশ্ন আর নথির জন্য দুটি আলাদা encoder ব্যবহার করে, e5 একটি encoder আর উপসর্গ; দুটিই asymmetric খোঁজের দুই সমাধান। ANCE (Xiong ও সহকর্মী, ২০২০) শেখার সময় চলমান index থেকে hard negative খুঁজে আনে। বহুভাষিক মূল্যায়নে Mr. TyDi আর MIRACL-এর মতো ডেটাসেটে বাংলা আছে বলে তাদের প্রবন্ধে উল্লেখ আছে; e5-এর card Mr. TyDi-র ফল দেখায়।

পরীক্ষা ও ইন্টারভিউতে: Bi- আর cross-encoder-এর পার্থক্য? আলাদা vector আর cosine বনাম জোড়া একসঙ্গে পড়ে score; প্রথমটি খোঁজে, দ্বিতীয়টি reranking-এ। Mask-সহ mean pooling? ∑m·h ÷ ∑m. InfoNCE? −log softmax(s/τ)-এর সঠিক ঘর; batch-এর বাকিরা negative. Normalise কেন? তখন cosine = dot, খোঁজ একটি matrix গুণ। BM25-এর k₁ আর b? tf-এর সম্পৃক্তি আর দৈর্ঘ্যের শাস্তি। RRF? ∑ ১/(k + rank), score-এর মাপ লাগে না।

৯ · AI-এর যুগে — AI-এর লেখা semantic search যাচাই

AI assistant কয়েক সেকেন্ডে একটি semantic search লিখে দেয়। ভুলগুলো প্রায় সবই নীরব: কোড চলে, ফলও আসে, শুধু ফল খারাপ।

যে ভুল প্রায়ই দেখা যায়ফলকীভাবে ধরবেন
Mask ছাড়া last_hidden_state.mean(1)Pad গড়ে ঢোকে; বাক্য দৈর্ঘ্যভেদে ভুলনিজের গড় library-র ফলের সঙ্গে মেলানো (আমাদের পার্থক্য ৫ × ১০−৮)
e5-এ উপসর্গ বাদ, বা নথিতেও “query: ”Card-এর নিয়ম ভাঙা; মান বদলাতে পারেCard পড়া, দুইভাবে মেপে দেখা
Normalise না করে dotলম্বা vector-এর নথি এগিয়ে যায়‖v‖ ছাপানো; cosine আর dot-এর ক্রম মেলানো
পুরোনো বা বানানো API, যেমন নেই এমন argumentError, বা নীরবে defaultনিজের version-এর নথি; ছোট input-এ shape ছাপানো
“০.৮-এর ওপরে হলে মিল” ধরনের স্থির সীমাModel-ভেদে অর্থহীনদাগানো ডেটায় সীমা ঠিক করা
তিনটি প্রশ্নে “দারুণ কাজ করছে”ভাগ্যের ফলBaseline, প্রশ্ন-তালিকা, ব্যবধান
বানানো benchmark score বা ভুল citationভুল সিদ্ধান্তপ্রবন্ধ আর model card খুলে মেলানো

AI-কে কোডের সঙ্গে প্রমাণও চাইতে বলুন:

আমি বাংলা, ইংরেজি আর Banglish প্রশ্নের জন্য একটি হেল্প সেন্টারের semantic search বানাতে চাই। sentence-transformers 6.2, intfloat/multilingual-e5-small ব্যবহার করব। ১. Model card অনুযায়ী উপসর্গ আর pooling কী, তা লেখো; নিশ্চিত না হলে বলো। ২. নথি আর প্রশ্ন encode করে normalise করার কোড দাও; প্রতিটি লাইনে shape কমেন্টে। ৩. last_hidden_state থেকে attention mask দিয়ে নিজে গড় নিয়ে library-র ফলের সঙ্গে মেলানোর একটি পরীক্ষা দাও। ৪. BM25 baseline আর RRF দিয়ে hybrid, আর recall@5 ও MRR@10-এর হিসাব; সঙ্গে paired bootstrap ব্যবধান। ৫. কোনো API বা benchmark সংখ্যা যাচাই না করে থাকলে স্পষ্ট করে বলো। “model অর্থ বোঝে” লিখো না।

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

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

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

প্র ০১BERT তো প্রতিটি token-এর চমৎকার contextual vector দেয়। তাহলে সেগুলোর গড়ই কেন ভালো sentence embedding হলো না?

কারণটি শেখার লক্ষ্যে। BERT শেখে লুকানো token অনুমান করতে (পর্ব ৪৭)। এই লক্ষ্য কোথাও বলে না যে “একই অর্থের দুটি বাক্যের গড় vector-এর cosine বেশি হতে হবে”। তাই বাক্যের জায়গাটি cosine-এর জন্য সাজানো থাকে না। Reimers আর Gurevych (২০১৯) ঠিক এটাই মাপেন: তাঁদের STS পরীক্ষায় BERT-এর গড় আর CLS vector GloVe-এর গড়ের চেয়েও পিছিয়ে ছিল, আর জোড়া বাক্য দিয়ে siamese fine-tuning করার পর ফল অনেক ভালো হয়।

আরেকটি পর্যবেক্ষণ Ethayarajh-এর (EMNLP ২০১৯): contextual vector-গুলো একটি সরু শঙ্কুর মতো এলাকায় জড়ো থাকে, যাকে বলে anisotropy. তখন প্রায় যে-কোনো দুটি বাক্যের cosine বেশি আসে, আর পার্থক্যগুলো ছোট হয়ে যায়। এর প্রতিকারে BERT-flow (Li ও সহকর্মী, ২০২০) আর whitening (Su ও সহকর্মী, ২০২১) vector-এর বণ্টন বদলানোর চেষ্টা করে। তবে পরে contrastive training, বিশেষ করে বিপুল প্রশ্ন-উত্তর জোড়ায়, মূল পথ হয়ে দাঁড়ায়, যেমন e5-এর card দুই ধাপের শেখার কথা বলে: প্রথমে দুর্বল তত্ত্বাবধানের জোড়ায় contrastive pre-training, পরে label দেওয়া ডেটায় fine-tuning.

এখান থেকে ব্যবহারিক শিক্ষা: “এই model-এর output-এর গড় নিলেই embedding” কথাটি সাধারণভাবে ভুল। কোনো BERT checkpoint নামিয়ে গড় নিয়ে খোঁজ বানালে কাজ চলতে পারে, কিন্তু retrieval-এর জন্য শেখানো model-এর সঙ্গে নিজের প্রশ্নে মেপে দেখবেন। আমাদের CLS-এর ছোট পরীক্ষাতেও দেখা গেল, ভুল pooling-এ cosine বেড়ে যায়, অর্থাৎ সব বাক্য বেশি কাছে দেখায়।

মূল উপলব্ধি: vector-এর জ্যামিতি শেখার লক্ষ্যের প্রতিফলন; cosine-এ খোঁজার জন্য এমন লক্ষ্যেই শেখাতে হয় যা cosine-কে অর্থবহ করে।

প্র ০২Embedding-এর যুগে BM25 কি পুরোনো? কেন বড় ব্যবস্থাগুলো এখনো শব্দ মেলানো খোঁজ রাখে?

পুরোনো, কিন্তু অকেজো নয়। BEIR benchmark (Thakur ও সহকর্মী, NeurIPS ২০২১ datasets track) নানা ক্ষেত্রের ১৮টি ডেটাসেটে zero-shot তুলনা করে জানায়, অনেক dense retriever প্রশিক্ষণের বাইরের ক্ষেত্রে BM25-এর চেয়ে খারাপ করেছিল; BM25 সেখানে একটি কঠিন baseline. পরের model-গুলো অনেক ক্ষেত্রে এগিয়েছে, তবে ডেটা বদলালে ফল বদলায়, এটিই স্থায়ী শিক্ষা।

BM25-এর শক্তি কয়েকটি জায়গায় স্পষ্ট। পণ্যের কোড, অর্ডার নম্বর, বিরল নাম, আইনের ধারা: এসব হুবহু মেলাতে হয়, আর embedding-এ এগুলো ঝাপসা হয়ে যেতে পারে। BM25 ব্যাখ্যা করা যায় (কোন শব্দ মিলেছে), নতুন নথি যোগ করলে কোনো model চালাতে হয় না, আর inverted index দশকের পর দশক ধরে পরীক্ষিত (সিস্টেম ডিজাইন কোর্সের পর্ব ২৭)। দুর্বলতাও স্পষ্ট: বাংলার বিভক্তি, প্রতিশব্দ, অন্য ভাষা বা লিপি। আমাদের run-এ ইংরেজি বা Banglish নথিতে বাংলা প্রশ্নে BM25 প্রায় শূন্য।

তাই hybrid search সাধারণ চর্চা হয়ে উঠেছে। লেখার সময়ে (নভেম্বর ২০২৬) Elasticsearch-এর নথিতে একটি RRF retriever আছে, যা BM25 আর kNN-এর ফল মেলাতে পারে, default rank_constant ৬০; Qdrant-এর নথিতে hybrid query-তে RRF আর DBSF নামের দুটি মিশ্রণের পথ আছে, আর সেখানে RRF-এর k-এর default লেখা ২. এসব সংস্করণভেদে পরিবর্তন হতে পারে; নিজে নথি পড়ে নেবেন, আর k-এর মান যে এক ব্যবস্থা থেকে আরেকটিতে ভিন্ন, সেটিও খেয়াল রাখবেন। কিন্তু আমাদের নিজের ছোট run দেখায়, RRF সবসময় জেতে না: একটি পক্ষ অনেক দুর্বল হলে মিশ্রণ ভালো পক্ষটিকে টেনে নামাতে পারে।

মূল উপলব্ধি: শব্দ মেলানো আর অর্থ মেলানো ভিন্ন ভুল করে; কোনটি বা কোন মিশ্রণ আপনার ডেটায় ভালো, তা নিজের প্রশ্নে মেপেই ঠিক হয়।

প্র ০৩পুরো একটি নথি, হোক তা ৫ লাইন বা ৫০ পাতা, একটিমাত্র vector-এ ধরা যায় কি? সীমাটা কোথায়?

প্রথম সীমা দৈর্ঘ্য। Model একটি নির্দিষ্ট সংখ্যক token-এর বেশি পড়ে না: e5-small-এ ৫১২, আমাদের ব্যবহৃত paraphrase-multilingual-MiniLM-এ ১২৮. এর বেশি হলে বাকিটা নীরবে কেটে যায়; কোনো error আসে না। বাংলায় token বেশি লাগে (পর্ব ৬৫), তাই একই অনুচ্ছেদ ইংরেজির চেয়ে আগে সীমা ছোঁয়।

দ্বিতীয় সীমা অর্থের মিশ্রণ। Mean pooling সব token-এর গড়; একটি নথিতে পাঁচটি বিষয় থাকলে vector পাঁচটির মাঝামাঝি কোথাও বসে, কোনোটির কাছেই নয়। আমাদের ফেরত নীতির নথিতে (bh01) চারটি উপশিরোনাম আছে: সময়সীমা, যা ফেরত হয় না, ইলেকট্রনিক্স, যাচাই। প্রশ্ন একটির সঙ্গে মিললেও পুরো নথির vector অন্যগুলোর দিকে টানা থাকে।

সমাধানের পথ কয়েকটি। নথিকে ছোট অংশে ভাগ করা (chunking), যা পর্ব ৬৯-এর বিষয়। প্রতিটি token-এর vector রেখে token-স্তরে মেলানো, যেমন ColBERT; জায়গা অনেক বেশি লাগে। আরেক দিকে Matryoshka representation learning (Kusupati ও সহকর্মী, ২০২২) এমনভাবে শেখায় যাতে vector-এর প্রথম কয়েকশো ঘর একাই কাজ চালায়; তখন জায়গা আর গতির বিনিময়ে নির্ভুলতা কিছুটা ছাড়া যায়। কোনটি দরকার, তা নথির দৈর্ঘ্য, প্রশ্নের ধরন আর খরচের সীমা দেখে মাপতে হয়।

মূল উপলব্ধি: একটি vector একটি সারাংশ, আর সারাংশে খুঁটিনাটি হারায়; দৈর্ঘ্যের সীমা দেখে নথি ভাগ করা retrieval-এর অর্ধেক কাজ।

প্র ০৪বাংলা প্রশ্নে ইংরেজি নথি খুঁজে পাওয়া কীভাবে সম্ভব হয়? আর Banglish কেন আলাদা সমস্যা?

বহুভাষিক model-কে এমনভাবে শেখানো হয় যাতে অনুবাদের জোড়া কাছাকাছি vector পায়। Reimers আর Gurevych-এর ২০২০ সালের পদ্ধতিতে (Making Monolingual Sentence Embeddings Multilingual using Knowledge Distillation) একটি ইংরেজি “শিক্ষক” model-এর vector-কে লক্ষ্য ধরে একটি বহুভাষিক “ছাত্র” model শেখে: ইংরেজি বাক্য আর তার অনুবাদ, দুটির vector-ই শিক্ষকের ইংরেজি vector-এর কাছে যেতে হবে। sentence-transformers-এর নথিতে paraphrase-multilingual-MiniLM-L12-v2-কে ইংরেজি paraphrase model-এর বহুভাষিক রূপ বলা হয়েছে, ৫০টির বেশি ভাষার সমান্তরাল (অনুবাদ) ডেটায় শেখানো, আর এই পরিবারের জন্য ওই প্রবন্ধটিকেই সূত্র ধরা হয়েছে। খেয়াল করুন: লেখার সময়ে এর card-এর ভাষার তালিকায় bn নেই। LaBSE (Feng ও সহকর্মী) অনুবাদ-জোড়ায় dual-encoder শেখায়; e5-এর বহুভাষিক রূপ বহু ভাষার জোড়ায় contrastive শেখে।

e5-এর তালিকায় বাংলা আছে, কিন্তু বাংলা ডেটা ইংরেজির তুলনায় অনেক কম বলে ধারণা করা হয়, তাই মান সাধারণত ইংরেজির চেয়ে কম হতে পারে; এটি আমরা মাপিনি, শুধু আমাদের ছোট প্রশ্ন-তালিকায় ফল দেখেছি। Banglish আরও কঠিন। এটি লাতিন লিপিতে বাংলা, যার কোনো মানক বানান নেই: “kotodin”, “koto din”, “kotodine”. Tokenizer এগুলোকে ইংরেজির মতো টুকরো করে, আর শেখার ডেটায় এমন লেখা কতটা ছিল, card-এ তার উল্লেখ নেই। আমাদের run-এ Banglish প্রশ্নে model-ভেদে ফল বেশ ভিন্ন।

ব্যবহারিক পথ: Banglish প্রশ্নের একটি আলাদা ভাগ রেখে মাপা; প্রয়োজনে বাংলা লিপিতে transliteration করে দুইভাবে খোঁজা; নথির পাশে Banglish-এ লেখা সাধারণ প্রশ্নও রাখা। এ ধরনের গ্রাহকেরা যেন কম ভালো উত্তর না পান, সেটি একধরনের ন্যায্যতার প্রশ্নও।

মূল উপলব্ধি: ভাষা-পেরোনো খোঁজ শেখার ডেটার জোড়া থেকে আসে; যে লেখার ধরন শেখায় কম ছিল, সেখানে মাপ না নিয়ে ভরসা করা যায় না।

প্র ০৫অনলাইনে embedding model-এর leaderboard আছে। সেখানে যে শীর্ষে, সেটিই কি বাজারঘরের জন্য নেওয়া উচিত?

সবচেয়ে পরিচিত তুলনা MTEB (Massive Text Embedding Benchmark; Muennighoff ও সহকর্মী, প্রবন্ধ ২০২২, EACL ২০২৩)। এতে retrieval, classification, clustering-সহ নানা কাজের অনেক ডেটাসেট একসঙ্গে মাপা হয়। এটি উপকারী শুরু, কিন্তু কয়েকটি সতর্কতা আছে। এক, গড় score অনেক কাজ মিশিয়ে আসে; আপনার কাজ হয়তো তার একটি ছোট অংশ। দুই, বাংলার ডেটাসেট সেখানে তুলনামূলক কম, আর Banglish প্রায় নেই বলে ধারণা করা যায়। তিন, জনপ্রিয় benchmark-এ বারবার পরীক্ষা করে model বাছাই হলে, বা শেখার ডেটায় benchmark-এর লেখা ঢুকে গেলে, score আশাবাদী হতে পারে (পর্ব ৫৮-এ একই ফাঁদ)।

চার, খরচ। বড় model একটু ভালো করলেও প্রতিটি নথি আর প্রশ্নে বেশি সময় আর memory লাগে, আর vector-এর মাত্রা বাড়লে index-এর জায়গাও বাড়ে। পাঁচ, লাইসেন্স আর ডেটা কোথায় যায়: hosted embedding সেবায় গ্রাহকের লেখা বাইরে যায়।

সঠিক পথ আমাদের ৬০টি প্রশ্নের মতো নিজের একটি প্রশ্ন-তালিকা: বাস্তব প্রশ্নের ধরন (বাংলা, Banglish, ইংরেজি, সংখ্যা, জায়গার নাম) মিলিয়ে, gold নথি হাতে দাগিয়ে। তারপর BM25 baseline, ২–৩টি model, recall@k আর MRR, bootstrap ব্যবধান আর paired পার্থক্য (মেশিন লার্নিং কোর্সের পর্ব ২১ আর পর্ব ২২)। আমাদের ৬০টি প্রশ্নে ব্যবধান এখনো চওড়া; সিদ্ধান্ত নেওয়ার আগে আরও প্রশ্ন যোগ করা ভালো। আর model-এর revision পিন করে রাখুন, কারণ model বদলালে পুরো index নতুন করে বানাতে হয়।

মূল উপলব্ধি: Leaderboard তালিকা ছোট করে; চূড়ান্ত বাছাই হয় নিজের প্রশ্নে, নিজের খরচের সীমায়, অনিশ্চয়তাসহ।

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান।

  1. পূর্ণ numerical: (ক) ৫টি নথি, একটি শব্দ শুধু একটি নথিতে একবার আছে; সেই নথির দৈর্ঘ্য ৯, গড় দৈর্ঘ্য ১১, k₁ = ১.৫, b = ০.৭৫. Lucene ধাঁচের IDF ধরে এই শব্দের BM25 অবদান কত? (খ) একটি প্রশ্নের তিন নথির cosine [০.৫, ০.২, ০.২], প্রথমটি সঠিক। τ = ০.১ আর τ = ১-এ InfoNCE loss কত? (গ) RRF-এ (k = ৬০) নথি X দুই তালিকায় rank ১ আর ৩, নথি Y দুটিতেই rank ২. কে আগে? (ঘ) তিনটি প্রশ্নে প্রথম সঠিক নথির rank ১, ৪ আর ১২. MRR@১০ আর recall@৫ কত (প্রতিটির একটিই সঠিক নথি)?

    (ক) IDF = ln(১ + (৫ − ১ + ০.৫)/(১ + ০.৫)) = ln ৪ = ১.৩৮৬. হর = ১ + ১.৫ × (০.২৫ + ০.৭৫ × ৯/১১) = ২.২৯৫. tf-অংশ = ২.৫/২.২৯৫ = ১.০৮৯. অবদান = ১.৫১০. (খ) τ = ০.১: logits [৫, ২, ২], p = e⁵/(e⁵ + ২e²) = ০.৯০৯৪, loss = ০.০৯৫. τ = ১: p = ০.৪০৩, loss = ০.৯০৯. (গ) X = ১/৬১ + ১/৬৩ = ০.০৩২২৬৬; Y = ২/৬২ = ০.০৩২২৫৮. X সামান্য আগে; পার্থক্য খুব ছোট, k বড় হলে rank-এর পার্থক্যের প্রভাব কমে। (ঘ) MRR@১০ = (১ + ০.২৫ + ০)/৩ = ০.৪১৭ (rank ১২ দশের বাইরে, তাই ০). recall@৫ = ২/৩ = ০.৬৬৭.

  2. সমালোচনা ও নকশা: একটি দলের পরিকল্পনা: “একটি ইংরেজি BERT checkpoint-এর CLS vector দিয়ে বাংলা হেল্প সেন্টারের খোঁজ বানাব। Vector normalise করব না, dot দিয়ে খুঁজব। cosine ০.৮-এর ওপরে হলে উত্তর দেখাব। পাঁচটি প্রশ্ন চালিয়ে দেখেছি, ভালো লাগছে।” কী কী সমস্যা? কীভাবে নতুন করে নকশা করবেন?

    (১) ইংরেজি model-এর card-এ বাংলা না থাকলে বাংলার vector প্রায় অর্থহীন হতে পারে। (২) Retrieval-এর জন্য শেখানো নয় এমন model-এর CLS; ভুল pooling. (৩) Normalise ছাড়া dot-এ লম্বা vector-এর নথি এগিয়ে যায়। (৪) স্থির ০.৮ সীমা: model-ভেদে score-এর ঘর আলাদা (e5-এ প্রায় সব ০.৭-এর ওপরে)। (৫) পাঁচটি প্রশ্ন কোনো প্রমাণ নয়; baseline নেই।

    নকশা: বহুভাষিক retrieval model, card অনুযায়ী pooling আর উপসর্গ, normalise করে cosine; BM25 baseline আর hybrid বিকল্প; বাংলা, Banglish, ইংরেজি, সংখ্যা ও জায়গার নামের অন্তত ৫০–১০০টি দাগানো প্রশ্ন; recall@k, MRR, bootstrap ব্যবধান; সীমা ঠিক করতে আলাদা যাচাই-ডেটা; model revision পিন করা।

  3. আউটপুট অনুমান করুন: H = np.arange(12).reshape(1, 4, 3).astype(float); m = np.array([[1, 1, 0, 0]]); print((H * m[:, :, None]).sum(1) / m.sum(1, keepdims=True)); print(H.mean(1)); print(H[:, 0])

    [[1.5 2.5 3.5]], [[4.5 5.5 6.5]], [[0. 1. 2.]]. সারিগুলো [0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11]. Mask-সহ গড় শুধু প্রথম দুটির: [(0+3)/2, (1+4)/2, (2+5)/2]. Mask ছাড়া চারটিরই গড়। শেষটি প্রথম token, অর্থাৎ CLS-এর জায়গা। Shape সবগুলোর (1, 3).

  4. কোড লিখুন: অংশ ৭-এর পাতার কোডে RRF-কে weighted করুন: score = w/(k + rankBM25) + (১ − w)/(k + ranke5). w = ০.২, ০.৩৫, ০.৫-এ MRR@১০ ছাপান। তারপর ভাবুন, কেন এই ৬০টি প্রশ্নে সেরা w বেছে সেটিকেই চূড়ান্ত ফল বলা ঠিক নয়।
    def wrrf(a, b, w, k=60):
        s = {}
        for lst, wt in ((a, w), (b, 1 - w)):
            for pos, d in enumerate(lst):
                s[d] = s.get(d, 0) + wt / (k + pos + 1)
        return sorted(s, key=lambda d: -s[d])
    for w in (0.2, 0.35, 0.5):
        r = [wrrf(a, b, w) for a, b in zip(df.bm25, df.e5)]
        print(w, round(np.mean([rr(g, l) for g, l in zip(df.gold, r)]), 3))

    পাতার কোডে চালিয়ে (রক্ষিত ৬০টি প্রশ্নের ক্রম, প্রথম ১০টি): w = ০.২ → 0.626, ০.৩৫ → 0.617, ০.৫ → 0.603 (সাধারণ RRF-এর সমান, কারণ দুই পাশে একই ওজন)। BM25-এর ওজন কমালে ফল e5-একার (০.৬৫১) দিকে এগোয়, কিন্তু ছাড়ায় না। আর এই ৬০টি প্রশ্ন দেখেই w বাছলে সেটি আর নিরপেক্ষ মাপ থাকে না: বাছাইয়ের ডেটায় জয় সবসময় একটু আশাবাদী। w আলাদা যাচাই-প্রশ্নে বাছুন, তারপর নতুন প্রশ্নে একবার মাপুন, ঠিক যেমন পর্ব ৬৬-এ eval set ভাগ করেছিলেন।

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

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

ক্রমহেল্প সেন্টারের জন্য একটি semantic search বানানো ও যাচাইয়ের ধাপগুলো সঠিক ক্রমে সাজান।

  1. Gold নথিসহ প্রশ্ন-তালিকা হাতে লেখা
  2. Model card পড়ে pooling, উপসর্গ আর লাইসেন্স দেখা
  3. নথিগুলো “passage: ” দিয়ে encode করে normalise করে রাখা
  4. প্রশ্ন “query: ” দিয়ে encode করে cosine-এ সাজানো
  5. BM25 baseline আর hybrid-এর সঙ্গে recall@k ও MRR মাপা
  6. Bootstrap ব্যবধান আর ব্যর্থ প্রশ্ন পড়ে সিদ্ধান্ত নেওয়া

একটি বাক্যের token vector [2, 0], [4, 2] আর একটি pad [10, 10]; mask [1, 1, 0]. Mask-সহ mean pooling-এর ফল কত?

  • [5.33, 4]
  • [2, 0]
  • [3, 1]
  • [6, 2]

১০ লাখ নথি থেকে প্রতিটি প্রশ্নে দ্রুত উত্তর খুঁজতে হবে। কোন নকশা সবচেয়ে যুক্তিসংগত?

  • সব নথিতে cross-encoder চালানো
  • Bi-encoder বা BM25-এ প্রথম কয়েকশো, তারপর সেগুলোতে cross-encoder
  • Cross-encoder-এর score আগেই হিসাব করে রাখা
  • প্রতিটি প্রশ্নে সব নথি আবার bi-encoder দিয়ে encode করা

InfoNCE-এ একটি প্রশ্নের তিন নথির cosine [0.6, 0.6, 0.0], প্রথমটি সঠিক, τ = ০.১. সঠিক নথির softmax সম্ভাবনা প্রায় কত?

  • প্রায় ১.০
  • প্রায় ০.৩৩
  • ০.৬
  • প্রায় ০.৫

দুটি vector-এর দৈর্ঘ্য ১ (unit vector). তাদের dot product আর cosine-এর সম্পর্ক কী?

  • হুবহু সমান
  • Dot হলো cosine-এর বর্গ
  • Dot = ১ − cosine
  • কোনো নির্দিষ্ট সম্পর্ক নেই

“ঢাকায় ডেলিভারি চার্জ কত?” প্রশ্নে BM25 সঠিক নথিটি পেল না, যেখানে লেখা “ঢাকা সিটির ভেতরে ডেলিভারি চার্জ ৬০ টাকা”. সবচেয়ে সম্ভাব্য কারণ কী?

  • Vector normalise করা হয়নি
  • IDF ঋণাত্মক হয়ে গেছে
  • বিভক্তির কারণে “ঢাকায়” আর “ঢাকা” মেলেনি
  • BM25 বাংলা লিপিতে কাজ করে না

৬০টি প্রশ্নে system A-এর MRR ০.৬৬, B-এর ০.৬৮; paired bootstrap-এ পার্থক্যের ৯৫% ব্যবধান [−০.০৫, +০.০৯]. সবচেয়ে ঠিক সিদ্ধান্ত কোনটি?

  • B নিশ্চিতভাবে ভালো
  • এই ডেটায় পার্থক্য নিশ্চিত নয়; আরও প্রশ্ন বা অন্য বিবেচনা লাগবে
  • A নিশ্চিতভাবে ভালো
  • ৬০টি প্রশ্নে bootstrap চালানোই যায় না

শব্দকোষ

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

মনে রাখুন

  1. Pooling: mean = ∑mtht ÷ ∑mt; mask ভুললে pad গড়ে ঢোকে, কোনো error ছাড়াই। Model যে pooling-এ শেখানো, সেটিই নিন।
  2. দুই নকশা: bi-encoder-এ নথির vector আগেই রাখা যায়; cross-encoder প্রতিটি জোড়া একসঙ্গে পড়ে, তাই শুধু অল্প প্রার্থীর reranking-এ।
  3. শেখা: InfoNCE = নিজের জোড়াকে “সঠিক শ্রেণি” ধরে cross-entropy, batch-এর বাকিরা negative; ছোট τ পার্থক্য ধারালো করে।
  4. খোঁজ: normalise করলে cosine = dot; e5-এ “query: ” / “passage: ”. BM25 সংখ্যা, কোড আর হুবহু শব্দে শক্ত, বিভক্তি আর ভাষা-পেরোনোয় দুর্বল।
  5. মাপ: নিজের দাগানো প্রশ্নে recall@k, MRR, bootstrap ব্যবধান; RRF = ∑ ১/(k + rank), কিন্তু জয় নিশ্চিত নয়, মেপে দেখুন।

পরের পর্বে: পর্ব ৬৮-এ (Vector index — FAISS, approximate nearest neighbor আর vector database) লাখ লাখ vector-এ দ্রুত খোঁজা: IVF, HNSW আর recall বনাম গতির হিসাব।

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

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