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

Embedding আর vector search — অর্থ দিয়ে খোঁজা

Embeddings and Vector Search — From TF-IDF to LSA and a Multilingual Sentence Model, Brute-Force and Approximate Nearest Neighbours, and Honest Retrieval Evaluation on Bangla Reviews

Taught in Bangla, this lesson shows why TF-IDF misses paraphrases, learns small embeddings with truncated SVD (LSA) in the browser, and compares TF-IDF, LSA and a real multilingual-e5-small run on Bangla and Banglish reviews with recall@k and MRR. It covers brute-force top-k cost, argpartition and heaps, a real FAISS HNSW and IVF benchmark, embedding drift, privacy and bias.

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

  • শব্দভিত্তিক খোঁজ কেন একই অর্থের ভিন্ন শব্দ ধরতে পারে না, আর one-hot, sparse আর dense vector-এর পার্থক্য
  • নিজের হাতে truncated SVD দিয়ে ছোট বাংলা সংগ্রহ থেকে embedding শেখা, আর তার সীমা
  • recall@k আর MRR হাতে ও কোডে, আর তিনটি খোঁজ-পদ্ধতির ন্যায্য তুলনা
  • একটি ছোট খোলা লাইসেন্সের multilingual model দিয়ে বাংলা ও Banglish খোঁজা, model card আর লাইসেন্স পড়ে
  • Brute-force top-k-এর খরচ, HNSW আর IVF-এর ধারণা, আর version, গোপনীয়তা ও bias-এর বাস্তব সাবধানতা

পর্ব ৬৭-এর শেষে দেখেছিলেন, TF-IDF-এর মিল মানে শব্দের মিল, অর্থের নয়। আর পর্ব ৩৩-এ হাতে বানানো একটি পণ্য-embedding দিয়ে cosine-এ “সবচেয়ে কাছের” পণ্য খুঁজেছিলেন। এই পর্ব দুটিকে জোড়া দেয়: embedding কোথা থেকে আসে, বিপুল vector-এ কীভাবে দ্রুত খোঁজা যায়, আর খোঁজ ভালো হলো কি না, তা কীভাবে মাপবেন। চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); প্রথম তিনটি একই namespace ভাগ করে (data-shared), আর প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। Pretrained model আর FAISS-এর জন্য ডেস্কটপের সত্যিকারের run.

১ · শব্দ মেলে না, অর্থ মেলে — TF-IDF কোথায় হারে

বাজারঘরের গ্রাহকসেবা দল চায় একটি খোঁজার বাক্স: কোনো কর্মী “দেরিতে ডেলিভারি” লিখলে আগের সব দেরির অভিযোগ একসঙ্গে উঠে আসুক। এমন খোঁজকে বলে semantic search: শব্দ হুবহু মেলানো নয়, অর্থ মেলানো। “দেরিতে এসেছে” আর “সময়মতো পৌঁছায়নি” একই অভিযোগের দুটি paraphrase, কিন্তু দুটিতে একটি শব্দও এক নয়।

পাড়ার ওষুধের দোকানে কেউ বললেন “মাথা ধরেছে”, আরেকজন বললেন “মাথাব্যথা করছে”, তৃতীয়জন বললেন “মাথা টনটন করছে”। অভিজ্ঞ দোকানদার তিনজনকেই একই তাক দেখান। যে কর্মচারী শুধু খাতার শব্দ মেলান, তিনি বাকি দুজনকে “পাওয়া যায়নি” বলবেন। TF-IDF সেই কর্মচারী।

এই পর্বের জন্য তিনটি ছোট কাল্পনিক ফাইল পাতার ভেতরে রাখা: corpus.csv-এ ১০টি বিষয়ে (দেরি, ভাঙা, দাম, মাপ, রিফান্ড, ভুল পণ্য, ব্যাটারি, রং ওঠা, রাইডারের ব্যবহার, তাজা খাবার) ৩টি করে মোট ৩০টি রিভিউ, যার দুটি Banglish-এ; background.txt-এ ৩৮টি label-ছাড়া ছোট বাক্য, শুধু অংশ ৩-এ প্রসঙ্গ শেখার জন্য; আর queries.csv-এ ২০টি প্রশ্ন, প্রতি বিষয়ে দুটি, অংশ ৪-এর মূল্যায়নের জন্য। বিষয়ের নাম কোডে ইংরেজিতে (late, broken, …)।

প্রথম ব্লকটি পর্ব ৬৭-এর বাংলা-বান্ধব tokenizer আর TfidfVectorizer দিয়ে সব লেখার (৩৮ + ৩০) TF-IDF বানায়, তারপর শুধু ৩০টি রিভিউয়ের মধ্যে একটি প্রশ্ন খোঁজে। প্রতিটি সারির L2 দৈর্ঘ্য ১, তাই dot product-ই cosine similarity.

import re
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer

D = pd.read_csv("corpus.csv")                      # খোঁজার 30টি রিভিউ, 10টি বিষয়
B = [b for b in open("background.txt", encoding="utf-8").read().split("\n") if b]
BN = f"{chr(0x0980)}-{chr(0x09FF)}"                # বাংলা Unicode block (পর্ব ৬৭)
TOK = re.compile(rf"[\w{BN}]+")
def tok(t): return TOK.findall(t.lower())

vec = TfidfVectorizer(tokenizer=tok, token_pattern=None)
A = vec.fit_transform(B + list(D["text"])).toarray()   # 38 + 30 = 68টি লেখা
X = A[len(B):]                                     # শুধু খোঁজার 30টি রিভিউ
print("A", A.shape, "| X", X.shape, f"| X-এর শূন্য ঘর {(X == 0).mean():.1%}")

def show(scores, k=4):
    for i in np.argsort(-scores, kind="stable")[:k]:
        print(f"  {scores[i]:.3f}  {D['topic'][i]:<7} {D['text'][i]}")

q = "আমার অর্ডার দেরিতে এসেছে"
s = X @ vec.transform([q]).toarray()[0]            # L2 = 1, তাই dot = cosine
print("খোঁজা:", q)
show(s)
print("#1-এর score:", s[1], "|", D["text"][1])
print("ভাগ করা শব্দ:", set(tok(q)) & set(tok(D["text"][1])))
print("শূন্য score পাওয়া রিভিউ:", int((s == 0).sum()), "টি")

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

A (68, 196) | X (30, 196) | X-এর শূন্য ঘর 96.5%
খোঁজা: আমার অর্ডার দেরিতে এসেছে
  0.555  late    অর্ডার দেরিতে এসেছে, পাঁচ দিন অপেক্ষা করতে হলো
  0.372  wrong   লাল অর্ডার করেছিলাম, এসেছে নীল রঙের জামা
  0.200  wrong   ভুল জিনিস এসেছে, অর্ডারের সাথে মেলে না
  0.179  late    ডেলিভারি অনেক দেরিতে হলো, কথা ছিল দুই দিন, সময়মতো আসেনি
#1-এর score: 0.0 | পার্সেল সময়মতো পৌঁছায়নি, পুরো এক সপ্তাহ অপেক্ষা
ভাগ করা শব্দ: set()
শূন্য score পাওয়া রিভিউ: 26 টি

১৯৬টি আলাদা শব্দ, আর একটি রিভিউয়ের vector-এর ৯৬.৫% ঘর শূন্য: TF-IDF হলো sparse vector. প্রথম ফলটি ঠিক, কারণ “অর্ডার”, “দেরিতে”, “এসেছে” তিনটিই মেলে। কিন্তু দ্বিতীয় হয়েছে ভুল রঙের জামার রিভিউ (০.৩৭২), শুধু “অর্ডার” আর “এসেছে” শব্দের জোরে; তৃতীয়ও ভুল পণ্যের। দেরির আসল রিভিউ #১, “পার্সেল সময়মতো পৌঁছায়নি”, পেয়েছে হুবহু 0.0: ভাগ করা শব্দের set খালি। ৩০টির মধ্যে ২৬টিই শূন্য, অর্থাৎ TF-IDF তাদের মধ্যে কোনো ক্রমই দিতে পারে না।

TF-IDF যা মাপে, তা ঠিকই মেপেছে; শব্দের মিলের যন্ত্রে অর্থের মিল চাওয়াটাই ভুল।

২ · One-hot থেকে sparse, তারপর dense — vector-এর তিন চেহারা

লেখাকে vector বানানোর তিনটি ধাপ, পুরোনো থেকে নতুন:

১ · One-hot

Vocabulary-তে যত শব্দ, তত ঘর; প্রতিটি শব্দের নিজের একটি ঘরে ১, বাকি সব ০ (one-hot encoding). দুটি ভিন্ন শব্দের dot product সবসময় ০: “দেরিতে” আর “সময়মতো” যেমন দূরে, “দেরিতে” আর “আম”-ও তেমনই।

মাত্রা = vocabulary

২ · Sparse (TF-IDF)

একটি লেখার শব্দগুলোর one-hot যোগ করে ওজন দেওয়া। লেখার মিল মাপা যায়, কিন্তু শুধু ভাগ করা শব্দ দিয়ে। প্রায় সব ঘর শূন্য, তাই sparse matrix-এ রাখা হয়।

হাজারো ঘর, বেশিরভাগ 0

৩ · Dense (embedding)

অল্প কিছু মাত্রা (১০, ৩৮৪, ৭৬৮…), প্রায় সব ঘরে অশূন্য ভগ্নাংশ। মাত্রাগুলোর আলাদা নাম নেই; অর্থ ছড়িয়ে থাকে অনেক ঘরে মিলিয়ে। একই প্রসঙ্গের শব্দ কাছাকাছি দিকে।

শত ঘর, সব ভরা

সংজ্ঞায়: embedding মানে প্রতিটি শব্দ, বাক্য বা জিনিসকে একটি নির্দিষ্ট মাত্রার dense vector দেওয়া, এমনভাবে যে অর্থে কাছাকাছি জিনিসের cosine বড় হয়। কিন্তু অর্থ যন্ত্র জানবে কীভাবে? উত্তরটি এসেছে ভাষাতত্ত্ব থেকে, distributional hypothesis নামে: যে শব্দগুলো একই রকম প্রসঙ্গে বসে, তাদের অর্থও প্রায়ই কাছাকাছি। “দেরিতে” আর “সময়মতো … না” দুটিই বসে “পার্সেল”, “অপেক্ষা”-র পাশে; “তাজা” আর “টাটকা” বসে “মাছ”, “স্বাদ”-এর পাশে। শব্দের পাশাপাশি আসা (co-occurrence) গুনলেই অর্থের একটি ছায়া পাওয়া যায়।

ধারণাটির সাধারণত উল্লেখ করা উৎস Zellig Harris-এর ১৯৫৪ সালের প্রবন্ধ “Distributional Structure”, আর J. R. Firth-এর ১৯৫৭ সালের বহুল উদ্ধৃত বাক্য “You shall know a word by the company it keeps”. দুজনের বক্তব্য এক নয়, তাই এটিকে একক আবিষ্কার না বলে একটি চিন্তাধারা বলাই নিরাপদ। আর এটি কাজের অনুমান, প্রমাণিত নিয়ম নয়: “ভালো” আর “খারাপ” প্রায় একই প্রসঙ্গে বসে (“মান ভালো”, “মান খারাপ”), অথচ অর্থ উল্টো।

নিচের stepper-এর সংখ্যা ব্লক ১-এর TF-IDF আর পরের অংশের ১০ মাত্রার LSA থেকে (ডেস্কটপে একই কোডের সত্যিকারের run).

A = “দেরিতে এসেছে” · B = “সময়মতো পৌঁছায়নি”
এই ধাপ
A
B
A · B
  1. Token. দুটি বাক্যে দুটি করে শব্দ, কোনোটি এক নয়। মানুষের চোখে একই অভিযোগ, যন্ত্রের চোখে চারটি আলাদা প্রতীক।
  2. One-hot. শুধু এই চারটি শব্দের vocabulary ধরলে A = [1, 1, 0, 0], B = [0, 0, 1, 1]. Dot product ০, তাই cosine ০: one-hot-এ প্রতিটি শব্দ বাকি সবার সঙ্গে সমকোণে।
  3. TF-IDF. পুরো ১৯৬ শব্দের vocabulary-তে দুটি করে ঘর অশূন্য। “পৌঁছায়নি” বিরল (idf ৪.১৩৫৫), তাই B-তে তার ওজন বেশি। তবু কোনো ঘরে দুটিই অশূন্য নয়, cosine আবারও ০.
  4. Dense. TF-IDF vector-কে একটি (১৯৬ × ১০) matrix P দিয়ে গুণ: প্রতিটি শব্দের P-সারি একটি ১০ মাত্রার vector, আর বাক্যের vector সেগুলোর ওজনসহ যোগ। এখন ১০টি ঘরের প্রায় সবই অশূন্য, আর A, B-র dot product ০.০৮৯৯, শূন্য নয়। P কীভাবে আসে, অংশ ৩-এ।
  5. Normalise. প্রতিটিকে নিজের দৈর্ঘ্য দিয়ে ভাগ (A: ০.৩২৫৪, B: ০.৩৫৫৯), যাতে দুটিই unit vector হয়।
  6. Cosine. ০.০৮৯৯ ÷ (০.৩২৫৪ × ০.৩৫৫৯) ≈ ০.৭৭৭. একটি শব্দও ভাগ না করেও দুই বাক্য এখন কাছাকাছি দিকে, কারণ চারটি শব্দই আলাদা আলাদা রিভিউয়ে একই প্রসঙ্গে (পার্সেল, ডেলিভারি, অপেক্ষা) বসেছিল।

৩ · পাতাতেই embedding শেখা — truncated SVD আর LSA

“কোন শব্দ কোন প্রসঙ্গে বসে” তথ্যটি এর মধ্যেই আছে: ব্লক ১-এর matrix A, যার ৬৮টি সারি লেখা আর ১৯৬টি কলাম শব্দ। কিন্তু A বিশাল আর গোলমেলে: প্রতিটি শব্দ একটি আলাদা কলাম। আমরা চাই অল্প কয়েকটি “গোপন দিক”, যেখানে একসঙ্গে আসা শব্দগুলো একই দিকে পড়ে। এটিই পর্ব ৬৫-এর PCA-র ভাবনা, আর হাতিয়ারও একই: SVD.

A = U · diag(S) · VT

এখানে S হলো singular value-এর তালিকা, বড় থেকে ছোট; VT-এর প্রতিটি সারি শব্দের জগতে একটি দিক; U-র প্রতিটি সারি একটি লেখা সেই দিকগুলোতে কতটা আছে। শুধু প্রথম k-টি দিক রাখলে (truncated SVD) পাওয়া যায় A-র সবচেয়ে ভালো k-মাত্রার কাছাকাছি রূপ (Eckart–Young উপপাদ্য)। তখন P = Vk (VT-এর প্রথম k সারি, ঘুরিয়ে কলাম), যেকোনো লেখার TF-IDF vector × P = তার k-মাত্রার embedding, আর P-এর সারি × S হলো শব্দের embedding. শব্দ-নথি matrix-এ এই পদ্ধতির নাম latent semantic analysis (LSA).

LSA-র পরিচিত সূচনা Scott Deerwester, Susan Dumais, George Furnas, Thomas Landauer আর Richard Harshman-এর ১৯৯০ সালের প্রবন্ধ “Indexing by Latent Semantic Analysis” (Journal of the American Society for Information Science). তাঁদের লক্ষ্যও ছিল খোঁজ: ব্যবহারকারী যে শব্দ লেখেন আর নথিতে যে শব্দ থাকে, তা প্রায়ই আলাদা। পরে আসে ২০১৩ সালের word2vec (পর্ব ৩৩), যা পাশের শব্দ অনুমান করে শেখে; Levy আর Goldberg (২০১৪) দেখান, তার একটি রূপও গাণিতিকভাবে co-occurrence matrix-এর factorization-এর কাছাকাছি।

পরের ব্লক k = ১০ নিয়ে LSA চালায়, চারটি শব্দের সবচেয়ে কাছের শব্দ দেখায়, তারপর stepper-এর দুই বাক্য আর ব্লক ১-এর প্রশ্নটি নতুন vector দিয়ে আবার খোঁজে। ব্লকটি ব্লক ১-এর A, vec, show আর q ব্যবহার করে।

import numpy as np

U, S, Vt = np.linalg.svd(A, full_matrices=False)   # A = U · diag(S) · Vt
print("U", U.shape, "S", S.shape, "Vt", Vt.shape)
print("প্রথম 12টি singular value:", S[:12].round(3))
k = 10
P = Vt[:k].T                                      # (196, 10): TF-IDF থেকে 10 মাত্রায়
W = P * S[:k]                                     # প্রতিটি শব্দের 10-মাত্রার vector
W = W / np.linalg.norm(W, axis=1, keepdims=True)
words = list(vec.get_feature_names_out())
for w in ["দেরিতে", "ভাঙা", "দাম", "তাজা"]:
    sim = W @ W[words.index(w)]
    near = [j for j in np.argsort(-sim, kind="stable") if words[j] != w][:4]
    print(f"{w:<7} →", ", ".join(f"{words[j]} {sim[j]:.3f}" for j in near))

def embed(texts):                                 # নতুন লেখা: TF-IDF, তারপর P, তারপর L2
    E = vec.transform(texts).toarray() @ P
    n = np.linalg.norm(E, axis=1, keepdims=True)
    return np.divide(E, n, out=np.zeros_like(E), where=n > 1e-9)   # প্রায় 0 হলে 0

Dk = embed(D["text"])                             # 30টি রিভিউয়ের dense vector
print("শূন্য vector:", [D["text"][i][:22] for i in np.flatnonzero(~Dk.any(axis=1))])
a, b = embed(["দেরিতে এসেছে", "সময়মতো পৌঁছায়নি"])
np.set_printoptions(precision=2, suppress=True, linewidth=60)
print("a =", a, "\nb =", b)
print("cos(a, b) =", round(float(a @ b), 3))
print("খোঁজা:", q)
show(Dk @ embed([q])[0])

আসল আউটপুট (ব্রাউজারের Python, NumPy 2.4.6):

U (68, 68) S (68,) Vt (68, 196)
প্রথম 12টি singular value: [1.799 1.697 1.666 1.64  1.557 1.537 1.501 1.483 1.476 1.409 1.359 1.352]
দেরিতে  → আসেনি 0.996, দিন 0.981, সময়মতো 0.976, এল 0.963
ভাঙা    → ফাটা 0.996, কাপ 0.991, কাপও 0.984, বাক্সে 0.984
দাম     → কিনতাম 0.989, মূল্য 0.989, একটু 0.988, কম 0.988
তাজা    → স্বাদ 0.999, আম 0.999, মিষ্টি 0.999, একদম 0.996
শূন্য vector: ['refund ekhono paini, d', 'battery khub durbol, d', 'rider bhai khub binoyi']
a = [-0.6   0.29 -0.32  0.07 -0.25  0.26 -0.35 -0.03 -0.42
 -0.15] 
b = [-0.39  0.08 -0.17  0.36 -0.53  0.05 -0.43  0.16 -0.4
  0.14]
cos(a, b) = 0.777
খোঁজা: আমার অর্ডার দেরিতে এসেছে
  0.924  late    অর্ডার দেরিতে এসেছে, পাঁচ দিন অপেক্ষা করতে হলো
  0.748  wrong   লাল অর্ডার করেছিলাম, এসেছে নীল রঙের জামা
  0.723  late    পার্সেল সময়মতো পৌঁছায়নি, পুরো এক সপ্তাহ অপেক্ষা
  0.632  late    ডেলিভারি অনেক দেরিতে হলো, কথা ছিল দুই দিন, সময়মতো আসেনি

শব্দের প্রতিবেশী: “দেরিতে”-র কাছে “সময়মতো” (০.৯৭৬) আর “আসেনি” (০.৯৯৬); “ভাঙা”-র কাছে “ফাটা” (০.৯৯৬); “দাম”-এর কাছে “মূল্য” (০.৯৮৯); “তাজা”-র কাছে “স্বাদ”, “মিষ্টি”. কেউ এই সম্পর্ক বলে দেয়নি; শুধু পাশাপাশি আসার গোনা থেকে এসেছে। কিন্তু গোলমালও স্পষ্ট: “দিন”, “এল”, “কিনতাম”, “একটু”, “একদম”-এর মতো সাধারণ শব্দও তালিকায়, কারণ সেগুলো ঘটনাচক্রে একই বাক্যে ছিল। আর সংখ্যাগুলো প্রায় সবই ০.৯৬-এর বেশি: ১০টি মাত্রায় ১৯৬টি শব্দকে ঠাসা হয়েছে, তাই একই দলের সবাই প্রায় একই দিকে।

Singular value প্রায় সমান (১.৭৯৯ থেকে ১.৩৫২): ৬৮টি ছোট বাক্যে কোনো দিক বিশেষ প্রভাবশালী নয়। এটি ছোট সংগ্রহের সতর্ক সংকেত: embedding গোলমেলে, ডেটা একটু বদলালেই প্রতিবেশী বদলাবে।

তিনটি শূন্য vector: তিনটি Banglish রিভিউয়ের কোনো শব্দ অন্য কোনো লেখায় নেই, তাই প্রসঙ্গ শেখার কিছুই ছিল না; প্রথম ১০টি দিকে তাদের ছায়া কার্যত শূন্য। এমন vector-কে দৈর্ঘ্য দিয়ে ভাগ করলে শুধু ভাসমান-বিন্দুর গোলমাল বড় হয়ে একটি এলোমেলো দিক পায়; এই পাতার প্রথম খসড়ায় ঠিক তা-ই ঘটেছিল, আর ডেস্কটপ ও ব্রাউজার আলাদা recall দিয়েছিল; তাই embed এমন সারিকে ০ রাখে, cosine-ও ০. LSA নিজের সংগ্রহের বাইরের শব্দ চেনে না।

খোঁজা: এখন “পার্সেল সময়মতো পৌঁছায়নি” তৃতীয় (০.৭২৩), ০ থেকে উঠে এসে। তবে ভুল রঙের জামার রিভিউ এখনো দ্বিতীয় (০.৭৪৮): “অর্ডার … এসেছে” প্রসঙ্গটি দুই বিষয়েই আছে। ভেক্টরের ঘরগুলোর চিহ্ন (+/−) আলাদা কম্পিউটারে উল্টে যেতে পারে, কারণ SVD-র প্রতিটি দিক −১ দিয়ে গুণ করলেও সমান বৈধ; cosine তাতে বদলায় না।

সৎ সতর্কতা: background বাক্যগুলো আর প্রশ্নগুলো একই লেখকের বানানো, প্রশ্ন জেনেই। তাই LSA-র জন্য দরকারি প্রসঙ্গ (যেমন “দেরিতে” আর “সময়মতো” একই বাক্যে) এখানে সহজে পাওয়া গেছে। বাস্তবে embedding শেখা হয় লক্ষ-কোটি বাক্য থেকে, যা খোঁজার প্রশ্ন না দেখেই লেখা। এই অংশের লক্ষ্য পদ্ধতি দেখানো, LSA-কে “ভালো” প্রমাণ করা নয়।

৪ · খোঁজা কতটা ভালো হলো — recall@k আর MRR

একটি উদাহরণে ভালো দেখানো কিছুই প্রমাণ করে না। খোঁজের মান মাপতে লাগে একটি label দেওয়া প্রশ্ন-তালিকা: প্রতিটি প্রশ্নের জন্য আগে থেকে ঠিক করা, কোন নথিগুলো প্রাসঙ্গিক। আমাদের queries.csv-এ ২০টি প্রশ্ন, প্রতিটির প্রাসঙ্গিক উত্তর একই বিষয়ের ৩টি রিভিউ। Information retrieval-এর দুটি বহুল ব্যবহৃত মাপ:

  • Recall@k = (প্রথম k ফলে কতগুলো প্রাসঙ্গিক) ÷ (মোট প্রাসঙ্গিক)। আমাদের ৩টি প্রাসঙ্গিক, তাই recall@3-এর মান ০, ০.৩৩, ০.৬৭ বা ১. পর্ব ৬০-এর recall-এর মতোই, শুধু তালিকার প্রথম k-টিতে সীমিত।
  • MRR (mean reciprocal rank): প্রতিটি প্রশ্নে প্রথম প্রাসঙ্গিক ফলটি কত নম্বরে (r), তার উল্টো 1/r; তারপর সব প্রশ্নের গড়। প্রথমেই ঠিক ফল হলে ১, দ্বিতীয়তে ০.৫, তৃতীয়তে ০.৩৩. যিনি শুধু প্রথম ঠিক উত্তরটি চান, তাঁর অভিজ্ঞতা এটি মাপে।
কাল্পনিক র‍্যাঙ্কিং · ✓ = প্রাসঙ্গিক
এই ধাপ
প্রশ্ন ১ “রং উঠে গেছে”
প্রশ্ন ২ “রিফান্ড দিচ্ছে না”
গড়
  1. প্রশ্ন ১-এর তালিকা। খোঁজ-ব্যবস্থা পাঁচটি রিভিউ এই ক্রমে দিয়েছে। আগে থেকে জানা, প্রাসঙ্গিক তিনটি: r2, r5, r7.
  2. Recall@3. প্রথম তিনটি r5, r1, r7; এর দুটি প্রাসঙ্গিক। মোট প্রাসঙ্গিক ৩, তাই ২ ÷ ৩ ≈ ০.৬৬৭. r2 পঞ্চম স্থানে, তাই গোনায় আসেনি।
  3. Reciprocal rank. প্রথম প্রাসঙ্গিক ফল r5, এক নম্বরে: RR = ১/১ = ১. পরেরগুলো কোথায়, RR তা দেখে না।
  4. প্রশ্ন ২-এর তালিকা। প্রাসঙ্গিক মাত্র দুটি: r4, r9. প্রথম দুটি ফল (r1, r6) অপ্রাসঙ্গিক।
  5. Recall@3 আর RR. প্রথম তিনে শুধু r4: ১ ÷ ২ = ০.৫. প্রথম প্রাসঙ্গিক তিন নম্বরে, RR = ১/৩ ≈ ০.৩৩৩.
  6. গড়। Recall@3 = (০.৬৬৭ + ০.৫) ÷ ২ ≈ ০.৫৮৩; MRR = (১ + ০.৩৩৩) ÷ ২ ≈ ০.৬৬৭. দুটি মাপ দুটি আলাদা প্রশ্নের উত্তর দেয়: “কতটা পেলাম” আর “প্রথম ঠিকটি কত তাড়াতাড়ি”।

এবার একই হিসাব কোডে, ২০টি প্রশ্নে, TF-IDF আর কয়েকটি k-এর LSA-র জন্য। ব্লকটি ব্লক ১ আর ২-এর D, X, vec, Vt ব্যবহার করে। সমতায় (kind="stable") আগের সারির রিভিউ আগে আসে, যাতে ফল প্রতিবার এক থাকে।

import numpy as np
import pandas as pd

Q = pd.read_csv("queries.csv")       # 20টি প্রশ্ন; ঠিক উত্তর = একই বিষয়ের 3টি রিভিউ
def evaluate(score_fn, k=3):
    rec, rr = [], []
    for topic, q in zip(Q["topic"], Q["query"]):
        order = np.argsort(-score_fn(q), kind="stable")
        rel = set(np.flatnonzero(D["topic"] == topic).tolist())
        rec.append(len(rel & set(order[:k].tolist())) / len(rel))   # recall@k
        first = next(r for r, i in enumerate(order, 1) if i in rel)
        rr.append(1 / first)                                      # reciprocal rank
    return np.array(rec), np.array(rr)

def unit(E):                                       # সারি L2 = 1; প্রায় শূন্য সারি 0-ই থাকে
    n = np.linalg.norm(E, axis=-1, keepdims=True)
    return np.divide(E, n, out=np.zeros_like(E), where=n > 1e-9)

def lsa_scorer(kk):
    Pk = Vt[:kk].T
    Dn = unit(X @ Pk)
    return lambda q: Dn @ unit(vec.transform([q]).toarray()[0] @ Pk)

tf = evaluate(lambda q: X @ vec.transform([q]).toarray()[0])
print(f"TF-IDF     recall@3 {tf[0].mean():.3f}  MRR {tf[1].mean():.3f}")
for kk in (5, 10, 20, 40):
    r = evaluate(lsa_scorer(kk))
    print(f"LSA k={kk:<4} recall@3 {r[0].mean():.3f}  MRR {r[1].mean():.3f}")
ls = evaluate(lsa_scorer(10))
print("\nযেখানে recall@3 বদলেছে (TF-IDF → LSA k=10):")
for q, a, b in zip(Q["query"], tf[0], ls[0]):
    if a != b:
        print(f"  {a:.2f} → {b:.2f}  {q}")

আসল আউটপুট (ব্রাউজারের Python):

TF-IDF     recall@3 0.550  MRR 0.883
LSA k=5    recall@3 0.550  MRR 0.788
LSA k=10   recall@3 0.733  MRR 0.929
LSA k=20   recall@3 0.617  MRR 0.942
LSA k=40   recall@3 0.600  MRR 0.942

যেখানে recall@3 বদলেছে (TF-IDF → LSA k=10):
  0.67 → 0.33  পণ্য আসতে অনেক দেরি হলো
  0.67 → 0.33  ডেলিভারি সময়মতো হয়নি
  0.67 → 1.00  জিনিস ভাঙা অবস্থায় এসেছে
  0.33 → 0.00  কাপ ফেটে গেছে
  0.33 → 1.00  দাম খুব চড়া
  0.33 → 1.00  মাপে বড় হয়েছে
  0.67 → 1.00  সাইজ ঠিক হয়নি
  0.33 → 0.67  রিফান্ড দিচ্ছে না
  0.33 → 1.00  যা চেয়েছি তা পাঠায়নি
  0.33 → 0.67  ব্যাটারি তাড়াতাড়ি শেষ
  0.67 → 1.00  ধোয়ার পর রং চলে গেছে
  0.33 → 0.67  ডেলিভারি ম্যানের আচরণ ভালো
  0.67 → 1.00  মাছ টাটকা ছিল
  0.67 → 1.00  সবজির স্বাদ ভালো

TF-IDF-এর recall@3 ০.৫৫০, MRR ০.৮৮৩: প্রথম ফলটি প্রায়ই ঠিক (কোনো একটি শব্দ মিললেই), কিন্তু তিনটির সবগুলো খুব কমই আসে। LSA k = ১০-এ recall@3 ০.৭৩৩, MRR ০.৯২৯. কিন্তু k-এর সারিটি দেখুন: k = ৫-এ recall TF-IDF-এর সমান আর MRR কম; k = ২০ বা ৪০-এ আবার নামে। আমরা k = ১০ বেছেছি এই ২০টি প্রশ্নের ফল দেখেই, যা পর্ব ৬৬-এর ভাষায় test set দিয়ে hyperparameter বাছাই। তাই LSA-র সুবিধা এখানে একটি আশাবাদী অনুমান।

প্রশ্ন ধরে দেখলে ছবিটি মিশ্র। “দাম খুব চড়া”, “মাপে বড় হয়েছে”, “যা চেয়েছি তা পাঠায়নি” ০.৩৩ থেকে ১-এ উঠেছে: প্রতিশব্দ (দাম/মূল্য, মাপ/সাইজ) প্রসঙ্গ থেকে জোড়া লেগেছে। কিন্তু “পণ্য আসতে অনেক দেরি হলো” ০.৬৭ থেকে ০.৩৩-এ নেমেছে। কারণ “দেরি” (দেরিতে নয়) এই সংগ্রহে বেশি এসেছে রিফান্ডের প্রসঙ্গে (“রিফান্ড আসতে দেরি”), তাই LSA তাকে রিফান্ডের দিকে ঠেলেছে। আর “কাপ ফেটে গেছে” ০-তে: “ফেটে” কোথাও নেই, আর “গেছে” আছে রং ওঠার রিভিউয়ে। প্রসঙ্গ থেকে শেখা মানে প্রসঙ্গের ভুলও শেখা।

৫ · Pretrained sentence embedding — ডেস্কটপে সত্যিকারের run

বাস্তবে লক্ষ-কোটি বাক্যে আগেই শেখানো একটি pretrained model নামিয়ে ব্যবহার করা হয়, যা প্রতিটি বাক্যের জন্য একটি vector দেয়: sentence embedding. এমন model সাধারণত transformer-ভিত্তিক, আর শেখে contrastive learning দিয়ে: মিল থাকা জোড়ার vector কাছে, এলোমেলো জোড়ার দূরে। Nils Reimers আর Iryna Gurevych-এর ২০১৯ সালের Sentence-BERT প্রবন্ধ এই ধারাকে জনপ্রিয় করে, আর sentence-transformers library সেখান থেকেই।

কোন model, কেন, আর লাইসেন্স

বাছাইয়ের শর্ত ছিল: ছোট, ল্যাপটপের CPU-তে চলে, account বা token ছাড়া নামানো যায়, খোলা লাইসেন্স, আর model card-এ বাংলার উল্লেখ। বেছে নেওয়া হয়েছে Hugging Face Hub-এর intfloat/multilingual-e5-small. লেখার সময়ে তার model card যা বলে (নিজে পড়ে যাচাই করা):

  • ভাষা: metadata-য় ৯৪টি ভাষার তালিকা, তাতে bn (বাংলা) আছে। Banglish-এর কোনো আলাদা উল্লেখ নেই।
  • আকার: ১২টি স্তর, embedding-এর মাপ ৩৮৪; microsoft/Multilingual-MiniLM-L12-H384 থেকে শুরু করা। Weight ফাইল model.safetensors প্রায় ৪৭১ MB.
  • লাইসেন্স: metadata-য় license: mit. Repo-তে আলাদা LICENSE ফাইল নেই, তাই MIT-এর মানক লেখাটি পড়ে নেওয়া হয়েছে: ব্যবহার, বদল আর বিতরণের অনুমতি, শর্ত হলো copyright ও লাইসেন্সের লেখা সঙ্গে রাখা, আর কোনো warranty নেই (MIT License). Model যে ডেটায় শেখানো, তার নিজস্ব শর্ত আলাদা প্রশ্ন; card-এ তার বিস্তারিত তালিকা নেই।
  • ব্যবহারের নিয়ম: খোঁজার কাজে প্রশ্নের আগে "query: " আর নথির আগে "passage: " জুড়তে হবে, নইলে card অনুযায়ী মান কমে। আর FAQ-তে লেখা, cosine score সাধারণত ০.৭ থেকে ১.০-এর মধ্যে থাকে, কারণ training-এ খুব কম temperature (০.০১) ব্যবহার হয়েছে; গুরুত্বপূর্ণ হলো score-এর ক্রম, মান নয়।
  • সীমা: লম্বা লেখা ৫১২ token-এ কেটে ফেলা হয়। Card-এর উদ্ধৃতি: Liang Wang ও সহকর্মী, “Multilingual E5 Text Embeddings: A Technical Report”, ২০২৪.

Model card আর লাইসেন্স পড়ার পুরো অভ্যাস পর্ব ৭১ (Pretrained model ব্যবহার — Hugging Face, model card আর লাইসেন্স)-এ। এখানে নির্দিষ্ট reproducibility-র জন্য model-এর revision (commit) পিন করা হয়েছে।

~/bazarghar-ai$ python -m venv .venv
~/bazarghar-ai$ .venv/bin/python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
~/bazarghar-ai$ .venv/bin/python -m pip install sentence-transformers faiss-cpu scikit-learn pandas
from sentence_transformers import SentenceTransformer
MODEL, REV = "intfloat/multilingual-e5-small", "614241f622f53c4eeff9890bdc4f31cfecc418b3"
m = SentenceTransformer(MODEL, revision=REV, device="cpu")
P = m.encode(["passage: " + t for t in D.text], normalize_embeddings=True)   # (30, 384)
def qemb(texts):
    return m.encode(["query: " + t for t in texts], normalize_embeddings=True)
# … TF-IDF আর LSA k=10: পাতার ব্লক ১–৩-এর হুবহু কোড; একই evaluate()
res = {"TF-IDF": evaluate(tfidf), "LSA k=10": evaluate(lsa),
       "e5-small": evaluate(lambda q: P @ qemb([q])[0])}
~/bazarghar-ai$ python st_search.py
Python 3.14.4, torch 2.15.0+cpu, sentence-transformers 6.2.0, scikit-learn 1.9.1
model intfloat/multilingual-e5-small rev 614241f | dim 384
P (30, 384) float32 | ‖row‖ [1. 1. 1.] | 3 ms/review
TF-IDF    recall@3 0.550  MRR 0.883
LSA k=10  recall@3 0.733  MRR 0.929
e5-small  recall@3 0.617  MRR 0.835
per query recall@3 (TF-IDF / LSA / e5):
  0.67 0.33 0.67  পণ্য আসতে অনেক দেরি হলো
  0.67 0.33 0.67  ডেলিভারি সময়মতো হয়নি
  0.67 1.00 0.33  জিনিস ভাঙা অবস্থায় এসেছে
  0.33 0.00 0.67  কাপ ফেটে গেছে
  0.33 1.00 1.00  দাম খুব চড়া
  …
  0.33 0.67 0.00  রিফান্ড দিচ্ছে না
  1.00 1.00 0.33  অন্য রঙের জিনিস এসেছে
  …
  0.67 1.00 0.33  মাছ টাটকা ছিল
  0.67 1.00 1.00  সবজির স্বাদ ভালো

আমার অর্ডার দেরিতে এসেছে   (সব 30টির score 0.747–0.859)
  0.859 late    অর্ডার দেরিতে এসেছে, পাঁচ দিন অপেক্ষা করতে হলো
  0.850 refund  ফেরত দেওয়ার পর রিফান্ড আসতে দেরি হচ্ছে
  0.831 wrong   ভুল জিনিস এসেছে, অর্ডারের সাথে মেলে না
  … #1 'সময়মতো পৌঁছায়নি' : score 0.799, rank 14

delivery onek deri hoise   (সব 30টির score 0.754–0.810)
  0.810 refund  refund ekhono paini, dui shoptaho hoye gelo
  0.808 battery battery khub durbol, druto shesh hoye jay
  0.804 rider   ডেলিভারি ম্যান খুব ভদ্র, হাসিমুখে পণ্য দিয়ে গেলেন

…

taka ferot chai   (সব 30টির score 0.761–0.814)
  0.814 size    সাইজ মেলেনি, জামা গায়ে আঁটসাঁট
  0.810 battery battery khub durbol, druto shesh hoye jay
  0.810 size    মাপ ঠিক নেই, সাইজ চার্ট ভুল

মাছ পচা ছিল   (সব 30টির score 0.737–0.847)
  0.847 fresh   মাছ একদম তাজা, স্বাদ দারুণ
  0.807 fade    একবার ধোয়ার পর রং উঠে গেছে
  0.797 fade    কাপড় ফ্যাকাশে হয়ে গেল, মান খারাপ

দাম সস্তা, খুশি   (সব 30টির score 0.781–0.857)
  0.857 price   দাম অনেক বেশি, বাজারে কম টাকায় পাওয়া যায়
  0.857 fresh   মাছ একদম তাজা, স্বাদ দারুণ
  0.855 price   মূল্য বেশি মনে হলো, দাম একটু কমানো উচিত

সত্যিকারের run, লেখার সময়ের version-এ (CPU only, transformers 5.22.1, huggingface_hub 2.4.0); কিছু লাইন “…” দিয়ে বাদ। প্রথমবার নামানোর সময় library সতর্ক করেছিল যে token ছাড়া গতির সীমা কম; এই খোলা model-এ token লাগেনি, দিইনি। পরের run HF_HUB_OFFLINE=1 দিয়ে, cache থেকে।

ফল যা বলে:

  • গড়ে e5 TF-IDF-এর চেয়ে ভালো, LSA-র চেয়ে খারাপ (recall@3 ০.৬১৭ বনাম ০.৫৫০ আর ০.৭৩৩)। LSA পেয়েছিল এই প্রশ্নের জন্য সাজানো ডেটা আর k; e5 সংগ্রহটির কিছুই দেখেনি। ২০টি প্রশ্নে একটি প্রশ্নের এক ধাপ বদল মানে গড়ে ০.০১৭, তাই পার্থক্যগুলো দুর্বল প্রমাণ।
  • যেখানে e5 জেতে: “কাপ ফেটে গেছে” (TF-IDF ০.৩৩, LSA ০, e5 ০.৬৭): subword tokenizer আর বিপুল লেখা থেকে “ফেটে”/“ফাটা”-র সম্পর্ক এসেছে বলে মনে হয়।
  • যেখানে হারে: “রিফান্ড দিচ্ছে না”-এ ০; আর “সময়মতো পৌঁছায়নি” ৩০টির মধ্যে ১৪ নম্বরে (০.৭৯৯)।
  • সংকীর্ণ score: সব প্রশ্ন মিলিয়ে ৩০টির score ০.৭৪–০.৯২-এর মধ্যে (ওপরের প্রশ্নে ০.৭৪৭–০.৮৫৯), card যেমন বলেছিল। তাই “০.৮-এর বেশি হলে প্রাসঙ্গিক” ধরনের সীমা অর্থহীন।
  • Banglish দুর্বল: “delivery onek deri hoise” আর “taka ferot chai”-এর প্রথম ফলগুলো ভুল; model যেন লিপির মিলকে অর্থের চেয়ে বেশি টানে। Card-এ বাংলা আছে, Banglish নেই।
  • বিপরীত অর্থ কাছাকাছি: “মাছ পচা ছিল”-এর প্রথম ফল “মাছ একদম তাজা” (০.৮৪৭)। বিষয় মিলেছে, মনোভাব উল্টো; খোঁজে এটি চলে, কিন্তু embedding-এর মিলকে মনোভাবের মিল ভাববেন না।

৬ · Vector search-এর যন্ত্র — normalise, dot, top-k আর খরচ

খোঁজার যন্ত্রের দুটি পর্যায়: একবার সব নথির vector বানিয়ে রাখা (index), তারপর প্রতিটি প্রশ্নের vector বানিয়ে সবচেয়ে কাছেরগুলো খোঁজা। ছবির সবচেয়ে জরুরি অংশ মাঝের লাল দাগ: দুই পর্যায়ে একই model, একই version.

Vector search-এর দুই পর্যায়

Index once, query many times — the same model and revision on both sides

Indexing path and query path meeting at the vector index আগে, একবার প্রতিটি খোঁজায় ৩০টি রিভিউ নতুন প্রশ্ন e5-small rev 614241f "passage: " একই model rev 614241f "query: " X: 30 × 384 float32 · দৈর্ঘ্য 1 q: 384 দৈর্ঘ্য 1 index: flat · HNSW · IVF score = X @ q → top-k ফল: সবচেয়ে কাছের ৩টি = একই

Normalise করলে cosine = dot. পর্ব ৩৩-এর সূত্র cos θ = (a·b) / (‖a‖ ‖b‖). সব vector আগেই unit vector করে রাখলে হর ১, তাই n-টি cosine মানে একটি matrix-vector গুণ X @ q: প্রতিটি সারির জন্য d-টি গুণ আর যোগ। মোট খরচ প্রতি প্রশ্নে O(n·d) (time complexity), আর মেমরি n·d·৪ byte (float32-এ প্রতিটি সংখ্যা ৪ byte).

Top-k বাছাই। সব n-টি score সাজালে লাগে O(n log n). কিন্তু আমাদের চাই শুধু সেরা k-টি। দুটি পথ: NumPy-র argpartition, যা গড়ে O(n) সময়ে সেরা k-টিকে আলাদা করে (ভেতরের ক্রম ছাড়া), তারপর শুধু সেই k-টি সাজানো; অথবা আকারে k-এর একটি min-heap, যার মাথায় সবসময় এখন পর্যন্ত সেরা k-এর সবচেয়ে দুর্বলটি থাকে। নতুন score তার চেয়ে বড় হলে বদল, নইলে বাদ। খরচ O(n log k), আর মেমরিতে মাত্র k-টি। Heap-এর গঠন বিস্তারিত দেখুন DSA কোর্সের heap পর্বে; Python-এ heapq.

প্রশ্ন “আমার অর্ডার দেরিতে এসেছে” · LSA k = 10-এর cosine
রিভিউ
score
কাজ
heap (মাথা আগে)
  1. প্রথম রিভিউ। Heap খালি, তাই ০.০৩৫ সরাসরি ঢোকে। Score-গুলো LSA (k = ১০) থেকে, ডেস্কটপে একই কোডের সত্যিকারের run; ক্রম ইচ্ছা করে এলোমেলো রাখা।
  2. দ্বিতীয়। ০.৭৪৮ ঢোকে। Min-heap-এ সবচেয়ে ছোটটি (০.০৩৫) মাথায় থাকে।
  3. তৃতীয়। −০.১২৭ ঢোকে, আর সবচেয়ে ছোট বলে মাথায় ওঠে। Heap এখন পূর্ণ: তিনটি। ঋণাত্মক cosine মানে প্রায় উল্টো দিক।
  4. #১, ০.৭২৩. মাথার −০.১২৭-এর চেয়ে বড়, তাই মাথাকে সরিয়ে এটি ঢোকে (heapreplace), আর নতুন মাথা ০.০৩৫. প্রতিটি বদল O(log k).
  5. #১৭, ০.৫৪০. মাথা ০.০৩৫-এর চেয়ে বড়: বদল। এটি ভুল পণ্যের রিভিউ, কিন্তু এই মুহূর্তে সেরা তিনের মধ্যে।
  6. #০, ০.৯২৪. মাথা ০.৫৪০ সরে যায়। নতুন মাথা ০.৭২৩.
  7. #২, ০.৬৩২. মাথা ০.৭২৩-এর চেয়ে ছোট, তাই একটি তুলনাতেই বাদ। Heap-এর আসল সুবিধা এখানে: বেশিরভাগ দুর্বল প্রার্থী এক তুলনায় বিদায় নেয়।
  8. শেষ। Heap-এর তিনটিকে বড় থেকে ছোট সাজালে #০ ০.৯২৪, #১৫ ০.৭৪৮, #১ ০.৭২৩: ঠিক ব্লক ২-এর প্রথম তিনটি। সব ৩০টি দেখলেও উত্তর একই, কারণ বাকি রিভিউগুলোর score ০.৭২৩-এর কম।

পরের ব্লক বড় মাপে একই কাজ করে: ২০,০০০টি এলোমেলো ৩৮৪-মাত্রার float32 vector (e5-small-এর মাপে), normalise, X @ q, argpartition, আর পুরো argsort-এর সঙ্গে সময়ের তুলনা। সময় মাপা হয়েছে সাতবারের মধ্যে সবচেয়ে কম দিয়ে, যাতে প্রথম-বারের বাড়তি খরচ বাদ যায়। এই ব্লক আগের ব্লকগুলোর namespace ব্যবহার করে না।

import time
import numpy as np

n, d, k = 20_000, 384, 5
rng = np.random.default_rng(42)
X = rng.standard_normal((n, d), dtype=np.float32)     # 20,000টি এলোমেলো "embedding"
X /= np.linalg.norm(X, axis=1, keepdims=True)         # প্রতিটি সারি unit vector
q = X[123] + 0.5 * rng.standard_normal(d, dtype=np.float32) / np.sqrt(d)
q /= np.linalg.norm(q)                                # query: #123-এর কাছের একটি বিন্দু

def ms(f, r=7):                                       # r বারের মধ্যে সবচেয়ে কম সময়
    best = 1e9
    for _ in range(r):
        t = time.perf_counter(); f(); best = min(best, time.perf_counter() - t)
    return best * 1000

s = X @ q                                             # n টি dot product = n টি cosine
top = np.argpartition(-s, k)[:k]                      # সেরা k-টি, ক্রম ছাড়া, গড়ে O(n)
top = top[np.argsort(-s[top])]                        # শুধু k-টিকে সাজানো
print("dtype", X.dtype, "| shape", X.shape, "| X.nbytes", X.nbytes)
print("top-5:", top.tolist(), "\ncosine:", s[top].round(3).tolist())
print("পুরো argsort-এর সঙ্গে মেলে?", np.array_equal(top, np.argsort(-s)[:k]))
print(f"X @ q {ms(lambda: X @ q):.2f} ms | argpartition {ms(lambda: np.argpartition(-s, k)):.2f} ms"
      f" | argsort {ms(lambda: np.argsort(-s)):.2f} ms")
c = (X[200:] @ X[:200].T).ravel()                     # এলোমেলো জোড়ার cosine কেমন?
print(f"এলোমেলো জোড়ার cosine: গড় {c.mean():.4f}, sd {c.std():.4f}, সর্বোচ্চ {c.max():.3f}")
print(f"1/√d = {1 / np.sqrt(d):.4f}")
for N in (20_000, 10**6):
    print(f"n = {N:>9,}: গুণ-যোগ {N * d:>11,} প্রতি query | মেমরি {N * d * 4 / 1e6:,.1f} MB")

আসল আউটপুট (ব্রাউজারের Python, একটি run; সময় আপনার যন্ত্রে আলাদা হবে):

dtype float32 | shape (20000, 384) | X.nbytes 30720000
top-5: [123, 13592, 520, 5480, 16836] 
cosine: [0.885, 0.195, 0.195, 0.186, 0.184]
পুরো argsort-এর সঙ্গে মেলে? True
X @ q 9.40 ms | argpartition 0.30 ms | argsort 1.30 ms
এলোমেলো জোড়ার cosine: গড় 0.0000, sd 0.0510, সর্বোচ্চ 0.252
1/√d = 0.0510
n =    20,000: গুণ-যোগ   7,680,000 প্রতি query | মেমরি 30.7 MB
n = 1,000,000: গুণ-যোগ 384,000,000 প্রতি query | মেমরি 1,536.0 MB

মেমরি: X.nbytes = ২০,০০০ × ৩৮৪ × ৪ = ৩,০৭,২০,০০০ byte ≈ ৩০.৭ MB. একই হিসাবে ১০ লাখ (১০৬) vector: ১০৬ × ৩৮৪ × ৪ = ১৫৩,৬০,০০,০০০ byte ≈ ১.৫৩৬ GB (প্রায় ১.৪৩ GiB), শুধু সংখ্যাগুলো, index-এর বাড়তি কাঠামো ছাড়া। float64 হলে দ্বিগুণ। প্রতিটি প্রশ্নে ৩৮.৪ কোটি গুণ-যোগ।

সঠিকতা আর সময়: argpartition + ছোট sort পুরো argsort-এর হুবহু একই ৫টি দিয়েছে (True), কম সময়ে। মূল খরচ X @ q, যা n-এর সঙ্গে রৈখিকভাবে বাড়ে: ১০ লাখে মোটামুটি ৫০ গুণ। প্রথম ফল #১২৩ নিজেই, কারণ q তার কাছ থেকেই বানানো।

উঁচু মাত্রার অদ্ভুততা: এলোমেলো জোড়ার cosine-এর গড় প্রায় ০, sd প্রায় ১/√d ≈ ০.০৫১: ৩৮৪ মাত্রায় এলোমেলো দুটি দিক প্রায় সমকোণে (পর্ব ৩৩-এর curse of dimensionality). আসল embedding এমন সমানভাবে ছড়ানো নয়, গুচ্ছ বাঁধে; ANN-এর জন্য এই পার্থক্য বিরাট।

৭ · Approximate nearest neighbour — HNSW, IVF আর vector database

প্রতি প্রশ্নে ১০ লাখ dot product অনেক কাজে চলে যায়, কিন্তু কোটি vector আর প্রতি সেকেন্ডে শত প্রশ্নে brute-force search ধীর আর দামি। Approximate nearest neighbour (ANN) পদ্ধতি একটি সৎ লেনদেন করে: সব vector না দেখে “প্রায় সবচেয়ে কাছের”গুলো খোঁজে। মূল্য দিতে হয় recall-এ: প্রকৃত সেরা k-এর কিছু হারাতে পারে। দুটি প্রধান ধারণা:

🕸 HNSW: প্রতিবেশীর graph

প্রতিটি vector একটি graph-এর node, কাছের কয়েকটির (M) সঙ্গে যুক্ত। ওপরের স্তরে অল্প node আর লম্বা লাফ, নিচে সব node আর ছোট লাফ। খোঁজা শুরু হয় ওপরে, প্রতিটি ধাপে প্রশ্নের দিকে সবচেয়ে কাছের প্রতিবেশীতে হেঁটে। efSearch বড় হলে বেশি প্রার্থী দেখে: recall বাড়ে, গতি কমে।

Malkov ও Yashunin, ২০১৬ থেকে

🗂 IVF: দলে ভাগ

আগে পর্ব ৬৫-এর k-means দিয়ে vector-গুলোকে nlist-টি দলে ভাগ; প্রতিটি দলের centroid আর তালিকা রাখা। খোঁজার সময় প্রশ্নের সবচেয়ে কাছের nprobe-টি centroid-এর দলেই শুধু brute-force. প্রকৃত প্রতিবেশী পাশের দলে থাকলে হারায়।

inverted file index

দুটিকে বলে HNSW আর IVF index. বড় মাপে vector-গুলো product quantization-এর মতো কৌশলে চাপা দেওয়া হয়, যাতে মেমরি কমে, কিছু নির্ভুলতার বিনিময়ে। ডেস্কটপে FAISS (Meta-র গবেষকদের খোলা library) দিয়ে তিনটি index-এর তুলনা: সত্যিকারের নির্ভুল উত্তর (IndexFlatIP, অর্থাৎ brute-force inner product) থেকে recall@10 মাপা, আর প্রতি প্রশ্নের গড় সময়। ১ লাখ ৩৮৪-মাত্রার vector, ২০০টি প্রশ্ন, এক thread, দুই রকম ডেটা: ২০০টি গুচ্ছে জড়ো হওয়া (আসল embedding-এর কাছাকাছি একটি অনুমান) আর একেবারে এলোমেলো।

faiss.omp_set_num_threads(1)                      # সব index এক thread-এ, তুলনা ন্যায্য
faiss.normalize_L2(X); faiss.normalize_L2(Q)      # এখন inner product = cosine
flat = faiss.IndexFlatIP(d); flat.add(X)          # নির্ভুল উত্তর
hnsw = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT); hnsw.add(X)
hnsw.hnsw.efSearch = 64
ivf = faiss.IndexIVFFlat(faiss.IndexFlatIP(d), d, 316, faiss.METRIC_INNER_PRODUCT)
ivf.train(X); ivf.add(X)                          # train = k-means, 316টি দল
ivf.nprobe = 8
~/bazarghar-ai$ python ann_bench.py        # 200টি গুচ্ছে জড়ো হওয়া vector
Python 3.14.4, numpy 2.5.4, faiss 1.15.1
exact  IndexFlatIP          recall@10 1.000  8.077 ms/query
HNSW build (M=32): 8.4 s
HNSW   efSearch=16           recall@10 0.916  0.063 ms/query
HNSW   efSearch=64           recall@10 0.982  0.110 ms/query
HNSW   efSearch=256          recall@10 0.989  0.247 ms/query
IVF build (nlist=316, k-means): 0.8 s
IVF    nprobe=1              recall@10 0.863  0.046 ms/query
IVF    nprobe=8              recall@10 1.000  0.214 ms/query
IVF    nprobe=32             recall@10 1.000  0.849 ms/query
memory: X 153.6 MB (n*d*4)

~/bazarghar-ai$ python ann_random.py       # একই কোড, গুচ্ছহীন এলোমেলো vector
Python 3.14.4, numpy 2.5.4, faiss 1.15.1
exact  IndexFlatIP          recall@10 1.000  8.226 ms/query
HNSW build (M=32): 35.5 s
HNSW   efSearch=16           recall@10 0.056  0.139 ms/query
HNSW   efSearch=64           recall@10 0.144  0.479 ms/query
HNSW   efSearch=256          recall@10 0.394  1.710 ms/query
IVF build (nlist=316, k-means): 0.8 s
IVF    nprobe=1              recall@10 0.019  0.035 ms/query
IVF    nprobe=8              recall@10 0.085  0.228 ms/query
IVF    nprobe=32             recall@10 0.255  0.841 ms/query
memory: X 153.6 MB (n*d*4)

সত্যিকারের run, একবার; সময় যন্ত্রভেদে বদলাবে, অনুপাতই দেখার জিনিস। গুচ্ছবদ্ধ ডেটায় HNSW efSearch=64-এ recall@10 ০.৯৮২, সময় ৮.০৭৭ থেকে ০.১১০ ms, প্রায় ৭০ গুণ দ্রুত; IVF nprobe=8-এ নির্ভুল উত্তরই (১.০০০), কারণ দলগুলো স্পষ্ট আলাদা। এলোমেলো ডেটায় একই সেটিংয়ে HNSW ০.১৪৪, IVF ০.০৮৫. কারণ ব্লক ৪-এ দেখা: এলোমেলো উঁচু মাত্রায় সবাই সবার থেকে প্রায় সমান দূরে, তাই graph বা দল কোনো শর্টকাট দিতে পারে না।

লেনদেন

ANN-এর সেটিং ডেটা ছাড়া বাছা যায় না। প্রকাশিত তুলনাগুলো (যেমন ann-benchmarks) মোটামুটি দেখায়, HNSW প্রায়ই উঁচু recall-এ দ্রুত কিন্তু মেমরি বেশি নেয়, IVF (quantization-সহ) কম মেমরিতে চলে; তবে ফল ডেটাভেদে বদলায়। নিজের embedding-এর নমুনায় flat index-এর তুলনায় recall@k আর সময় মেপে সেটিং বাছুন।

কোটি vector রাখা, নতুন রিভিউ যোগ, মোছা, metadata দিয়ে ছাঁকা (“শুধু ঢাকার, গত মাসের”), আর ব্যাকআপ: এসব নিয়ে কাজ করে vector database, বা PostgreSQL-এর pgvector-এর মতো extension. ডেটাবেসের দিক থেকে বিস্তারিত, IVF, HNSW আর product quantization-সহ, আছে DBMS কোর্সের vector database পর্বে। বাজারঘরের কয়েক হাজার রিভিউয়ে অবশ্য ব্লক ৪-এর brute-force-ই যথেষ্ট।

৮ · বাস্তবে চালাতে — hybrid search, version, গোপনীয়তা, bias আর RAG

Hybrid search: দুই খোঁজ একসঙ্গে

অংশ ৫ দেখিয়েছে, embedding কখনো হারায় যেখানে শব্দ মেলানো জেতে: পণ্যের কোড, বিরল নাম, “রিফান্ড”-এর মতো নির্দিষ্ট শব্দ। তাই বাস্তব ব্যবস্থায় প্রায়ই hybrid search: শব্দভিত্তিক তালিকা (TF-IDF বা BM25, ১৯৯০-এর দশকে Stephen Robertson ও সহকর্মীদের Okapi ব্যবস্থা থেকে) আর embedding-এর তালিকা মেলানো হয়। একটি সহজ পদ্ধতি reciprocal rank fusion (Cormack, Clarke আর Büttcher, ২০০৯): প্রতিটি নথির score = ∑ 1/(60 + তালিকায় তার অবস্থান). শুধু অবস্থান লাগে বলে score-এর আলাদা মাপ (TF-IDF ০–১, e5 ০.৭–১) সমস্যা নয়। এটি এখানে চালানো হয়নি।

একই model, একই version

দুটি ভিন্ন model-এর vector একই জগতে থাকে না, মাত্রা সমান হলেও। LSA-তেও তাই: background বদলে SVD আবার চালালে P বদলায়, পুরোনো Dk অচল। এর নাম embedding drift. নিয়মগুলো:

  • Index আর প্রশ্নে হুবহু একই model, একই revision, একই উপসর্গ (“passage: ”/“query: ”), একই normalization.
  • প্রতিটি index-এর সঙ্গে model-এর নাম, revision, মাত্রা আর তৈরির তারিখ লিখে রাখা; model বদলালে পুরো index নতুন করে বানানো।
  • নতুন model আনার আগে দুটির recall@k একই প্রশ্ন-তালিকায় তুলনা করা।
  • Library version বদলালেও ফল সামান্য বদলাতে পারে (e5-এর card নিজেই বলে); একটি “পরিচিত বাক্য → পরিচিত vector” পরীক্ষা রাখুন।

গোপনীয়তা: embedding লেখার ছায়া বহন করে

Embedding দেখতে শুধু সংখ্যা, কিন্তু গবেষণা সতর্ক করে: Congzheng Song আর Ananth Raghunathan ২০২০ সালে দেখান যে embedding থেকে মূল লেখার কিছু শব্দ আর বৈশিষ্ট্য আন্দাজ করা যায়, আর John Morris ও সহকর্মীরা ২০২৩ সালে কিছু model-এর embedding থেকে ছোট লেখার অনেকটাই ফিরিয়ে আনার পদ্ধতি দেখান (embedding inversion). কতটা ফেরানো যায় তা model আর লেখাভেদে বদলায়, আর এই model-এ বা বাংলায় আমরা তা মাপিনি। তবু নিরাপদ অনুমান: ব্যক্তিগত তথ্যসহ রিভিউয়ের embedding-কে মূল লেখার মতোই গোপন ধরুন, আর সম্ভব হলে embedding-এর আগেই ব্যক্তিগত তথ্য মুছুন। বাইরের API-তে embedding বানালে লেখাটি সেই সেবার কাছে যায়; এই পর্বে model তাই নিজের যন্ত্রেই চালানো।

Bias: জ্যামিতি সমাজের ছাপও ধরে

পর্ব ৩৩-এর analogy-র আলোচনায় দেখেছেন, word embedding-এ পেশার সঙ্গে লিঙ্গের stereotype পাওয়া গেছে (Bolukbasi ও সহকর্মী, ২০১৬)। Aylin Caliskan ও সহকর্মীরা ২০১৭ সালে (Science) cosine-ভিত্তিক একটি পক্ষপাত-মাপ দিয়ে একই ধরনের ছাপ দেখান। Sentence embedding-ও শেখা ডেটার প্রতিফলন, তাই bias থাকতে পারে; বাংলায় তা কতটা, তার মাপ আমাদের কাছে নেই। বাজারঘরের বাস্তব প্রশ্ন: Banglish বা আঞ্চলিক বানানে লেখা অভিযোগ কি কম খুঁজে পাওয়া যায়? অংশ ৫-এর ফল বলে সম্ভবত হ্যাঁ, মানে কিছু গ্রাহকের অভিযোগ কম গুরুত্ব পায়। দলভিত্তিক মাপ আর নথিপত্র পর্ব ৭২ (দায়িত্বশীল AI-এর অভ্যাস — bias যাচাই, গোপনীয়তা আর নথিপত্র)-এ।

RAG-এর প্রথম ধাপ

RAG (retrieval-augmented generation) নকশায় প্রশ্নের embedding দিয়ে প্রাসঙ্গিক কয়েকটি নথি খুঁজে প্রশ্নের সঙ্গে ভাষা model-কে দেওয়া হয়। প্রথম অর্ধেক হুবহু এই পর্ব, তাই এখানকার প্রতিটি দুর্বলতা RAG-এ ঢোকে: খোঁজ ভুল নথি আনলে উত্তরও ভুলের ওপর দাঁড়ায়। বাকি অর্ধেক পর্ব ৭০ (LLM কীভাবে কাজ করে — token, পরের শব্দের অনুমান আর সীমা)-এ।

আরও গভীরে: LSA-র SVD বড় sparse matrix-এ পুরোটা না করে randomized বা Lanczos পদ্ধতিতে শুধু প্রথম k-টি দিক বের করা হয় (scikit-learn-এর TruncatedSVD). HNSW-এর মূল প্রবন্ধ Yury Malkov আর Dmitry Yashunin-এর (arXiv ২০১৬, পরে IEEE TPAMI-তে); IVF আর product quantization-এর প্রভাবশালী কাজ Hervé Jégou, Matthijs Douze আর Cordelia Schmid-এর (২০১১), আর FAISS-এর বর্ণনা Johnson, Douze আর Jégou-র ২০১৭ সালের প্রবন্ধে। MRR আর nDCG-এর মতো মাপ TREC-এর মতো মূল্যায়ন-উদ্যোগে দীর্ঘদিন ব্যবহৃত।

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

“বাংলা রিভিউয়ের জন্য semantic search বানিয়ে দাও” বললে AI assistant কয়েক লাইনে কোড দেয়, যা চলে আর ফল দেখায়। দেখতে ভালো ফলের নিচে ভুলগুলো প্রায়ই নীরব:

AI যা প্রায়ই করেকীভাবে ধরবেন
শুধু ইংরেজির জন্য শেখানো model বাছে, বা model-এর নাম বানিয়ে ফেলেHub-এ model-টি আছে কি না, card-এ ভাষার তালিকা আর লাইসেন্স নিজে পড়ুন
e5-এর মতো model-এ “query: ”/“passage: ” উপসর্গ বাদ দেয়Model card-এর ব্যবহারবিধি মিলিয়ে নিন; দুইভাবে recall মেপে দেখুন
Normalise না করে dot product-কে cosine বলেnp.linalg.norm দিয়ে সারির দৈর্ঘ্য ছাপিয়ে দেখুন
“score ০.৮-এর বেশি মানে প্রাসঙ্গিক” ধরনের সীমা বসায়নিজের ডেটায় score-এর পরিসর দেখুন; সীমা label দেওয়া প্রশ্নে বাছুন
দুই-তিনটি ভালো উদাহরণ দেখিয়ে “খোঁজ কাজ করছে” বলেLabel দেওয়া প্রশ্ন-তালিকায় recall@k আর MRR, TF-IDF baseline-সহ
ANN index-এর recall না মেপে সেটিং বসায়Flat index থেকে নির্ভুল উত্তর নিয়ে recall@k আর সময় মাপুন
Index আর প্রশ্নে আলাদা model বা version, বা পুরোনো API (get_sentence_embedding_dimension-এর মতো নাম বদলানো method)Revision পিন, version লিখে রাখা, আর প্রতিটি warning পড়া

নির্দিষ্ট হোন, আর যাচাই করা যায় এমন আউটপুট চান। আসল গ্রাহকের রিভিউ, নাম বা ফোন নম্বর পেস্ট করবেন না; কয়েকটি বানানো উদাহরণই যথেষ্ট।

আমার corpus.csv-তে দুটি কলাম: topic আর text (বাংলা ও কিছু Banglish রিভিউ), আর queries.csv-তে topic আর query. Python 3.14 দিয়ে কোড লেখো যা: ১. sentence-transformers দিয়ে Hugging Face Hub-এর একটি ছোট, খোলা লাইসেন্সের multilingual model চালাবে, CPU-তে, কোনো token বা API key ছাড়া; model-এর নাম, revision আর লাইসেন্স comment-এ লিখবে, আর model card-এ বাংলা আছে কি না আমাকে নিজে যাচাই করতে বলবে। ২. model card যে উপসর্গ বলে (যেমন "query: " / "passage: ") তা মানবে; normalize_embeddings=True. ৩. brute-force খোঁজ: X @ q, তারপর np.argpartition দিয়ে top-k. ৪. একই বিষয়ের রিভিউকে প্রাসঙ্গিক ধরে recall@3 আর MRR, আর একই প্রশ্নে TfidfVectorizer-এর baseline. ৫. প্রতিটি প্রশ্নের score-এর সর্বনিম্ন আর সর্বোচ্চ ছাপাবে; কোনো নির্দিষ্ট score-সীমা অনুমানে বসাবে না। ৬. কোনো warning চাপা দেবে না, আর কোথাও বলবে না যে model লেখার অর্থ বোঝে।

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

পরীক্ষা ও ইন্টারভিউতে:

  • Unit vector-এ cosine আর dot product-এর সম্পর্ক? সমান, কারণ cos θ = a·b / (‖a‖‖b‖) আর দুটি দৈর্ঘ্যই ১.
  • ৫০ লাখ × ৭৬৮ float32 vector-এর মেমরি? ৫ × ১০৬ × ৭৬৮ × ৪ = ১৫.৩৬ × ১০৯ byte ≈ ১৫.৩৬ GB.
  • Brute-force top-k-এর খরচ? প্রতি প্রশ্নে O(n·d) গুণ-যোগ, আর বাছাইয়ে argpartition-এ গড়ে O(n), heap-এ O(n log k).
  • Recall@k আর MRR-এর পার্থক্য? Recall@k: প্রথম k-তে কত ভাগ প্রাসঙ্গিক এল; MRR: প্রথম প্রাসঙ্গিকটি কত আগে এল, 1/r-এর গড়।
  • HNSW-এ efSearch বা IVF-এ nprobe বাড়ালে? বেশি প্রার্থী দেখা হয়: recall বাড়ে, প্রতি প্রশ্নের সময়ও বাড়ে।

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

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

প্র ০১LSA ১৯৯০ সালের, আর এই পর্বের ছোট পরীক্ষায় সে একটি আধুনিক model-এর চেয়ে বেশি recall পেয়েছে। তাহলে কি পুরোনো পদ্ধতিই ভালো?

সংখ্যাটি সত্যি, কিন্তু উপসংহারটি নয়। Deerwester ও সহকর্মীরা ১৯৯০ সালে LSA প্রস্তাব করেছিলেন ঠিক এই সমস্যার জন্য: ব্যবহারকারীর শব্দ আর নথির শব্দ আলাদা হলে খোঁজ ব্যর্থ হয়। তাঁদের সমাধান ছিল সংগ্রহের ভেতরের co-occurrence থেকে “গোপন দিক” শেখা। এর মানে LSA ঠিক ততটাই জানে, যতটা তার সংগ্রহে আছে।

আমাদের পরীক্ষায় তিনটি জিনিস LSA-র পক্ষে কাজ করেছে। এক, background-এর ৩৮টি বাক্য লেখা হয়েছিল প্রশ্নগুলো জেনে, তাই “দাম” আর “মূল্য”, “মাপ” আর “সাইজ” ঠিক যেভাবে দরকার সেভাবে একই বাক্যে বসেছিল। দুই, k = ১০ বাছা হয়েছিল এই ২০টি প্রশ্নের ফল দেখে; k = ৫ বা ৪০-এ সুবিধা অনেকটা মুছে যায়। তিন, প্রশ্ন আর রিভিউ সবই একই ছোট শব্দভান্ডারে লেখা। একটি নতুন প্রশ্ন, যেমন “কাপ ফেটে গেছে”, এলেই LSA ০ পেয়েছে, কারণ “ফেটে” তার জগতে নেই। e5 সংগ্রহটির কিছুই দেখেনি, তবু “ফেটে” আর “ফাটা”-র সম্পর্ক ধরেছে, কারণ বিপুল বহুভাষিক লেখা আর subword tokenizer থেকে শেখা।

এটি মেশিন লার্নিংয়ের পুরোনো শিক্ষারই রূপ: যে মাপ দিয়ে বাছাই করেছেন, সেই মাপে ফল আশাবাদী হয় (পর্ব ৬৬)। ন্যায্য তুলনার জন্য দরকার ছিল আলাদা একটি প্রশ্ন-তালিকা, যা background লেখার পরে অন্য কেউ লিখেছেন, আর k বাছাই আরেকটি তালিকায়। তবু LSA-কে একেবারে বাতিল করাও ভুল। নির্দিষ্ট ক্ষেত্রের ছোট ব্যবস্থায়, যেখানে নিজের প্রচুর লেখা আছে কিন্তু বড় model চালানোর সামর্থ্য বা অনুমতি নেই, LSA সস্তা, ব্যাখ্যাযোগ্য আর ব্রাউজারেই চলে।

মূল উপলব্ধি: একটি ছোট, নিজের বানানো পরীক্ষায় জয় দুর্বল প্রমাণ; ডেটা আর hyperparameter কোথা থেকে এসেছে তা না বললে সংখ্যাটি বিভ্রান্ত করে।

প্র ০২Distributional hypothesis-এর জোর আর দুর্বলতা কী? “ভালো” আর “খারাপ” কেন embedding-এ প্রায়ই কাছাকাছি থাকে?

Zellig Harris ১৯৫০-এর দশকে প্রস্তাব করেন যে শব্দের বিতরণ, অর্থাৎ কোন প্রসঙ্গে সে বসে, তার অর্থ সম্পর্কে অনেক কিছু বলে; Firth-এর “company it keeps” বাক্যটি সেই ধারারই জনপ্রিয় রূপ। এই ধারণার বড় জোর হলো, এর জন্য কোনো অভিধান বা মানুষের label লাগে না: শুধু প্রচুর লেখা। তাই LSA, word2vec থেকে আজকের sentence embedding, সবই কোনো না কোনোভাবে প্রসঙ্গ থেকে শেখে। এই পর্বেই দেখেছেন, “দেরিতে” আর “সময়মতো” কেউ জুড়ে দেয়নি, তবু একই প্রসঙ্গে বসায় কাছাকাছি এসেছে।

দুর্বলতাটি ঠিক সেখানেই। প্রসঙ্গ বলে শব্দটি কী নিয়ে কথা বলে, কিন্তু কোন দিকে বলে তা সবসময় নয়। “মান ভালো” আর “মান খারাপ”, “দাম বেশি” আর “দাম কম”: বিপরীত শব্দ প্রায় একই শব্দের পাশে বসে। ফলে embedding-এ বিপরীত শব্দ প্রায়ই খুব কাছে থাকে, এটি গবেষণায় বহুল আলোচিত একটি সমস্যা। আমাদের e5 run-এ “মাছ পচা ছিল”-এর প্রথম ফল “মাছ একদম তাজা”, আর “দাম সস্তা, খুশি”-র প্রথম “দাম অনেক বেশি”. বিষয় (মাছ, দাম) মিলেছে; মনোভাব উল্টো।

এটি সবসময় দোষ নয়। খোঁজের কাজে, যেমন “মাছ নিয়ে সব রিভিউ”, এই আচরণ ঠিকই আছে। কিন্তু কেউ যদি embedding-এর মিলকে sentiment-এর মিল ধরেন, যেমন “এই positive রিভিউয়ের কাছের রিভিউগুলোও positive”, তবে ভুল হবে। Sentiment-এর জন্য আলাদা label দেওয়া ডেটায় শেখানো classifier (পর্ব ৬৭) দরকার, embedding শুধু তার input হতে পারে। আরও একটি সূক্ষ্ম দিক: Negation (“ভালো না”) অনেক model-এর জন্য কঠিন বলে বিভিন্ন গবেষণায় রিপোর্ট করা হয়েছে; বাংলায় তা কতটা, নিজের ডেটায় মাপা দরকার।

মূল উপলব্ধি: প্রসঙ্গ থেকে শেখা embedding “কী বিষয়ে” ভালো ধরে, “কোন দিকে” প্রায়ই ধরে না; কাজটি কোনটি চায়, তা দেখে embedding ব্যবহার করুন।

প্র ০৩বাজারঘরে ৫ হাজার রিভিউ আছে, বছরে হয়তো ৫০ হাজার হবে। Brute-force, HNSW না vector database — কীভাবে সিদ্ধান্ত নেবেন?

সংখ্যা দিয়ে শুরু করুন। ৫০,০০০ × ৩৮৪ × ৪ byte = প্রায় ৭৭ MB: একটি সাধারণ সার্ভারের মেমরিতে সহজেই আঁটে। প্রতি প্রশ্নে ৫০,০০০ × ৩৮৪ ≈ ১.৯ কোটি গুণ-যোগ; ব্লক ৪-এর ২০,০০০-এর সময়কে আড়াই গুণ করলে মোটামুটি অনুমান পাবেন, যা গ্রাহকসেবা দলের কয়েকজন কর্মীর খোঁজের জন্য যথেষ্ট দ্রুত হওয়ার কথা। এই মাপে brute-force-এর বড় সুবিধা: recall হুবহু ১, কোনো index বানানো বা সেটিং বাছার ঝামেলা নেই, নতুন রিভিউ মানে শুধু matrix-এ একটি সারি যোগ।

HNSW বা IVF তখনই ভাবার বিষয়, যখন n কোটির ঘরে, প্রশ্ন প্রতি সেকেন্ডে অনেক, বা সময়ের সীমা কড়া। Yury Malkov আর Dmitry Yashunin-এর HNSW ২০১৬ সালে প্রকাশের পর থেকে বহু library-তে এসেছে, আর FAISS-এর মতো library IVF ও quantization-এর বহু রূপ দেয়। কিন্তু এই পর্বের FAISS run দেখিয়েছে মূল্যটি: গুচ্ছবদ্ধ ডেটায় recall ০.৯৮, এলোমেলো ডেটায় একই সেটিংয়ে ০.১৪. আসল embedding কোন দিকে, তা মাপতে হয়। এছাড়া HNSW graph মেমরিতে বাড়তি জায়গা নেয়, বানাতে সময় লাগে, আর মোছা-বদলানো জটিল।

Vector database-এর প্রশ্নটি আলাদা: গতি নয়, ব্যবস্থাপনা। রিভিউয়ের সঙ্গে metadata (জেলা, তারিখ, পণ্য), access নিয়ন্ত্রণ, ব্যাকআপ, একাধিক সার্ভিস থেকে ব্যবহার। বাজারঘরের ডেটা যদি ইতিমধ্যে PostgreSQL-এ থাকে, pgvector-এর মতো extension একই জায়গায় vector রাখতে দেয়; DBMS কোর্সের vector database পর্বে এই লেনদেন বিস্তারিত। নতুন একটি আলাদা ডেটাবেস মানে নতুন চালানোর খরচ আর নতুন ঝুঁকি।

মূল উপলব্ধি: আগে n·d·৪ byte আর n·d গুণ-যোগ হিসাব করুন; ছোট মাপে brute-force সবচেয়ে সৎ আর সরল, ANN বা নতুন ডেটাবেস আনুন মাপা প্রয়োজনে, ফ্যাশনে নয়।

প্র ০৪বহুভাষিক model-এর card-এ “bn” লেখা থাকলেই কি বাংলা ও Banglish খোঁজের জন্য তা ভরসাযোগ্য?

না। “bn” মানে শুধু এই যে training ডেটায় বাংলা ছিল; কতটা ছিল, কোন ধরনের লেখা ছিল, আর বাংলায় খোঁজের মান কেমন, তা আলাদা প্রশ্ন। বহুভাষিক model নিয়ে গবেষণায় প্রায়ই আলোচিত হয় যে কম-সম্পদের ভাষার (যে ভাষার ডিজিটাল লেখা ইংরেজির তুলনায় কম) ভাগ training-এ ছোট, আর অনেক benchmark-এ সেই ভাষার মূল্যায়নও কম। বাংলার বক্তা বিপুল হলেও লেখা-ডেটার হিসাবে তার অবস্থান প্রায়ই এভাবে বর্ণনা করা হয়; তবে নির্দিষ্ট model-এ ঠিক কত ভাগ, তা আমরা জানি না, আর অনুমানে সংখ্যা বসানো উচিত নয়।

আমাদের ছোট run কিছু সংকেত দেয়, প্রমাণ নয়। বাংলা হরফের প্রশ্নে e5 বেশ কিছু paraphrase ধরেছে (“ফেটে”/“ফাটা”, “টাকা ফেরত”), আবার কিছু হারিয়েছে (“সময়মতো পৌঁছায়নি” ১৪ নম্বরে)। Banglish-এ ছবিটি দুর্বলতর: “delivery onek deri hoise”-এর তিনটি ফলই ভুল, আর মনে হয় model রোমান হরফের মিলকে অর্থের চেয়ে বেশি গুরুত্ব দিয়েছে। Card-এ Banglish-এর উল্লেখ নেই, তাই এটি অবাক করার মতো নয়। অথচ বাস্তবে বাংলাদেশের অনলাইন লেখার একটি বড় অংশ রোমান হরফে বা মিশ্র বলে সাধারণভাবে মনে করা হয়।

সমাধান তাই যাচাই: নিজের ডেটা থেকে, বা তার মতো করে, বাংলা, Banglish আর মিশ্র প্রশ্নের একটি label দেওয়া তালিকা বানানো, আর প্রতিটি দলের recall@k আলাদা করে মাপা। একটি দলে মান খুব কম হলে উপায়: লিপি এক করা (transliteration, যা নিজেই কঠিন), hybrid search, বা নিজের ডেটায় model-কে আরও শেখানো (fine-tuning), যার ধারণা পর্ব ৭১-এ। আর ফলটি নথিতে লিখে রাখা, যাতে পরের কেউ ধরে না নেন যে “bn” মানেই সব ঠিক।

মূল উপলব্ধি: Model card-এর ভাষার তালিকা একটি দাবি, মাপ নয়; নিজের ভাষা, লিপি আর ডেটার ধরন অনুযায়ী আলাদা করে recall মাপুন।

প্র ০৫Embedding কি “শুধু সংখ্যা”, তাই গোপনীয়তার ঝুঁকিমুক্ত? রিভিউয়ের embedding বাইরে শেয়ার করা কতটা নিরাপদ?

সংখ্যার তালিকা দেখে মনে হয় মূল লেখা হারিয়ে গেছে। কিন্তু embedding-এর পুরো উদ্দেশ্যই হলো লেখার অর্থ যতটা সম্ভব ধরে রাখা, তাই তথ্য সেখানে থাকবে, এটাই স্বাভাবিক। প্রশ্ন শুধু কতটা ফেরানো যায়। Congzheng Song আর Ananth Raghunathan ২০২০ সালে দেখান যে sentence embedding থেকে মূল লেখার কিছু শব্দ আর লেখকের কিছু বৈশিষ্ট্য আন্দাজ করা সম্ভব। John Morris ও সহকর্মীরা ২০২৩ সালে একটি পদ্ধতি দেখান যা কিছু model-এর embedding থেকে বারবার অনুমান আর সংশোধন করে ছোট লেখার বড় অংশ, কখনো হুবহু, ফিরিয়ে আনে, আর তাঁরা নাম-পরিচয়ের মতো সংবেদনশীল তথ্যও ফেরানোর উদাহরণ দেন।

এই ফলগুলোর সীমা আছে: নির্দিষ্ট model, মূলত ইংরেজি লেখা, আর আক্রমণকারীর কাছে একই বা কাছাকাছি model থাকার অনুমান। বাংলা রিভিউ আর e5-small-এ ঝুঁকি কতটা, তা আমরা মাপিনি। কিন্তু নিরাপত্তার নকশায় অনিশ্চয়তার উত্তর হলো সতর্কতা। বাজারঘরের রিভিউয়ে প্রায়ই নাম, ফোন নম্বর, ঠিকানার অংশ থাকে (“রাইডার ভাই ফোন করে ঠিকানা জেনে নিলেন”)। এমন লেখার embedding-কে মূল লেখার সমান সংবেদনশীল ধরুন।

বাস্তব পদক্ষেপ: embedding বানানোর আগেই ফোন নম্বর আর নামের মতো তথ্য মুছে বা ঢেকে ফেলা; vector database-এ মূল টেবিলের মতোই access নিয়ন্ত্রণ; বাইরের embedding API-তে লেখা পাঠালে সেই সেবার ডেটা-শর্ত পড়া, কারণ লেখাটি তাদের কাছে যায়; আর কোনো গ্রাহক তথ্য মুছতে বললে তাঁর রিভিউয়ের vector-ও index থেকে মোছা। এই অভ্যাসগুলো সাইবার নিরাপত্তা কোর্সের privacy engineering পর্বের সাধারণ নীতির সঙ্গে মেলে, আর পর্ব ৭২-এ বাজারঘরের model-এর জন্য বিস্তারিত হবে।

মূল উপলব্ধি: Embedding লেখার একটি ঘন সারাংশ, ছদ্মবেশ নয়; যে লেখা গোপন, তার embedding-ও গোপন ধরুন।

অনুশীলন

হাতে হিসাব, কোড, নকশার সমালোচনা আর আউটপুট অনুমান; প্রতিটির উত্তর নিচে লুকানো আছে।

  1. হাতে cosine top-k, recall আর মেমরি (হিসাব): চারটি normalise করা ২-মাত্রার রিভিউ-vector: r1 = (০.৬, ০.৮), r2 = (১, ০), r3 = (০, ১), r4 = (০.৮, ০.৬). প্রশ্ন q = (০.৮, ০.৬)-এর জন্য (ক) চারটি cosine আর top-2 বের করুন; (খ) প্রাসঙ্গিক রিভিউ {r1, r3} হলে recall@2 আর reciprocal rank কত; (গ) ৮০ লাখ রিভিউয়ের ৩৮৪-মাত্রার float32 embedding-এর মেমরি কত?
    • সবাই unit vector, তাই cosine = dot. r1: ০.৮ × ০.৬ + ০.৬ × ০.৮ = ০.৯৬; r2: ০.৮; r3: ০.৬; r4: ০.৬৪ + ০.৩৬ = ১.০০.
    • ক্রম: r4 (১.০০), r1 (০.৯৬), r2 (০.৮০), r3 (০.৬০)। Top-2 = r4, r1.
    • প্রথম দুটিতে প্রাসঙ্গিক শুধু r1: recall@2 = ১/২ = ০.৫. প্রথম প্রাসঙ্গিক দুই নম্বরে: RR = ০.৫.
    • মেমরি: ৮ × ১০৬ × ৩৮৪ × ৪ = ১২,২৮৮ × ১০৬ byte ≈ ১২.২৯ GB (প্রায় ১১.৪৪ GiB). প্রতি প্রশ্নে ৩০৭.২ কোটি গুণ-যোগ।

    খেয়াল করুন, প্রশ্নটি হুবহু r4-এর মতো, তাই cosine ১. বাস্তবে এমন হুবহু মিল প্রায়ই একই রিভিউয়ের নকল, যা খোঁজের তালিকায় আলাদা করে সামলাতে হয়।

  2. Heap দিয়ে top-k (কোড): Python-এর heapq দিয়ে top_k(scores, k) লিখুন, যা একটি তালিকার score একে একে পড়ে আকারে k-এর min-heap রাখে আর শেষে (score, index) জোড়া বড় থেকে ছোট ফেরত দেয়। [0.035, 0.748, -0.127, 0.723, 0.540, 0.924, 0.632] আর k = ৩ দিয়ে চালান, আর sorted-এর সঙ্গে মিলিয়ে দেখুন।
    import heapq
    
    def top_k(scores, k):
        h = []                                  # (score, index), মাথায় সবচেয়ে ছোট
        for i, s in enumerate(scores):
            if len(h) < k:
                heapq.heappush(h, (s, i))
            elif s > h[0][0]:
                heapq.heapreplace(h, (s, i))    # মাথা সরিয়ে নতুনটি, O(log k)
        return sorted(h, reverse=True)
    
    sc = [0.035, 0.748, -0.127, 0.723, 0.540, 0.924, 0.632]
    print(top_k(sc, 3))
    print(sorted(((s, i) for i, s in enumerate(sc)), reverse=True)[:3])

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4):

    [(0.924, 5), (0.748, 1), (0.723, 3)]
    [(0.924, 5), (0.748, 1), (0.723, 3)]

    এগুলো heap stepper-এর একই সাতটি score, তালিকার ক্রমে; index এখানে তালিকার অবস্থান (০ থেকে), রিভিউয়ের নম্বর নয়। সমান score থাকলে tuple-এর দ্বিতীয় উপাদান (index) দিয়ে তুলনা হয়, তাই কোনো error হয় না। heapq.nlargest(3, …) একই কাজের তৈরি function.

  3. নকশার সমালোচনা: একজন সহকর্মী লিখেছেন: “একটি ইংরেজি sentence model দিয়ে সব রিভিউয়ের embedding বানিয়েছি। গত সপ্তাহে আরও নতুন একটি model পেয়ে প্রশ্নগুলো সেটি দিয়ে encode করছি, index আবার বানাইনি, কারণ দুটিরই মাপ ৩৮৪. তিনটি প্রশ্নে ফল খুব ভালো লেগেছে। score ০.৮-এর বেশি হলে ‘মিলেছে’ দেখাচ্ছি। গ্রাহকের নাম-ফোনসহ রিভিউ একটি বিনা-মূল্যের অনলাইন embedding সেবায় পাঠাই।” কী কী ভুল, আর কীভাবে সাজাবেন?
    • ইংরেজি model বাংলায়: card-এ বাংলা না থাকলে বাংলা লেখার vector প্রায় অর্থহীন হতে পারে। বহুভাষিক model, card-এ ভাষা যাচাই, আর নিজের ডেটায় মাপ।
    • দুই model মেশানো: মাপ সমান হলেও দুই model-এর vector-এর জগৎ আলাদা; এটি embedding drift-এর সবচেয়ে খারাপ রূপ। Model বদলালে পুরো index নতুন করে, revision লিখে।
    • তিনটি প্রশ্ন: প্রমাণ নয়। Label দেওয়া প্রশ্ন-তালিকায় recall@k আর MRR, TF-IDF baseline-সহ।
    • স্থির ০.৮ সীমা: e5-এর মতো model-এ প্রায় সব score ০.৭–১-এর মধ্যে, তাই ০.৮ প্রায় সবকিছুকে “মিলেছে” বলবে; অন্য model-এ হয়তো কিছুই না। সীমা ঠিক করতে হবে label দেওয়া ডেটায়।
    • ব্যক্তিগত তথ্য বাইরে: নাম-ফোন সেবাটির কাছে যায়, আর embedding থেকেও তথ্য ফেরানোর ঝুঁকি আছে। আগে ব্যক্তিগত তথ্য মোছা, নিজের যন্ত্রে খোলা model চালানো, বা সেবার ডেটা-শর্ত পড়া।

    নতুন নকশা: একটি ছোট খোলা multilingual model নিজের সার্ভারে, revision পিন; রিভিউ থেকে ব্যক্তিগত তথ্য মুছে embedding; brute-force NumPy খোঁজ (এই মাপে যথেষ্ট); বাংলা ও Banglish আলাদা প্রশ্ন-তালিকায় recall@k; সীমার বদলে top-k দেখানো, score পাশে রেখে।

  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে?
    import numpy as np
    s = np.array([0.2, 0.9, 0.4, 0.9, 0.1, 0.7], dtype=np.float32)
    top = np.argpartition(-s, 3)[:3]
    print(sorted(top.tolist()))
    print(top[np.argsort(-s[top], kind="stable")].tolist() == [1, 3, 5] or
          top[np.argsort(-s[top], kind="stable")].tolist() == [3, 1, 5])
    X = np.ones((1000, 384), dtype=np.float32)
    print(X.nbytes, X.astype(np.float64).nbytes)

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, NumPy 2.5.4):

    [1, 3, 5]
    True
    1536000 3072000

    প্রথম লাইন: সবচেয়ে বড় তিনটি score ০.৯, ০.৯, ০.৭, তাদের index ১, ৩, ৫. argpartition এই তিনটিকে প্রথম তিন ঘরে আনে, কিন্তু ভেতরের ক্রমের নিশ্চয়তা দেয় না, তাই sorted দিয়ে ছাপা। দ্বিতীয় লাইন: শুধু তিনটিকে সাজালে ০.৭ সবসময় শেষে; দুটি ০.৯-এর কোনটি আগে, তা argpartition-এর ফেরত দেওয়া ক্রমের ওপর নির্ভর করে, তাই দুই সম্ভাবনাই পরীক্ষা করা। তৃতীয় লাইন: ১,০০০ × ৩৮৪ × ৪ = ১৫,৩৬,০০০ byte, float64-এ দ্বিগুণ ৩০,৭২,০০০.

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

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

ক্রমবাংলা রিভিউয়ের জন্য embedding-ভিত্তিক খোঁজ বানানো আর যাচাইয়ের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Model card-এ ভাষা, লাইসেন্স আর ব্যবহারবিধি পড়ে model বাছা, revision পিন করা
  2. সব রিভিউয়ের embedding বানানো, normalise করে index-এ রাখা
  3. একই model ও revision দিয়ে প্রশ্নের embedding বানানো
  4. X @ q আর argpartition দিয়ে top-k বের করা
  5. Label দেওয়া প্রশ্ন-তালিকায় recall@k আর MRR মাপা, TF-IDF baseline-সহ
  6. ভুল ফলগুলো পড়ে Banglish বা বিরল শব্দের মতো দুর্বল দল আলাদা করে মাপা

“অর্ডার দেরিতে এসেছে” আর “পার্সেল সময়মতো পৌঁছায়নি” দুটি রিভিউয়ের কোনো শব্দ এক নয়। শব্দভিত্তিক TF-IDF-এ (L2 normalise করা) দুটির cosine similarity কত?

  • ১
  • −১
  • ০
  • vocabulary-র আকারের ওপর নির্ভর করে, প্রায় ০.৫

Latent semantic analysis (LSA)-তে শব্দ ও নথির dense vector কীভাবে পাওয়া যায়?

  • শব্দ-নথির TF-IDF matrix-এ truncated SVD চালিয়ে প্রথম k-টি দিক রাখা
  • প্রতিটি শব্দকে একটি এলোমেলো vector দিয়ে
  • শব্দগুলোর one-hot vector সরাসরি ব্যবহার করে
  • Positive/negative label দিয়ে logistic regression শিখিয়ে

একটি প্রশ্নের প্রাসঙ্গিক নথি {d2, d5, d9}. খোঁজের প্রথম পাঁচটি ফল: d7, d5, d1, d2, d9. recall@3 আর reciprocal rank কত?

  • recall@3 = ০.৬৬৭, RR = ০.৫
  • recall@3 = ০.৩৩৩, RR = ০.৫
  • recall@3 = ০.৩৩৩, RR = ১
  • recall@3 = ১, RR = ০.৫

১০ লাখ রিভিউ, প্রতিটির ৩৮৪-মাত্রার float32 embedding. শুধু vector-গুলো রাখতে কত মেমরি লাগে, আর brute-force-এ প্রতি প্রশ্নে মোটামুটি কতটি গুণ-যোগ?

  • ৩৮৪ MB, ৩৮.৪ কোটি
  • ৩.০৭২ GB, ৭৬.৮ কোটি
  • ১.৫৩৬ GB, ১০ লাখ
  • ১.৫৩৬ GB, ৩৮.৪ কোটি

একটি HNSW index-এ efSearch ১৬ থেকে ২৫৬ করলে সাধারণত কী ঘটে?

  • Index-এর মেমরি অর্ধেক হয়ে যায়
  • Recall বাড়ে আর প্রতি প্রশ্নের সময়ও কমে
  • Recall সাধারণত বাড়ে, প্রতি প্রশ্নের সময়ও বাড়ে
  • Recall সবসময় হুবহু ১ হয়

একটি দল ৬ মাস আগে model A দিয়ে সব রিভিউয়ের index বানিয়েছে। এখন নতুন model B দিয়ে শুধু প্রশ্ন encode করছে; দুটির embedding-এর মাপই ৩৮৪. সমস্যা কী?

  • দুই model-এর vector-এর জগৎ আলাদা, তাই cosine অর্থহীন; index নতুন করে বানাতে হবে
  • কোনো সমস্যা নেই, কারণ মাপ সমান
  • শুধু প্রশ্নের vector আবার normalise করলেই ঠিক হবে
  • শুধু খোঁজ ধীর হবে, ফল একই থাকবে

শব্দকোষ

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

মনে রাখুন

  1. শব্দ বনাম অর্থ: TF-IDF ভাগ করা শব্দ ছাড়া মিল দেখে না; paraphrase-এর cosine হুবহু ০. Embedding অর্থের কাছাকাছি লেখাকে কাছাকাছি দিকে রাখতে চায়।
  2. প্রসঙ্গ থেকে শেখা: একই প্রসঙ্গের শব্দ কাছাকাছি (distributional hypothesis). Truncated SVD (LSA) ছোট সংগ্রহেও এটি দেখায়, কিন্তু গোলমেলে; বিপরীত শব্দও কাছাকাছি আসে।
  3. মাপুন: Label দেওয়া প্রশ্নে recall@k আর MRR, baseline-সহ, আর যে প্রশ্নে বাছাই করেছেন সেই প্রশ্নে জয়কে আশাবাদী ধরুন। e5-small বাংলায় কিছু paraphrase ধরে, Banglish-এ দুর্বল, score ০.৭–১-এর মধ্যে।
  4. যন্ত্র: Normalise করলে cosine = dot; brute-force O(n·d), মেমরি n·d·৪ byte (১০ লাখ × ৩৮৪ ≈ ১.৫৩৬ GB); top-k-এ argpartition বা heap.
  5. বাস্তবে: ANN (HNSW, IVF) recall দিয়ে গতি কেনে, আর তার মান ডেটার গঠনে নির্ভর করে; index আর প্রশ্নে একই model ও revision; embedding-কে গোপন ডেটা ধরুন; bias মাপুন।

পরের পর্বে: এই পর্বের খোঁজ হলো RAG-এর প্রথম অর্ধেক। পর্ব ৭০ (LLM কীভাবে কাজ করে — token, পরের শব্দের অনুমান আর সীমা)-এ দেখবেন বড় ভাষা model লেখাকে কীভাবে token-এ ভাঙে, পরের token কীভাবে অনুমান করে, আর কেন খুঁজে আনা নথি দিলেও তার উত্তর যাচাই করতে হয়।

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

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