এই পর্বে যা শিখবেন
- Embedding কী, কোথা থেকে আসে, আর কেন শব্দ গোনা vector অর্থ বোঝে না
- Cosine, dot product আর Euclidean দূরত্ব হাতে হিসাব করা — আর normalization কেন তিনটিকে মিলিয়ে দেয় (প্রমাণসহ)
- Exact k-NN-এর খরচ, আর IVF, HNSW ও product quantization কীভাবে recall-এর বিনিময়ে গতি কেনে
- Metadata filter (pre বনাম post), hybrid search, reciprocal rank fusion আর RAG-এর পুরো পাইপলাইন
- pgvector, বিশেষায়িত vector DB আর search engine-এর মধ্যে বেছে নেওয়া — খরচ আর ফাঁদসহ
১ · শব্দ মেলানো বনাম অর্থ মেলানো
একটি অনলাইন দোকানের সার্চ বক্সে একজন ক্রেতা লিখলেন “ফোনের চার্জার”. দোকানের ক্যাটালগে পণ্যটির নাম ইংরেজিতে — “Mobile charger 25W”. মানুষ হিসেবে আপনি এক মুহূর্তে বোঝেন, দুটি একই জিনিস। কিন্তু ডেটাবেস কী বোঝে?
পুরান ঢাকার একটি পাইকারি দোকানের অভিজ্ঞ কর্মচারীর কথা ভাবুন। ক্রেতা বললেন “মোবাইলে চার্জ দেওয়ার জিনিস”, কেউ বললেন “charger”, কেউ “চার্জার লাইট”. কর্মচারী নামের বানান মেলান না — তিনি মাথায় একটি “মানচিত্র” রাখেন: কোন তাকে চার্জার, পাশেই কেবল আর পাওয়ার ব্যাংক, দূরে চাল-ডাল। প্রশ্ন শুনে তিনি মানচিত্রের সঠিক জায়গায় যান, তারপর আশপাশের জিনিসগুলো দেখান। Vector database ঠিক এই মানচিত্রটি সংখ্যা দিয়ে বানায়।
এই পর্বের প্রথম ডেটাসেট একটি ছোট ক্যাটালগ — items টেবিল: item_id, title (পণ্যের নাম, কিছু বাংলায়, কিছু ইংরেজিতে), category, আর তিনটি সংখ্যা x, y, z — প্রতিটি পণ্যের একটি ছোট embedding (অংশ ২-এ ব্যাখ্যা)। আপাতত সংখ্যাগুলো বাদ রেখে সাধারণ কৌশলে খুঁজে দেখুন:
-- ক্রেতা লিখেছেন: "ফোনের চার্জার"
SELECT item_id, title FROM items WHERE title LIKE '%চার্জার%';
SELECT item_id, title FROM items WHERE title LIKE '%charger%';
SELECT item_id, title FROM items WHERE title LIKE '%ফোন%';-- ১ম: কোনো সারি নেই -- ২য়: item_id │ title 1 │ Mobile charger 25W -- ৩য়: item_id │ title 2 │ ফোনের কভার
তিনটি চেষ্টা, তিন রকম ব্যর্থতা। ক্রেতার আসল শব্দ “চার্জার” দিয়ে কিছুই আসেনি, কারণ ক্যাটালগে বাংলা বানানটি নেই। ইংরেজি “charger” দিয়ে পাওয়া গেল — কিন্তু ক্রেতা ইংরেজি লেখেননি, অনুবাদ করে দিলেন আপনি নিজে। আর “ফোন” দিয়ে এল ফোনের কভার — শব্দ মিলেছে, চাহিদা মেলেনি। পর্ব ৩০-এর full-text search (inverted index, stemming, BM25) এর চেয়ে অনেক ভালো শব্দ মেলায়, কিন্তু মূল সীমা একই: যে শব্দ লেখাতেই নেই, তা দিয়ে মিল খোঁজা যায় না। ভাষা বদলালে, সমার্থক শব্দ (synonym) এলে, বা ক্রেতা “Banglish”-এ “phoner charger” লিখলে keyword search অসহায়।
এই সমস্যার নাম semantic search — অর্থ দিয়ে খোঁজা। এর ভিত্তি তিনটি ধারণা: (১) প্রতিটি লেখা, ছবি বা পণ্যকে একটি সংখ্যার তালিকায় রূপান্তর, যার নাম embedding; (২) দুটি তালিকা কতটা কাছাকাছি, তা মাপার সূত্র; (৩) কোটি তালিকার মধ্যে দ্রুত কাছেরগুলো খুঁজে বের করার index. যে ডেটাবেস এই তিনটি কাজ ভালোভাবে করে, সেটিই vector database.
২ · Embedding — অর্থকে সংখ্যার তালিকায় বসানো
গণিতে vector মানে নির্দিষ্ট ক্রমে সাজানো কয়েকটি সংখ্যা, যেমন [0.70, 0.70, 0.10]. সংখ্যার সংখ্যাকে বলে dimension (মাত্রা)। দুই মাত্রার vector কাগজে একটি বিন্দু, তিন মাত্রার vector ঘরের ভেতরে একটি বিন্দু। বাস্তব embedding-এর মাত্রা সাধারণত কয়েকশো থেকে কয়েক হাজার — ৩৮৪, ৭৬৮, ১০২৪, ১৫৩৬ ইত্যাদি, মডেলভেদে।
প্রথম চেষ্টা: শব্দ গুনে vector
সবচেয়ে সরল উপায় হলো bag-of-words: সব ডকুমেন্টের প্রতিটি আলাদা শব্দ একটি মাত্রা, আর মান = শব্দটি কতবার আছে। এটি সত্যিই একটি vector, আর এর ওপর cosine হিসাবও করা যায় (অংশ ৩-এ সূত্র)। দেখা যাক “mobile charger” প্রশ্নে এটি কী দেয় — Python-এর শুধু standard library দিয়ে:
import math
from collections import Counter
def bow(text): # bag-of-words: শব্দ → কতবার
return Counter(text.lower().split())
def cosine(a, b):
dot = sum(a[w] * b[w] for w in a)
na = math.sqrt(sum(v * v for v in a.values()))
nb = math.sqrt(sum(v * v for v in b.values()))
return dot / (na * nb)
docs = ["Mobile charger 25W", "phone charger fast", "mobile cover",
"ফোনের চার্জার", "phoner charger", "cotton panjabi"]
q = bow("mobile charger")
print("vocabulary:", sorted({w for d in docs for w in bow(d)}))
for d in sorted(docs, key=lambda d: -cosine(q, bow(d))):
print(f"{cosine(q, bow(d)):.3f} {d}")vocabulary: ['25w', 'charger', 'cotton', 'cover', 'fast', 'mobile', 'panjabi', 'phone', 'phoner', 'চার্জার', 'ফোনের'] 0.816 Mobile charger 25W 0.500 mobile cover 0.500 phoner charger 0.408 phone charger fast 0.000 ফোনের চার্জার 0.000 cotton panjabi
এগারোটি শব্দ, তাই এগারো মাত্রার vector. ফল হতাশাজনক: “ফোনের চার্জার” পেল 0 — ঠিক “cotton panjabi”-র সমান! কারণ একটি শব্দও মেলেনি, আর এই vector-এ “phone” আর “mobile” দুটি সম্পূর্ণ আলাদা মাত্রা, তাদের মধ্যে কোনো সম্পর্ক নেই। “mobile cover” (একটি শব্দ মিলেছে, চাহিদা ভুল) উঠে গেছে “phone charger fast”-এর (অর্থে সঠিক) ওপরে। শব্দ গোনা vector আসলে keyword search-এরই আরেক রূপ। দরকার এমন vector, যেখানে মাত্রাগুলো শব্দ নয়, অর্থের দিক.
শেখা embedding — মডেল যা বানায়
একটি embedding model (একটি নিউরাল নেটওয়ার্ক) বিপুল পরিমাণ লেখা থেকে শেখে কোন শব্দ বা বাক্য কোন প্রসঙ্গে আসে। “phone” আর “mobile” একই রকম বাক্যে আসে, তাই তাদের vector কাছাকাছি হয়। ২০১৩ সালে Google-এর Tomas Mikolov ও সহকর্মীদের word2vec এই চিন্তা জনপ্রিয় করে — বিখ্যাত উদাহরণ: king − man + woman-এর vector পড়ে queen-এর কাছে। পরে BERT (২০১৮)-এর মতো transformer মডেল আর Sentence-BERT (২০১৯)-এর মতো পদ্ধতি পুরো বাক্যের embedding দিতে শুরু করে। বহুভাষিক (multilingual) মডেল একই অর্থের বাংলা আর ইংরেজি বাক্যকে কাছাকাছি বসানোর চেষ্টা করে — তাই “ফোনের চার্জার” আর “mobile charger” একই এলাকায় পড়তে পারে। ছবির জন্যও embedding আছে: OpenAI-এর CLIP (২০২১) ছবি আর লেখাকে একই জায়গায় বসায়, ফলে “লাল শাড়ি” লিখে ছবি খোঁজা যায়।
অর্থের মানচিত্র — দুই মাত্রায় আঁকা একটি নকশা
Similar meaning → nearby points (a 2-D sketch of a high-dimensional space)
এই পর্বের items টেবিলের x, y, z এমনই একটি খেলনা embedding — হাতে বানানো, যাতে হিসাব নিজে মেলানো যায়। বোঝার সুবিধার জন্য মোটামুটি ধরুন x ≈ “ডিভাইস-ভাব”, y ≈ “চার্জ-বিদ্যুৎ-ভাব”, z ≈ “পোশাক-ভাব”, আর মুদির জিনিসের মান ঋণাত্মক। প্রশ্ন “ফোনের চার্জার”-এর embedding ধরা হলো [0.70, 0.68, 0.12].
বাস্তব embedding-এর কোনো একটি মাত্রার সাধারণত মানুষের বোঝার মতো অর্থ থাকে না — অর্থ ছড়িয়ে থাকে সব মাত্রায় একসাথে। আর একই লেখার embedding মডেলভেদে সম্পূর্ণ আলাদা: মডেল A-র vector আর মডেল B-র vector তুলনা করা অর্থহীন, মাত্রা সমান হলেও। ডকুমেন্ট আর প্রশ্ন — দুটির embedding একই মডেল দিয়ে বানাতে হবে।
৩ · মিল মাপার তিন উপায় — cosine, dot product আর Euclidean
দুটি vector u = (u₁, …, u_d) আর v-এর জন্য তিনটি প্রচলিত মাপ:
- Dot product: u·v = u₁v₁ + u₂v₂ + … + u_dv_d. যত বড়, তত মিল। কিন্তু vector-এর দৈর্ঘ্যও এতে ঢুকে যায়।
- Cosine similarity: cos θ = u·v / (‖u‖ ‖v‖), যেখানে ‖u‖ = √(u₁² + … + u_d²) হলো দৈর্ঘ্য। শুধু দিক মাপে: −1 (উল্টো দিক) থেকে 1 (একই দিক)।
- Euclidean distance: ‖u − v‖ = √(Σ(uᵢ − vᵢ)²) — দুই বিন্দুর সোজা দূরত্ব (L2). যত ছোট, তত মিল।
তিনটি কি একই ক্রম দেয়? সবসময় নয়। নিচের ছবিতে দুই মাত্রার তিনটি vector — q = (3, 4), a = (4, 3), b = (6, 8). তারপর ধাপে ধাপে হিসাব:
দিক বনাম দূরত্ব
Cosine looks at direction, Euclidean at position
| কী হিসাব | |
| a-র জন্য | |
| b-র জন্য | |
| কে এগিয়ে |
- তিনটি vector আর তাদের দৈর্ঘ্য। ‖q‖ = √(9 + 16) = 5, ‖a‖ = 5, ‖b‖ = 10. q আর a সমান লম্বা, b দ্বিগুণ — b = 2q.
- q আর a-র cosine. Dot product 24, দুই দৈর্ঘ্যের গুণফল 25, তাই cos = 0.96 — কোণ প্রায় ১৬°, বেশ কাছাকাছি দিক।
- q আর b-র cosine. Dot product 50, দৈর্ঘ্যের গুণফল 50, cos = 1.00 — একই দিক, কোণ শূন্য। cosine দৈর্ঘ্যকে ভাগ করে বাদ দেয়, তাই b “বড়” হওয়ায় কিছু আসে-যায় না।
- শুধু dot product. 50 বনাম 24 — b এগিয়ে, কিন্তু অর্ধেক কারণ b লম্বা। দৈর্ঘ্য যদি “জনপ্রিয়তা” বা “লেখার দৈর্ঘ্য”-র মতো কিছু বহন করে, dot product সেটিকেও মিলের অংশ ধরে। ইচ্ছা করে চাইলে ভালো (কিছু recommendation সিস্টেম তা-ই চায়), না জেনে হলে বিপদ।
- Euclidean দূরত্ব। a মাত্র √2 ≈ 1.414 দূরে, b পুরো 5 দূরে। এবার ক্রম উল্টে গেল — a এগিয়ে। একই তিনটি vector, মাপ বদলালে উত্তর বদলায়।
- Normalize করুন — সব মিলে যায়। প্রতিটি vector-কে নিজের দৈর্ঘ্য দিয়ে ভাগ করলে q̂ = b̂ = (0.6, 0.8), â = (0.8, 0.6). এখন dot product = cosine, আর Euclidean দূরত্ব ‖q̂ − â‖ = √0.08 ≈ 0.283, ‖q̂ − b̂‖ = 0. তিন মাপই বলছে b সেরা।
‖u‖ = ‖v‖ = 1 হলে, ‖u − v‖² = (u − v)·(u − v) = u·u − 2u·v + v·v = 2 − 2 cos θ. অর্থাৎ একক দৈর্ঘ্যের vector-এ Euclidean দূরত্ব cosine-এর একটি কমতে-থাকা (decreasing) ফাংশন, আর dot product নিজেই cosine. তাই normalization-এর পর তিনটি মাপ হুবহু একই ক্রম দেয়। এজন্য অনেক embedding মডেল আগে থেকেই normalize করা vector দেয়, আর তখন সবচেয়ে সস্তা হিসাব — dot product — ব্যবহার করা হয়।
এবার আসল ক্যাটালগে। এই পাতার SQLite-এ sqrt() ফাংশন আছে (SQLite ৩.৩৫ থেকে ঐচ্ছিক math function; পাতার ইঞ্জিনে চালু), তাই cosine সরাসরি SQL-এ লেখা যায়। প্রশ্নের vector একটি এক-সারির CTE q-তে রাখা হলো, আর FROM items, q প্রতিটি পণ্যের পাশে সেটি বসিয়ে দিল (cross join, পর্ব ২০):
-- প্রশ্ন "ফোনের চার্জার"-এর embedding
WITH q(qx, qy, qz) AS (VALUES (0.70, 0.68, 0.12))
SELECT title, category,
round((x*qx + y*qy + z*qz)
/ (sqrt(x*x + y*y + z*z) * sqrt(qx*qx + qy*qy + qz*qz)), 4) AS cosine
FROM items, q
ORDER BY cosine DESC
LIMIT 5;title │ category │ cosine Mobile charger 25W │ Electronics │ 0.9997 USB-C cable │ Electronics │ 0.9831 Power bank 20000mAh │ Electronics │ 0.9804 সোলার ল্যাম্প │ Home │ 0.8636 Bluetooth headphone │ Electronics │ 0.8532
যে প্রশ্নে LIKE শূন্য সারি দিয়েছিল, সেখানে এবার চার্জার সবার ওপরে, তারপর কেবল আর পাওয়ার ব্যাংক — ঠিক যা একজন অভিজ্ঞ কর্মচারী দেখাতেন। ফোনের কভার নেমে গেছে (ফলের বাইরে), যদিও তার নামেই “ফোন” আছে। শাড়ি বা চালের cosine প্রায় 0 বা ঋণাত্মক (সম্পর্কহীন বা উল্টো দিক)। এই ফলে একটি গুরুত্বপূর্ণ জিনিস নেই: কোনো “মিলেছে/মেলেনি” সীমা। vector search সবসময় কিছু দেয় — সবচেয়ে কাছের k-টি — প্রশ্ন অর্থহীন হলেও। প্রয়োজনে একটি ন্যূনতম স্কোর (যেমন cosine ≥ 0.8) নিজে বসাতে হয়।
তিন মাপের পার্থক্য এই ডেটাতেও দেখা যায়, কারণ এখানে vector-গুলো normalize করা নয় (দৈর্ঘ্য 0.65 থেকে 1.0). পাঁচটি ইলেকট্রনিক্স পণ্যের তিন রকম ক্রম, window function দিয়ে (পর্ব ২৬):
WITH q(qx, qy, qz) AS (VALUES (0.70, 0.68, 0.12)),
m AS (
SELECT title, x*qx + y*qy + z*qz AS dot,
(x*qx + y*qy + z*qz) / (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)) AS cos,
sqrt((x-qx)*(x-qx) + (y-qy)*(y-qy) + (z-qz)*(z-qz)) AS l2
FROM items, q WHERE category = 'Electronics')
SELECT title, round(dot, 3) AS dot, round(cos, 4) AS cosine, round(l2, 3) AS l2,
RANK() OVER (ORDER BY dot DESC) AS r_dot,
RANK() OVER (ORDER BY cos DESC) AS r_cos,
RANK() OVER (ORDER BY l2) AS r_l2
FROM m ORDER BY r_cos;title │ dot │ cosine │ l2 │ r_dot │ r_cos │ r_l2 Mobile charger 25W │ 0.978 │ 0.9997 │ 0.028 │ 1 │ 1 │ 1 USB-C cable │ 0.899 │ 0.9831 │ 0.184 │ 3 │ 2 │ 2 Power bank 20000mAh │ 0.964 │ 0.9804 │ 0.197 │ 2 │ 3 │ 3 Bluetooth headphone │ 0.778 │ 0.8532 │ 0.52 │ 4 │ 4 │ 4 ফোনের কভার │ 0.652 │ 0.7983 │ 0.594 │ 5 │ 5 │ 5
দ্বিতীয় আর তৃতীয় স্থানে মতভেদ: dot product-এ Power bank (0.964) এগিয়ে, cosine আর Euclidean-এ USB-C cable. কারণ Power bank-এর vector-এর দৈর্ঘ্য 1.0, কেবলের 0.93 — dot product বড় দৈর্ঘ্যকে পুরস্কার দিয়েছে। কোন মাপ ব্যবহার করবেন, তা ঠিক করে embedding মডেল: মডেলটি যে মাপে প্রশিক্ষিত (সাধারণত তার নথিতে লেখা থাকে), সেটিই নিন। SQLite-এ sqrt() না থাকলে (কিছু build-এ math function বন্ধ থাকে) দূরত্বের বর্গ দিয়ে সাজান — বর্গমূল ক্রম বদলায় না — অথবা আগে normalize করে রাখা vector-এ শুধু dot product.
৪ · Exact k-NN — সবার সাথে মিলিয়ে দেখা, আর কেন তা বড় মাপে চলে না
অংশ ৩-এর কোয়েরি যা করল, তার নাম k-nearest neighbours (k-NN) খোঁজ, আর পদ্ধতিটি brute force: প্রতিটি vector-এর সাথে দূরত্ব হিসাব, তারপর সেরা k-টি রাখা। এর ফল নিখুঁত — একে বলে exact k-NN. খরচ হিসাব করা সহজ:
- n-টি vector, প্রতিটি d মাত্রার। একটি দূরত্বে d-টি গুণ ও যোগ, তাই মোট n·d গুণ-যোগ — O(n·d).
- সেরা k-টি রাখতে k আকারের একটি heap: O(n log k). পুরো সাজানো (ORDER BY) লাগে না।
- প্রতিটি vector অন্তত একবার মেমরি থেকে পড়তে হয়। float32-এ একটি সংখ্যা ৪ byte, তাই মোট ৪·n·d byte.
সংখ্যা বসান: n = ১ কোটি (10⁷) পণ্য-বিবরণ বা ডকুমেন্টের টুকরো, d = ৭৬৮. প্রতি প্রশ্নে 7.68 × 10⁹ গুণ-যোগ, আর 4 × 10⁷ × 768 ≈ ৩০.৭ GB ডেটা পড়া। আধুনিক সার্ভারের মেমরি সেকেন্ডে মোটামুটি কয়েক দশক GB পড়তে পারে, তাই একটি প্রশ্নেই প্রায় এক সেকেন্ড বা তার বেশি — আর সার্চ বক্সে প্রতি সেকেন্ডে শত শত প্রশ্ন আসে। GPU বা sharding (পর্ব ৫৪) সময় কমায়, কাজ নয়।
B-tree বা R-tree কেন সাহায্য করে না
পর্ব ৪০-এর B+ tree একটি মাত্রায় সাজায়; দুই-তিন মাত্রার ভৌগোলিক ডেটায় R-tree বা k-d tree এলাকা ভাগ করে খোঁজে। কিন্তু মাত্রা কয়েকশো হলে এরা প্রায় সব শাখাই খুলতে বাধ্য হয়। এর নাম curse of dimensionality: উচ্চ মাত্রায় এলোমেলো বিন্দুগুলোর পারস্পরিক দূরত্ব প্রায় সমান হয়ে আসে — “সবচেয়ে কাছের” আর “সবচেয়ে দূরের” বিন্দুর পার্থক্য আনুপাতিকভাবে ছোট হয়ে যায় — তাই কোনো এলাকাকে নিশ্চিতভাবে “বাদ” দেওয়া যায় না।
Exact উত্তরের জন্য সবকিছু দেখতে হয়। বড় মাপে গতি পেতে একটি শর্ত মানতে হয়: মাঝেমধ্যে আসল কাছের প্রতিবেশীটি বাদ পড়তে পারে। এটিই approximate nearest neighbour (ANN) — সার্চে এটি সাধারণত গ্রহণযোগ্য, কারণ ১০টি ফলের মধ্যে ৯টি ঠিক হলেও ক্রেতা ভালো ফল পান। কিন্তু ব্যাংকের লেনদেন মেলানোর মতো কাজে “প্রায় ঠিক” চলবে না — সেখানে vector search-ই ভুল টুল।
৫ · Approximate nearest neighbour — IVF, HNSW আর product quantization
IVF — আগে এলাকা বাছাই, তারপর খোঁজ
বড় লাইব্রেরিতে একটি বই খুঁজতে আপনি সব তাক ঘোরেন না — আগে ঠিক করেন বইটি কোন বিভাগে (ইতিহাস, বিজ্ঞান…), তারপর শুধু সেই বিভাগের তাক দেখেন। IVF (inverted file index) একই কাজ করে। তৈরির সময় k-means দিয়ে সব vector-কে L-টি দলে ভাগ করা হয়; প্রতিটি দলের কেন্দ্রবিন্দুকে বলে centroid, আর দলের সদস্যদের আইডির তালিকাকে বলে list (পর্ব ৩০-এর posting list-এর মতো — তাই নাম “inverted file”). খোঁজার সময়: প্রশ্নের সবচেয়ে কাছের nprobe-টি centroid বাছুন, শুধু সেই list-গুলোর vector-এর সাথে দূরত্ব হিসাব করুন।
খরচ: L-টি centroid-এর সাথে তুলনা, তারপর মোটামুটি nprobe × n / L-টি vector (দলগুলো সমান মাপের হলে)। n = ১০ লাখ, L = ১০০০, nprobe = ১০ হলে 1000 + 10 × 1000 = ১১,০০০ দূরত্ব — brute force-এর ১০ লাখের প্রায় ৯০ ভাগের এক ভাগ। দাম: আসল প্রতিবেশী যদি একটি দলের সীমানার ওপারে থাকে, আর সেই দল বাছাই না হয়, সে বাদ পড়বে। SQL-এই দেখুন। আমাদের ১০টি পণ্যকে চারটি list-এ ভাগ করা হয়েছে — নতুন দুটি টেবিল centroids(list_id, x, y, z) আর ivf_lists(item_id, list_id):
-- ধাপ ১: কোন list প্রশ্নের সবচেয়ে কাছে?
WITH q(qx, qy, qz) AS (VALUES (0.70, 0.68, 0.12))
SELECT list_id, round((x*qx + y*qy + z*qz)
/ (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)), 4) AS cosine
FROM centroids, q ORDER BY cosine DESC;
-- ধাপ ২: nprobe = 1 — শুধু list 2-এর ভেতরে top-3
WITH q(qx, qy, qz) AS (VALUES (0.70, 0.68, 0.12))
SELECT i.item_id, i.title, round((x*qx + y*qy + z*qz)
/ (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)), 4) AS cosine
FROM items i JOIN ivf_lists l USING (item_id), q
WHERE l.list_id IN (2)
ORDER BY cosine DESC LIMIT 3;list_id │ cosine 2 │ 0.9367 1 │ 0.9174 3 │ 0.048 4 │ -0.4153 item_id │ title │ cosine 1 │ Mobile charger 25W │ 0.9997 4 │ Power bank 20000mAh │ 0.9804 6 │ সোলার ল্যাম্প │ 0.8636
list 2 (“চার্জ-বিদ্যুৎ” দল: চার্জার, পাওয়ার ব্যাংক, সোলার ল্যাম্প) সবচেয়ে কাছে, তবে list 1 (“ফোন-অ্যাকসেসরি”: কভার, কেবল, হেডফোন) খুব পিছিয়ে নেই — 0.9367 বনাম 0.9174. শুধু list 2 খুঁজে আমরা ১০টির বদলে ৪টি দূরত্ব (centroid) + ৩টি দূরত্ব (সদস্য) হিসাব করেছি। কিন্তু অংশ ৩-এর exact ফল ছিল চার্জার, কেবল, পাওয়ার ব্যাংক; এখানে কেবল (item 3) নেই, তার জায়গায় সোলার ল্যাম্প। কেবলটি list 1-এ পড়েছে — সীমানার ওপারে। এই খোঁজের recall@3 = মিল / k = ২/৩ ≈ ০.৬৭. এবার IN (2) বদলে IN (2, 1) লিখে চালান (nprobe = 2): exact ফল ফিরে আসবে, recall = 1, কাজও বাড়বে।
খেলনা ডেটায় একটি উদাহরণ যথেষ্ট নয়। নিচের Python প্রোগ্রাম ৮ মাত্রার ৪০০০টি vector বানায়, IVF তৈরি করে (৩২টি list, k-means), আর ৫০টি প্রশ্নে recall@10 ও প্রতি প্রশ্নে কতটি দূরত্ব হিসাব হলো তা মাপে:
import random
random.seed(7)
D, N, K, L = 8, 4000, 10, 32 # মাত্রা, ভেক্টর, top-k, list-সংখ্যা
centers = [[random.uniform(-1, 1) for _ in range(D)] for _ in range(40)]
point = lambda: [c + random.gauss(0, 0.25) for c in random.choice(centers)]
data = [point() for _ in range(N)]
d2 = lambda a, b: sum((x - y) ** 2 for x, y in zip(a, b)) # দূরত্বের বর্গ
near = lambda v, cs, n=1: sorted(range(len(cs)), key=lambda j: d2(v, cs[j]))[:n]
cent = random.sample(data, L) # IVF তৈরি: k-means, ৫ বার হালনাগাদ
for _ in range(5):
groups = [[] for _ in range(L)]
for v in data: groups[near(v, cent)[0]].append(v)
cent = [[sum(c) / len(g) for c in zip(*g)] if g else cent[j] for j, g in enumerate(groups)]
inv = [[] for _ in range(L)]
for i, v in enumerate(data): inv[near(v, cent)[0]].append(i)
queries = [point() for _ in range(50)]
truth = [set(near(q, data, K)) for q in queries] # brute force: N দূরত্ব
print(f"brute force recall@10=1.000 distances/query={N}")
for nprobe in (1, 2, 4, 8):
hit = work = 0
for q, t in zip(queries, truth):
cand = [i for j in near(q, cent, nprobe) for i in inv[j]]
work += L + len(cand)
hit += len(t & set(sorted(cand, key=lambda i: d2(q, data[i]))[:K]))
print(f"IVF nprobe={nprobe} recall@10={hit / (K * len(queries)):.3f} distances/query={work // len(queries)}")brute force recall@10=1.000 distances/query=4000 IVF nprobe=1 recall@10=0.918 distances/query=193 IVF nprobe=2 recall@10=0.986 distances/query=330 IVF nprobe=4 recall@10=0.996 distances/query=608 IVF nprobe=8 recall@10=1.000 distances/query=1133
এটিই vector index-এর মূল লেনদেন, সংখ্যায়। nprobe = 1-এ কাজ ২০ ভাগের এক ভাগের কম (193 বনাম 4000), আর আসল top-10-এর ৯১.৮% পাওয়া গেল। nprobe দ্বিগুণ করলে recall দ্রুত বাড়ে, কাজও বাড়ে; nprobe = 8-এ সব ঠিক, তবু কাজ brute force-এর মোটামুটি চার ভাগের এক ভাগ। এখানে ডেটা স্পষ্ট দলে বিভক্ত বলে ফল এত ভালো; বাস্তব embedding-এ দলের সীমানা ঝাপসা, তাই একই recall পেতে সাধারণত বেশি nprobe লাগে। নিয়ম: nprobe (বা অন্য সিস্টেমে তার সমতুল্য) নিজের ডেটায় recall মেপে ঠিক করুন, অনুমানে নয়।
HNSW — স্তরে স্তরে সাজানো পরিচিতির জাল
দ্বিতীয় পরিবারটি গ্রাফভিত্তিক। একটি proximity graph-এ প্রতিটি vector একটি node, আর প্রতিটি node তার কয়েকটি কাছের node-এর সাথে edge দিয়ে যুক্ত। খোঁজ হলো greedy search: কোনো একটি node থেকে শুরু করে বারবার সেই প্রতিবেশীর দিকে যান, যে প্রশ্নের আরও কাছে — যতক্ষণ আর উন্নতি না হয়। ঢাকা থেকে কারও ঠিকানা খুঁজতে আপনি প্রথমে জেলা, তারপর থানা, তারপর মহল্লা ধরে এগোন — বড় লাফ আগে, ছোট পা পরে। HNSW (Hierarchical Navigable Small World; Yury Malkov ও Dmitry Yashunin, ২০১৬ সালে প্রকাশিত) এই চিন্তাকে স্তরে সাজায়: সবচেয়ে নিচের স্তর ০-তে সব node, ওপরের প্রতিটি স্তরে এলোমেলোভাবে বাছা অল্প কিছু node আর লম্বা edge — দ্রুত দূরে যাওয়ার “হাইওয়ে”। খোঁজ শুরু হয় ওপরের স্তরের একটি নির্দিষ্ট entry point থেকে।
তিন স্তরের একটি ছোট HNSW
Layers of a proximity graph; orange arrows = the greedy search path for query Q
Q = (6.5, 5.5). Node-গুলোর অবস্থান: A(1,8), B(2,5), C(4,8), D(3,3), E(5,5), F(6,3), G(4,1), H(8,2), I(9,6), J(7,8), K(6,6). স্তর ২-এ edge শুধু A–H; স্তর ১-এ A–D, A–J, D–H, H–J; স্তর ০-তে ছবির ধূসর edge-গুলো। এবার খোঁজটি ধাপে ধাপে:
| স্তর | |
| এখন যেখানে | |
| সেরা দূরত্ব | |
| মোট দূরত্ব-হিসাব |
- ওপরের স্তরে শুরু। প্রতিটি HNSW-এর একটি নির্দিষ্ট entry point থাকে — এখানে A, সবচেয়ে ওপরের স্তর ২-এ। প্রথম হিসাব: d(A, Q) = 6.04. এখনো পর্যন্ত সেরা A.
- স্তর ২-এ লম্বা লাফ। A-এর একমাত্র প্রতিবেশী H: দূরত্ব 3.81, যা 6.04-এর চেয়ে কম। তাই H-এ সরে যাই। এক লাফে মানচিত্রের অন্য প্রান্তে — ওপরের স্তরের edge লম্বা হওয়াই এর উদ্দেশ্য।
- স্তর ২-এ আর উন্নতি নেই। H-এর প্রতিবেশী কেবল A, যা আরও দূরে (আগের হিসাব মনে রাখা হয়, আবার করতে হয় না)। এই স্তরে এটিই স্থানীয় সেরা, তাই H-কে শুরু ধরে এক স্তর নিচে নামি।
- স্তর ১। এখানে H-এর প্রতিবেশী D (4.30) আর J (2.55). J কাছে, তাই J-এ সরি। মোট হিসাব এখন ৪টি।
- স্তর ১-এ থামা, নিচে নামা। J-এর প্রতিবেশী A আর H — দুটিই আগে দেখা, আর দূরে। J নিয়ে সবচেয়ে নিচের, সব node-এর স্তর ০-তে নামি।
- স্তর ০-এ সূক্ষ্ম খোঁজ। J-এর প্রতিবেশী C (3.54), I (2.55), K (0.71). K অনেক কাছে — K-তে সরি। মোট হিসাব ৭টি।
- থামা — উত্তর K. K-এর প্রতিবেশী E (1.58), F (2.55), I আর J — কেউই 0.71-এর চেয়ে কাছে নয়। খোঁজ শেষ: K, যা সত্যিই সবচেয়ে কাছের node. ১১টি node-এর মধ্যে ৯টি দূরত্ব হিসাব হলো — এত ছোট গ্রাফে লাভ সামান্য, কিন্তু লাখ-কোটি node-এ হিসাবের সংখ্যা মোটামুটি log n-এর হারে বাড়ে বলে পরীক্ষায় দেখা যায়, n-এর হারে নয়।
বাস্তব HNSW শুধু একটি “সেরা” node ধরে রাখে না। স্তর ০-তে সে ef_search আকারের একটি প্রার্থী-তালিকা (beam) রাখে — ধরুন সেরা ৪০টি — আর সেই সবার প্রতিবেশী যাচাই করে। এতে স্থানীয় ফাঁদে (local minimum) আটকে যাওয়ার সম্ভাবনা কমে। ef_search বাড়ালে recall বাড়ে, গতি কমে — IVF-এর nprobe-এর মতোই একটি নব। তৈরির সময় প্রতিটি node সর্বোচ্চ কয়টি প্রতিবেশী রাখবে (প্রচলিত নাম M, প্রায়ই ১৬) আর তৈরির সময়ের beam (ef_construction) — এ দুটিও recall, মেমরি আর তৈরির সময় ঠিক করে। HNSW সাধারণত একই recall-এ IVF-এর চেয়ে দ্রুত উত্তর দেয়, কিন্তু গ্রাফের edge-এর জন্য বাড়তি মেমরি লাগে আর তৈরি করতে সময় বেশি; মোছা ও হালনাগাদও জটিল।
Product quantization — vector ছোট করে রাখা
গতির পর দ্বিতীয় সমস্যা মেমরি। ৭৬৮ মাত্রার একটি float32 vector = ৩০৭২ byte; ১ কোটি vector ≈ ৩০.৭ GB, index ছাড়াই। Product quantization (PQ; Hervé Jégou, Matthijs Douze ও Cordelia Schmid, ২০১১) vector-কে ভাগ করে সংকুচিত করে:
- ৭৬৮ মাত্রাকে m = ৯৬টি টুকরোয় ভাগ করুন, প্রতিটি ৮ মাত্রার।
- প্রতিটি টুকরোর জন্য আলাদা k-means চালিয়ে ২৫৬টি প্রতিনিধি (codebook) বানান।
- প্রতিটি টুকরোর জায়গায় শুধু কাছের প্রতিনিধির নম্বর রাখুন — ০ থেকে ২৫৫, অর্থাৎ ১ byte.
ফল: ৩০৭২ byte থেকে ৯৬ byte — ৩২ গুণ ছোট; ১ কোটি vector এখন প্রায় ০.৯৬ GB. খোঁজার সময় প্রশ্নের প্রতিটি টুকরো থেকে ২৫৬টি প্রতিনিধির দূরত্বের একটি টেবিল একবার বানানো হয় (৯৬ × ২৫৬ সংখ্যা), তারপর প্রতিটি সংকুচিত vector-এর দূরত্ব = ৯৬টি টেবিল-দেখা আর যোগ। দাম আবার নির্ভুলতা: দূরত্ব এখন আনুমানিক। তাই প্রচলিত কৌশল হলো PQ দিয়ে প্রার্থী বাছাই, তারপর সেরা কয়েকশোর আসল vector দিয়ে আবার হিসাব করে সাজানো (re-ranking). IVF আর PQ একসাথে (IVF-PQ) বড় মাপে খুব প্রচলিত; Facebook AI Research-এর open-source লাইব্রেরি FAISS (২০১৭) এগুলোর পরিচিত বাস্তবায়ন। সরল quantization-ও আছে — float32-এর বদলে float16 বা int8 (২ বা ৪ গুণ ছোট)।
| পদ্ধতি | মূল চিন্তা | নব (knob) | শক্তি | দুর্বলতা |
|---|---|---|---|---|
| Brute force | সবার সাথে তুলনা | — | recall = 1, index লাগে না | O(n·d) প্রতি প্রশ্নে |
| IVF | দলে ভাগ, কাছের দলে খোঁজ | lists, nprobe | সরল, কম মেমরি, দ্রুত তৈরি | সীমানার প্রতিবেশী হারায়; ডেটা বদলালে দল পুরোনো |
| HNSW | স্তরের গ্রাফে greedy হাঁটা | M, ef_construction, ef_search | উঁচু recall-এ দ্রুত | বেশি মেমরি, ধীর তৈরি |
| PQ | vector সংকুচিত কোড | m, codebook-এর আকার | মেমরি বহু গুণ কম | দূরত্ব আনুমানিক; re-ranking লাগে |
আরও গভীরে: ANN পদ্ধতিগুলোর তুলনার জন্য ANN-Benchmarks-এর মতো উন্মুক্ত মাপকাঠি recall বনাম প্রতি সেকেন্ডে প্রশ্নের (QPS) বক্ররেখা আঁকে — একটি সংখ্যা নয়, পুরো বক্ররেখাই আসল তথ্য। মেমরিতে না আঁটা ডেটার জন্য Microsoft-এর DiskANN (২০১৯) SSD-তে রাখা গ্রাফ ব্যবহার করে, আর Google-এর ScaNN উন্নত quantization যোগ করে। পুরোনো আরেক ধারা locality-sensitive hashing (LSH; Indyk ও Motwani, ১৯৯৮) এমন hash function বানায়, যেখানে কাছের vector একই bucket-এ পড়ার সম্ভাবনা বেশি — তত্ত্বে সুন্দর গ্যারান্টি, কিন্তু বাস্তব embedding-এ গ্রাফ-পদ্ধতি সাধারণত এগিয়ে থাকে।
৬ · Metadata filter আর hybrid search
“ঈদের উপহার — ইলেকট্রনিক্স থেকে”, “ফোনের চার্জার — স্টকে আছে এমন”, “এই নীতিমালা — শুধু আমার শাখার নথি থেকে”. vector-এর পাশে রাখা সাধারণ কলাম (category, দাম, স্টক, তারিখ, অনুমতি) দিয়ে ছাঁকাকে বলে metadata filtering. এখানে relational ডেটাবেসের সুবিধা স্পষ্ট: কোর্সের db-shop-এর products আর categories-এর পাশে একটি নতুন টেবিল যোগ করা হলো — product_vec(product_id, x, y, z), প্রতিটি পণ্যের একই ধাঁচের খেলনা embedding — আর বাকিটা সাধারণ JOIN আর WHERE.
-- প্রশ্ন "ঈদের উপহার", শুধু Electronics থেকে
WITH q(qx, qy, qz) AS (VALUES (0.40, 0.00, 0.80))
SELECT p.name, c.name AS category, p.price,
round((v.x*qx + v.y*qy + v.z*qz)
/ (sqrt(v.x*v.x + v.y*v.y + v.z*v.z) * sqrt(qx*qx + qy*qy + qz*qz)), 4) AS cosine
FROM products p
JOIN categories c USING (category_id)
JOIN product_vec v USING (product_id), q
WHERE c.name = 'Electronics'
ORDER BY cosine DESC LIMIT 3;name │ category │ price │ cosine Smart Watch │ Electronics │ 3500 │ 0.6706 Headphone │ Electronics │ 1200 │ 0.5359 Mobile Phone │ Electronics │ 15000 │ 0.4708
WHERE লাইনটি মুছে আবার চালান: তখন ফল Panjabi (0.8895), Saree (0.8444), SQL Book (0.6871) — “ঈদের উপহার”-এর অর্থে পোশাকই সবচেয়ে কাছে, যা যুক্তিসংগত। ফিল্টার দিয়ে আমরা বলেছি “অর্থে কাছের, কিন্তু এই শর্ত মেনে”. ফিল্টার স্কোর বদলায় না, প্রার্থী বদলায়।
Pre-filter বনাম post-filter
ছোট টেবিলে ফিল্টার আর সাজানো এক কোয়েরিতেই হয়ে যায়। কিন্তু ANN index থাকলে প্রশ্ন ওঠে — ফিল্টার কখন? দুটি পথ:
- Post-filtering: আগে index থেকে top-k আনুন, তারপর শর্ত না মানা সারি ফেলে দিন। সহজ, কিন্তু ফলে k-এর কম সারি থাকতে পারে — এমনকি শূন্য।
- Pre-filtering: আগে শর্ত মানা সারি বাছুন, তারপর শুধু তাদের মধ্যে কাছেরগুলো খুঁজুন। ফল সঠিক, কিন্তু শর্তে অনেক সারি বাদ পড়লে ANN index (যা সব সারির জন্য বানানো) ঠিকমতো কাজে লাগে না; তখন প্রায়ই বাছাই করা সারিতে brute force-ই দ্রুত।
-- প্রশ্ন "ফোনে চার্জ রাখার জিনিস"; শর্ত: stock > 0
WITH q(qx, qy, qz) AS (VALUES (0.65, 0.75, 0.05)),
s AS (SELECT p.name, p.stock, round((v.x*qx + v.y*qy + v.z*qz)
/ (sqrt(v.x*v.x+v.y*v.y+v.z*v.z) * sqrt(qx*qx+qy*qy+qz*qz)), 4) AS cosine
FROM products p JOIN product_vec v USING (product_id), q)
SELECT * FROM (SELECT * FROM s ORDER BY cosine DESC LIMIT 3) -- post-filter
WHERE stock > 0;
WITH q(qx, qy, qz) AS (VALUES (0.65, 0.75, 0.05)),
s AS (SELECT p.name, p.stock, round((v.x*qx + v.y*qy + v.z*qz)
/ (sqrt(v.x*v.x+v.y*v.y+v.z*v.z) * sqrt(qx*qx+qy*qy+qz*qz)), 4) AS cosine
FROM products p JOIN product_vec v USING (product_id), q)
SELECT * FROM s WHERE stock > 0 ORDER BY cosine DESC LIMIT 3; -- pre-filter-- post-filter name │ stock │ cosine Smart Watch │ 5 │ 0.8638 Mobile Phone │ 12 │ 0.8525 -- pre-filter name │ stock │ cosine Smart Watch │ 5 │ 0.8638 Mobile Phone │ 12 │ 0.8525 Headphone │ 40 │ 0.7702
অর্থে সবচেয়ে কাছের পণ্যটি Power Bank (cosine প্রায় 0.996), কিন্তু তার stock 0. Post-filter-এ index top-3 দিল Power Bank, Smart Watch, Mobile Phone — তারপর Power Bank বাদ, ক্রেতা পেলেন মাত্র দুটি ফল, যদিও Headphone স্টকে ছিল। শর্ত যত কড়া (যেমন “শুধু এই শাখার নথি”, যা মোটের ১%), post-filter তত বেশি খালি হাতে ফেরে। প্রচলিত সমাধান: post-filter-এ k-এর চেয়ে অনেক বেশি (যেমন 10k) প্রার্থী আনা, অথবা এমন index যা খোঁজার সময়ই ফিল্টার মানে — অনেক vector DB আর pgvector-এর নতুন সংস্করণে এর ব্যবস্থা আছে (অংশ ৮)। পর্ব ৪৩-এর selectivity-র ধারণাই এখানে সিদ্ধান্ত নেয়: শর্ত কয়টি সারি রাখে?
Hybrid search — keyword আর vector একসাথে
অর্থ ধরা vector search-এর শক্তি, কিন্তু হুবহু টোকেন ধরা তার দুর্বলতা। মডেল নম্বর (“25W”), পণ্যের কোড, মানুষের নাম, NID বা অর্ডার নম্বর — embedding-এ এদের সূক্ষ্ম পার্থক্য প্রায়ই হারিয়ে যায়। Keyword search (পর্ব ৩০-এর BM25) ঠিক এখানেই শক্ত। দুটি মেলানোকে বলে hybrid search. সবচেয়ে সরল রূপ: দুই স্কোরের ওজনযুক্ত যোগফল। প্রশ্ন “25W charger”:
-- প্রশ্ন "25W charger": vector + keyword flag
WITH q(qx, qy, qz) AS (VALUES (0.62, 0.78, 0.04)),
s AS (SELECT title,
(x*qx + y*qy + z*qz) / (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)) AS cos,
(lower(title) LIKE '%25w%') AS kw
FROM items, q)
SELECT title, round(cos, 4) AS cosine, kw,
round(0.7 * cos + 0.3 * kw, 4) AS hybrid
FROM s
ORDER BY hybrid DESC LIMIT 4;title │ cosine │ kw │ hybrid Mobile charger 25W │ 0.9917 │ 1 │ 0.9942 Power bank 20000mAh │ 0.9988 │ 0 │ 0.6992 USB-C cable │ 0.9639 │ 0 │ 0.6748 সোলার ল্যাম্প │ 0.9293 │ 0 │ 0.6505
শুধু vector-এ Power bank (0.9988) চার্জারের (0.9917) চেয়ে এগিয়ে — অর্থে দুটিই “চার্জ দেওয়ার জিনিস”, আর “25W” সংখ্যাটির কথা মডেল প্রায় ভুলে গেছে। SQLite-এ তুলনার ফল 1 বা 0, তাই kw একটি flag. keyword মেলায় চার্জার সবার ওপরে উঠে এল। কিন্তু ওজন 0.7/0.3 কোথা থেকে? অনুমান। আর cosine (−1 থেকে 1) আর BM25 (০ থেকে যেকোনো বড় সংখ্যা) একই মাপের নয় — সরাসরি যোগ করলে একটি অন্যটিকে ডুবিয়ে দেয়।
এই মাপের সমস্যা এড়ানোর জনপ্রিয় উপায় reciprocal rank fusion (RRF; Gordon Cormack, Charles Clarke ও Stefan Büttcher, ২০০৯)। স্কোর ভুলে শুধু ক্রম নিন: প্রতিটি তালিকায় ডকুমেন্টের অবস্থান r হলে অবদান 1 / (k + r), আর সব তালিকার অবদান যোগ করুন; k সাধারণত ৬০. যে তালিকায় ডকুমেন্টটি নেই, সেখান থেকে অবদান ০. প্রশ্ন “USB-C charger”:
WITH q(qx, qy, qz) AS (VALUES (0.66, 0.74, 0.05)),
vec AS (SELECT item_id, ROW_NUMBER() OVER (ORDER BY (x*qx + y*qy + z*qz)
/ (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)) DESC) AS r FROM items, q),
kw AS (SELECT item_id, ROW_NUMBER() OVER (ORDER BY hits DESC, item_id) AS r
FROM (SELECT item_id, (instr(lower(title), 'usb-c') > 0)
+ (instr(lower(title), 'charger') > 0) AS hits FROM items)
WHERE hits > 0)
SELECT i.title, v.r AS vec_rank, k.r AS kw_rank,
round(1.0/(60 + v.r) + coalesce(1.0/(60 + k.r), 0), 5) AS rrf
FROM items i JOIN vec v USING (item_id) LEFT JOIN kw k USING (item_id)
ORDER BY rrf DESC LIMIT 4;title │ vec_rank │ kw_rank │ rrf Mobile charger 25W │ 1 │ 1 │ 0.03279 USB-C cable │ 3 │ 2 │ 0.032 Power bank 20000mAh │ 2 │ NULL │ 0.01613 সোলার ল্যাম্প │ 4 │ NULL │ 0.01563
দুটি আলাদা ক্রম বানানো হলো — vector ক্রম (ROW_NUMBER, পর্ব ২৬) আর একটি খুব সরল keyword ক্রম (কয়টি শব্দ মেলে; বাস্তবে এখানে BM25 বসবে)। চার্জার দুই তালিকাতেই ১ নম্বরে: 1/61 + 1/61 ≈ 0.03279. USB-C cable vector-এ ৩, keyword-এ ২: 1/63 + 1/62 ≈ 0.032 — তাই সে vector-এর ২ নম্বর Power bank-কে (শুধু 1/62 ≈ 0.01613) টপকে গেল, কারণ ক্রেতা স্পষ্ট “USB-C” লিখেছেন। LEFT JOIN আর coalesce নিশ্চিত করল, যে পণ্য keyword তালিকায় নেই তার অবদান ০, NULL নয় (পর্ব ১৬-এর NULL propagation মনে রাখুন)। RRF-এর সৌন্দর্য: কোনো ওজন বা স্কোরের মাপ মেলাতে হয় না। অনেক search engine আর vector DB এখন hybrid search-এ RRF বা এর রূপভেদ দেয়। আরও ভালো ফলের জন্য প্রায়ই শেষে একটি re-ranking ধাপ যোগ হয় — একটি বড় মডেল প্রশ্ন আর প্রতিটি প্রার্থীকে একসাথে পড়ে নতুন করে সাজায়।
৭ · RAG — নিজের ডেটা দিয়ে LLM-কে উত্তর দেওয়ানো
ChatGPT-এর মতো LLM (large language model) বিপুল লেখা থেকে শিখেছে, কিন্তু আপনার ব্যাংকের সেবা-নির্দেশিকা, আপনার দোকানের ফেরত-নীতি বা গত সপ্তাহের অফিস-আদেশ তার জানা নেই। না জেনে প্রশ্ন পেলে সে প্রায়ই আত্মবিশ্বাসী কিন্তু বানানো উত্তর দেয় — একে বলে hallucination. RAG (retrieval-augmented generation; Patrick Lewis ও সহকর্মীরা, ২০২০) এর একটি সরল জবাব: প্রশ্নের উত্তর দেওয়ার আগে নিজের ডেটা থেকে প্রাসঙ্গিক অংশ খুঁজে এনে LLM-কে সাথে দিন, আর বলুন “শুধু এই লেখা থেকে উত্তর দাও”. খোঁজার অংশটিই vector database-এর কাজ।
একটি ব্যাংকের সেবা-নির্দেশিকার উদাহরণে পুরো পাইপলাইন দেখুন। প্রথম দুই ধাপ আগে একবার (এবং নথি বদলালে আবার) হয় — indexing; বাকি চার ধাপ প্রতিটি প্রশ্নে — query time.
- নথি ভাগ
chunksটুকরো করা - Embed ও জমা
vector DBএকবার - প্রশ্নের embedding
qএকই মডেল - Top-k খোঁজ
k = 2ANN + filter - Prompt তৈরি
contextনির্দেশ + টুকরো - LLM-এর উত্তর
[c1]উৎসসহ
c1: কার্ড হারালে বা চুরি হলে সঙ্গে সঙ্গে হেল্পলাইনে
ফোন করে কার্ডটি ব্লক করুন।
c2: নতুন বা বদলি কার্ডের জন্য শাখায় আবেদন করুন;
ফি সময়ভেদে আলাদা, শাখায় জেনে নিন।
c3: ATM থেকে দৈনিক টাকা তোলার একটি সীমা আছে।
c4: হিসাব খুলতে জাতীয় পরিচয়পত্র আর ছবি লাগে।
chunk_id │ embedding │ source c1 │ [0.85, 0.15, 0.50] │ নির্দেশিকা, পৃ. ৩ c2 │ [0.60, 0.70, 0.20] │ নির্দেশিকা, পৃ. ৩ c3 │ [0.20, 0.90, 0.30] │ নির্দেশিকা, পৃ. ৫ c4 │ [0.10, 0.30, 0.95] │ নির্দেশিকা, পৃ. ১
"কার্ড হারিয়ে গেলে কী করব?"
↓ embedding model
q = [0.80, 0.30, 0.45]
c1 0.986 ← কার্ড ব্লক c2 0.856 ← বদলি কার্ড c4 0.618 c3 0.603
নিচের তথ্য থেকেই উত্তর দাও। তথ্যে না থাকলে বলো "জানি না"। প্রতিটি দাবির পাশে উৎস লেখো। [c1] কার্ড হারালে বা চুরি হলে সঙ্গে সঙ্গে ... [c2] নতুন বা বদলি কার্ডের জন্য শাখায় ... প্রশ্ন: কার্ড হারিয়ে গেলে কী করব?
প্রথমে এখনই হেল্পলাইনে ফোন করে কার্ডটি ব্লক করুন [c1]. তারপর বদলি কার্ডের জন্য শাখায় আবেদন করুন; ফি শাখায় জেনে নিন [c2].
- Chunking — নথি ভাগ। পুরো নির্দেশিকা একটি vector-এ ভরলে সব তথ্য গড় হয়ে ঝাপসা হয়ে যায়, আর LLM-এর প্রসঙ্গের জায়গাও (context window) সীমিত। তাই নথিকে ছোট অংশে ভাগ করা হয় — chunking. প্রচলিত উপায়: অনুচ্ছেদ বা শিরোনাম ধরে, কয়েকশো token-এর টুকরো, পাশের টুকরোর সাথে কিছুটা overlap রেখে। টুকরো খুব ছোট হলে প্রসঙ্গ হারায়, খুব বড় হলে খোঁজ অস্পষ্ট হয়।
- Embed ও জমা। প্রতিটি chunk embedding model দিয়ে vector, তারপর vector DB-তে — সাথে metadata: উৎস, পৃষ্ঠা, তারিখ, কারা দেখতে পারবে। এই টেবিলটি একটি সাধারণ টেবিলের মতোই: chunk_id-তে primary key, source-এ foreign key রাখা যায়। এখানে vector-গুলো খেলনা, ৩ মাত্রার।
- প্রশ্নের embedding. গ্রাহকের প্রশ্নও vector হলো — একই মডেল দিয়ে, নইলে অংশ ২-এর সতর্কবার্তা অনুযায়ী তুলনা অর্থহীন।
- Top-k খোঁজ। cosine হিসাবে c1 (0.986) আর c2 (0.856) সেরা দুটি। প্রশ্নে “ব্লক” শব্দটি নেই, তবু c1 এল। বাস্তবে এখানেই metadata filter (যেমন গ্রাহকের দেশ বা অনুমতি) আর hybrid search বসে।
- Prompt তৈরি। নির্দেশ (“শুধু এই তথ্য থেকে, না থাকলে ‘জানি না’, উৎস লেখো”), খুঁজে পাওয়া chunk আর প্রশ্ন একসাথে একটি prompt. chunk-গুলোর আইডি সাথে দেওয়ায় উত্তরে উৎস উল্লেখ করা সম্ভব হয়।
- উত্তর, উৎসসহ। LLM প্রসঙ্গ থেকে উত্তর লিখল আর [c1], [c2] দেখাল — গ্রাহক বা কর্মী মূল নথিতে যাচাই করতে পারেন। RAG hallucination কমায়, শূন্য করে না: ভুল chunk এলে উত্তরও ভুল হবে, আর LLM কখনো কখনো প্রসঙ্গ ছাড়িয়েও লেখে।
RAG-এর মান মাপা — recall@k
RAG-এর মান দুই অংশে ভাগ করে মাপুন। খোঁজের মান: কিছু প্রশ্নের জন্য মানুষ আগে থেকে চিহ্নিত করে রাখে কোন chunk-গুলো প্রাসঙ্গিক; তারপর recall@k = (top-k-এ আসা প্রাসঙ্গিক chunk) / (মোট প্রাসঙ্গিক chunk). প্রাসঙ্গিক chunk top-k-তে না এলে সবচেয়ে ভালো LLM-ও উত্তর দিতে পারবে না। উত্তরের মান: উত্তর কি প্রসঙ্গ-নির্ভর (প্রতিটি দাবি chunk-এ আছে?), আর প্রশ্নের জবাব দেয় কি? ANN index-এর recall (অংশ ৫, exact k-NN-এর তুলনায়) আর RAG-এর retrieval recall (মানুষের বিচারের তুলনায়) দুটি আলাদা মাপ — দুটিই লাগে।
৮ · কোথায় রাখবেন — pgvector, বিশেষায়িত vector DB, search engine আর SQLite
Vector রাখার চারটি বড় পরিবার আছে। নিচের কোডগুলো এই পাতায় চলবে না — শুধু পড়ার জন্য; সংস্করণভেদে নাম ও প্যারামিটার বদলাতে পারে, তাই ব্যবহারের আগে নিজের সংস্করণের নথি দেখুন।
PostgreSQL + pgvector
pgvector একটি open-source PostgreSQL extension (Andrew Kane-এর তৈরি, ২০২১ সাল থেকে)। এটি একটি vector(n) কলাম-টাইপ, দূরত্বের অপারেটর আর দুই রকম ANN index দেয়। সবচেয়ে বড় সুবিধা: vector থাকে আপনার সাধারণ টেবিলেই — একই transaction, JOIN, foreign key, backup আর অনুমতি।
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE items (
item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
category text NOT NULL,
stock int NOT NULL DEFAULT 0,
embedding vector(3) NOT NULL -- বাস্তবে মডেলের মাত্রা, যেমন 768
);
INSERT INTO items (title, category, stock, embedding)
VALUES ('Mobile charger 25W', 'Electronics', 30, '[0.70,0.70,0.10]');
-- অপারেটর: <-> L2 দূরত্ব, <=> cosine দূরত্ব (= 1 − cosine), <#> ঋণাত্মক inner product
SELECT title, 1 - (embedding <=> '[0.70,0.68,0.12]') AS cosine
FROM items
WHERE category = 'Electronics'
ORDER BY embedding <=> '[0.70,0.68,0.12]'
LIMIT 5;লক্ষ করুন: ORDER BY-এ দূরত্ব (ছোট = কাছে), similarity নয়; index কাজে লাগাতে এই রূপটিই লিখতে হয়। নতুন সংস্করণে L1-এর <+> আর halfvec (float16), sparsevec-এর মতো টাইপও এসেছে। Index দুই রকম, আর প্রতিটি নির্দিষ্ট অপারেটরের জন্য বানাতে হয় (operator class):
-- HNSW (pgvector 0.5.0 থেকে): cosine-এর জন্য
CREATE INDEX items_emb_hnsw ON items
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100; -- ডিফল্ট 40; বাড়ালে recall বাড়ে, গতি কমে
-- IVFFlat: আগে ডেটা ঢোকান, তারপর index (k-means চলে থাকা সারিতে)
CREATE INDEX items_emb_ivf ON items
USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);
SET ivfflat.probes = 10; -- = nprobe; ডিফল্ট 1
-- ফিল্টারসহ খোঁজে কম ফল এড়াতে (0.8.0 থেকে): iterative index scan
SET hnsw.iterative_scan = relaxed_order;
EXPLAIN ANALYZE
SELECT item_id FROM items ORDER BY embedding <=> '[0.70,0.68,0.12]' LIMIT 5;EXPLAIN-এ Index Scan using items_emb_hnsw দেখলে নিশ্চিত হন index ব্যবহার হচ্ছে (পর্ব ৪১)। ভুল operator class (যেমন L2-র index, কিন্তু কোয়েরিতে <=>) হলে PostgreSQL নীরবে sequential scan করবে — ফল ঠিক, শুধু ধীর। মনে রাখুন, index-সহ ফল approximate: index-ছাড়া একই কোয়েরির সাথে মিলিয়ে recall মাপা যায়। Index-যোগ্য মাত্রার একটি সীমাও আছে (vector-এর জন্য ২০০০, halfvec-এ এর চেয়ে বেশি — সংস্করণ দেখে নিন)।
বিশেষায়িত vector DB, search engine আর SQLite
সম্পূর্ণ managed cloud সেবা (পর্ব ৬১-এর DBaaS ধারণা) — সার্ভার চালাতে হয় না। নিচে Python client-এর তুলনামূলক নতুন সংস্করণের ধাঁচ:
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="YOUR_API_KEY") # key পরিবেশ-চলক থেকে নিন
pc.create_index(name="products", dimension=3, metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"))
index = pc.Index("products")
index.upsert(vectors=[{"id": "1", "values": [0.70, 0.70, 0.10],
"metadata": {"category": "Electronics"}}])
res = index.query(vector=[0.70, 0.68, 0.12], top_k=3,
filter={"category": {"$eq": "Electronics"}}, include_metadata=True)Rust-এ লেখা open-source vector DB; নিজে চালানো বা cloud — দুটিই হয়। Payload (metadata) দিয়ে ফিল্টার খোঁজার সময়েই প্রয়োগ হয়।
from qdrant_client import QdrantClient
from qdrant_client.models import (Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue)
client = QdrantClient(url="http://localhost:6333")
client.create_collection("products",
vectors_config=VectorParams(size=3, distance=Distance.COSINE))
client.upsert("products", points=[
PointStruct(id=1, vector=[0.70, 0.70, 0.10], payload={"category": "Electronics"})])
hits = client.query_points("products", query=[0.70, 0.68, 0.12], limit=3,
query_filter=Filter(must=[FieldCondition(key="category",
match=MatchValue(value="Electronics"))])).pointsপুরোনো client-এ খোঁজার মেথডের নাম ছিল search; নতুনগুলোতে query_points.
বড় মাপের জন্য নকশা করা open-source vector DB (বহু সার্ভারে ছড়ানো যায়), সাথে হালকা Milvus Lite-ও আছে।
from pymilvus import MilvusClient
client = MilvusClient("http://localhost:19530")
client.create_collection(collection_name="products", dimension=3, metric_type="COSINE")
client.insert("products", [{"id": 1, "vector": [0.70, 0.70, 0.10],
"category": "Electronics"}])
res = client.search("products", data=[[0.70, 0.68, 0.12]], limit=3,
filter='category == "Electronics"', output_fields=["category"])Go-তে লেখা open-source vector DB; নিজস্ব hybrid search আর মডেল-মডিউলের জন্য পরিচিত। v4 Python client-এর ধাঁচ (collection আগে তৈরি করা আছে ধরে নিয়ে):
import weaviate
from weaviate.classes.query import Filter
client = weaviate.connect_to_local()
products = client.collections.get("Product")
res = products.query.near_vector(near_vector=[0.70, 0.68, 0.12], limit=3,
filters=Filter.by_property("category").equal("Electronics"))
for obj in res.objects:
print(obj.properties)
client.close()Python-কেন্দ্রিক, প্রোটোটাইপ আর ছোট RAG অ্যাপে জনপ্রিয়; প্রক্রিয়ার ভেতরেই চালানো যায়। নতুন সংস্করণে দূরত্বের মাপ বলার পদ্ধতি বদলেছে, তাই নথি দেখে নিন:
import chromadb
client = chromadb.Client() # মেমরিতে; ডিস্কে: PersistentClient(path=...)
col = client.create_collection("products", metadata={"hnsw:space": "cosine"})
col.add(ids=["1"], embeddings=[[0.70, 0.70, 0.10]],
metadatas=[{"category": "Electronics"}], documents=["Mobile charger 25W"])
res = col.query(query_embeddings=[[0.70, 0.68, 0.12]], n_results=1,
where={"category": "Electronics"})পর্ব ৫৯-এর search engine-গুলোও vector খোঁজ যোগ করেছে — keyword (BM25) আর vector একই জায়গায়, তাই hybrid search স্বাভাবিক। Elasticsearch 8-এর ধাঁচ (OpenSearch-এর k-NN plugin-এর সিনট্যাক্স আলাদা):
PUT products
{ "mappings": { "properties": {
"title": { "type": "text" },
"category": { "type": "keyword" },
"embedding": { "type": "dense_vector", "dims": 3, "index": true, "similarity": "cosine" } } } }
POST products/_search
{ "knn": { "field": "embedding", "query_vector": [0.70, 0.68, 0.12],
"k": 3, "num_candidates": 50,
"filter": { "term": { "category": "Electronics" } } } }num_candidates হলো HNSW-এর প্রার্থী-তালিকা — বাড়ালে recall বাড়ে, গতি কমে।
ছোট বা অফলাইন অ্যাপে (মোবাইল, ডেস্কটপ) SQLite-এর extension sqlite-vec (Alex Garcia-র, আগের sqlite-vss-এর উত্তরসূরি) একটি vec0 virtual table দেয়; শুরুর দিকের সংস্করণ মূলত brute force খোঁজে, যা কয়েক লাখ vector পর্যন্ত প্রায়ই যথেষ্ট দ্রুত। এই পাতার sql.js-এ এটি নেই।
CREATE VIRTUAL TABLE vec_items USING vec0(embedding float[3]);
INSERT INTO vec_items (rowid, embedding) VALUES (1, '[0.70, 0.70, 0.10]');
SELECT rowid, distance
FROM vec_items
WHERE embedding MATCH '[0.70, 0.68, 0.12]' AND k = 3
ORDER BY distance;৯ · খরচ, ফাঁদ আর কখন PostgreSQL + pgvector-ই যথেষ্ট
মডেল বদলালে সব নতুন করে
নতুন embedding মডেলের vector পুরোনোটির সাথে তুলনীয় নয়। মডেল বদলানো মানে সব ডকুমেন্টের re-embedding — কোটি টুকরোয় সময় আর API-খরচ দুটিই বড়। কলামে model_version রাখুন, নতুন কলামে ধীরে ধীরে ভরুন, তারপর সুইচ করুন (পর্ব ২৩-এর expand-contract-এর মতো)।
মাত্রা মানে মেমরি
১৫৩৬ মাত্রা × ৪ byte = ৬১৪৪ byte প্রতি vector; ১০ লাখ vector ≈ ৬.১ GB, HNSW-এর edge বাদেই। ANN index মেমরিতে থাকলে দ্রুত — না আঁটলে ডিস্ক আর ধীরগতি। কম মাত্রার মডেল, float16 বা PQ বিবেচনা করুন।
পুরোনো vector
পণ্যের বিবরণ বদলাল, কিন্তু embedding আগের — খোঁজ পুরোনো অর্থে চলবে। লেখা আর vector একই transaction-এ হালনাগাদ করুন, বা পরিবর্তন ধরে (trigger, change data capture) আবার embed করুন। মোছা নথির vector-ও মুছুন — নইলে RAG মুছে ফেলা তথ্য দেখাবে।
গোপনীয়তা
Embedding “শুধু সংখ্যা” নয়: গবেষণায় দেখা গেছে কিছু ক্ষেত্রে embedding থেকে মূল লেখার উল্লেখযোগ্য অংশ পুনর্গঠন করা সম্ভব। তাই এগুলো ব্যক্তিগত ডেটার মতোই রক্ষা করুন — encryption, অনুমতি, বাইরের API-তে কী পাঠাচ্ছেন তার হিসাব (পর্ব ৫১)। RAG-এ অনুমতির ফিল্টার খোঁজের সময়েই দিন, যাতে একজন অন্যের নথি না পান।
বাংলার মান যাচাই করুন। অনেক বহুভাষিক embedding মডেল বাংলা সমর্থন করে, কিন্তু মান মডেলভেদে অনেক আলাদা হতে পারে — ইংরেজির তুলনায় প্রশিক্ষণের বাংলা লেখা সাধারণত কম। Banglish (“phoner charger”), একই অক্ষরের দুই Unicode রূপ (পর্ব ৩০) আর আঞ্চলিক শব্দ বিশেষ ঝুঁকি। কোনো মডেল বেছে নেওয়ার আগে নিজের ৫০–১০০টি বাস্তব বাংলা প্রশ্নে recall@k মাপুন; বিজ্ঞাপনের দাবি বা ইংরেজি benchmark-এর ওপর ভরসা করবেন না।
কখন PostgreSQL + pgvector-ই যথেষ্ট
একটি বাস্তবসম্মত শুরুর নিয়ম: যদি vector-এর সংখ্যা কয়েক লাখ থেকে কয়েক কোটির মধ্যে, index এক সার্ভারের মেমরিতে আঁটে, আর আপনার ডেটা আগেই PostgreSQL-এ — তাহলে pgvector দিয়ে শুরু করুন। লাভ: একটি সিস্টেম, একটি backup, transaction-এ লেখা আর vector একসাথে, ফিল্টারে পূর্ণ SQL আর JOIN, পরিচিত অনুমতি-ব্যবস্থা। বিশেষায়িত vector DB-র কথা ভাবুন যখন — বহু কোটি বা শত কোটি vector, প্রতি সেকেন্ডে বিপুল প্রশ্ন, অনেক সার্ভারে ছড়ানো দরকার, বা আপনার দলের PostgreSQL চালানোর অভিজ্ঞতা নেই এবং managed সেবা চান। তখনো source of truth relational ডেটাবেসেই রাখুন; vector DB তার প্রতিলিপি, তাই দুই-কপির consistency সমস্যাও আসে।
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Cosine আর Euclidean-এর পার্থক্য কী, কখন একই? — cosine দিক মাপে, Euclidean অবস্থান; একক দৈর্ঘ্যের vector-এ ‖u − v‖² = 2 − 2 cos θ, তাই ক্রম একই।
- Exact k-NN-এর খরচ কত? — প্রতি প্রশ্নে O(n·d) হিসাব আর পুরো ডেটা একবার পড়া; top-k রাখতে heap দিয়ে O(n log k).
- IVF আর HNSW-এর পার্থক্য? — IVF দলে ভাগ করে কাছের nprobe-টি দলে খোঁজে; HNSW স্তরের গ্রাফে greedy হাঁটে। HNSW সাধারণত দ্রুত কিন্তু বেশি মেমরি নেয়।
- Post-filter-এর সমস্যা কী? — top-k আনার পর ছাঁকলে k-এর কম ফল থাকতে পারে; কড়া শর্তে pre-filter বা খোঁজের সময় ফিল্টার লাগে।
- RAG কী, কেন? — প্রশ্নের প্রাসঙ্গিক নথি খুঁজে LLM-কে প্রসঙ্গ হিসেবে দেওয়া, যাতে উত্তর হালনাগাদ, উৎসভিত্তিক আর কম বানানো হয়।
১০ · AI-এর যুগে vector search — AI-এর কোড যাচাই
“আমার পণ্যের জন্য semantic search বানাও” বা “একটি RAG chatbot বানাও” — AI টুল এমন অনুরোধে মিনিটেই চলার মতো কোড দেয়। কিন্তু এই বিষয়ের ভুলগুলো প্রায় কখনো error দেয় না; খারাপ ফল দেয়, চুপচাপ:
| AI যা করে | আসলে সমস্যা |
|---|---|
pgvector-এ <-> লেখে, অথচ মডেল cosine-এর জন্য, বা index-এর operator class আলাদা | ভুল ক্রম, অথবা index না লেগে sequential scan |
| ডকুমেন্ট এক মডেলে, প্রশ্ন আরেক মডেলে embed করে (বা মাত্রা মেলায় না) | তুলনা অর্থহীন, বা vector(768) বনাম 1536-এর error |
| ORDER BY-তে similarity DESC লেখে | অনেক সিস্টেমে index শুধু দূরত্ব ASC-তে কাজ করে |
| top-k এনে তারপর ফিল্টার করে | post-filter-এ কম বা শূন্য ফল; অনুমতির ফিল্টারে তথ্য ফাঁসের ঝুঁকি |
| লাইব্রেরির পুরোনো API লেখে (নাম বদলে গেছে) | vector DB-র client দ্রুত বদলায় — সংস্করণ না বললে AI মিশিয়ে ফেলে |
| কোনো মূল্যায়ন ছাড়াই “কাজ করছে” বলে | recall@k না মাপলে জানবেন না ফল কতটা ভালো |
প্রশ্ন করার সময় সিস্টেম, সংস্করণ, মডেল আর যাচাইয়ের দাবি একসাথে দিন:
AI-এর লেখা vector search বা RAG কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Vector খোঁজার গণিত বহু পুরোনো। তাহলে “vector database” হঠাৎ ২০২০-এর দশকে আলাদা একটি শ্রেণি হয়ে উঠল কেন?
ধারণাগুলো সত্যিই পুরোনো। Gerard Salton-এর vector space model (১৯৭০-এর দশক) ডকুমেন্টকে শব্দের vector ধরে cosine দিয়ে মিল মাপত — অংশ ২-এর bag-of-words-এর পূর্বসূরি। LSH এসেছে ১৯৯৮ সালে, product quantization ২০১১-তে। কিন্তু তখন vector-এ ভালো অর্থ ছিল না: শব্দ গোনা vector keyword search-এর চেয়ে খুব বেশি কিছু দিত না।
বদল এল মডেলে। word2vec (২০১৩) দেখাল শেখা vector-এ অর্থের সম্পর্ক ধরা পড়ে; BERT (২০১৮) আর বাক্য-embedding-এর পদ্ধতিগুলো পুরো বাক্য আর অনুচ্ছেদের মান অনেক বাড়াল; CLIP (২০২১) ছবি আর লেখাকে এক জায়গায় আনল। একই সময়ে Facebook-এর FAISS (২০১৭) আর HNSW (২০১৬)-এর মতো কাজ ANN খোঁজকে বাস্তবসম্মত করল। তবে FAISS একটি লাইব্রেরি — persistence, হালনাগাদ, ফিল্টার, অনুমতি, backup, বহু সার্ভার — এসব নিজে বানাতে হতো। সেই ফাঁক পূরণ করতে Pinecone, Milvus, Qdrant, Weaviate-এর মতো পণ্য আসে।
শেষ ধাক্কা ২০২২ সালের শেষে ChatGPT-এর পর RAG-এর জোয়ার: হঠাৎ প্রায় প্রতিটি প্রতিষ্ঠান নিজের নথি দিয়ে chatbot চাইল, আর প্রতিটির দরকার হলো একটি vector store. তারপর প্রতিষ্ঠিত ডেটাবেসগুলোও বৈশিষ্ট্যটি যোগ করল — PostgreSQL-এ pgvector, Elasticsearch-এ dense vector, MongoDB আর Redis-এও vector index. তাই আজ বিতর্ক: vector কি আলাদা ডেটাবেস, নাকি JSON-এর মতো সবার একটি বৈশিষ্ট্য (পর্ব ৩০)?
মূল উপলব্ধি: নতুন ডেটাবেস শ্রেণি সাধারণত জন্মায় যখন একটি নতুন চাহিদা পুরোনো সিস্টেমে মেটে না; তারপর পুরোনো সিস্টেমগুলো সেই ক্ষমতা শিখে নেয়। বেছে নেওয়ার সময় প্রশ্ন করুন: আমার মাপে কি বিশেষায়িত সিস্টেমের বাড়তি জটিলতা সত্যিই লাভজনক?
প্র ০২“Curse of dimensionality” আসলে কী, আর কেন এর জন্য আমরা নিখুঁত উত্তর ছেড়ে approximate উত্তর মেনে নিই?
শব্দবন্ধটি Richard Bellman-এর (১৯৫০-৬০-এর দশক, dynamic programming প্রসঙ্গে), কিন্তু খোঁজের ক্ষেত্রে এর অর্থ আরও নির্দিষ্ট। একটি সহজ পর্যবেক্ষণ দিয়ে শুরু করুন: একটি d-মাত্রার একক ঘনকের প্রতিটি মাত্রার মাঝের ৯০% অংশ নিলে যে ছোট ঘনক হয়, তার আয়তন 0.9^d. d = ২-এ ৮১%, কিন্তু d = ১০০-এ প্রায় 0.0027% — উচ্চ মাত্রায় প্রায় সব আয়তন কিনারার দিকে। একই কারণে এলোমেলো বিন্দুগুলোর দূরত্ব একটি সংকীর্ণ পরিসরে জমে যায়: সবচেয়ে কাছের আর সবচেয়ে দূরের বিন্দুর দূরত্বের অনুপাত ১-এর কাছে চলে আসে।
এর ফল k-d tree বা R-tree-র মতো স্থান-ভাগ করা index-এর জন্য মারাত্মক। এরা কাজ করে “এই এলাকা প্রশ্ন থেকে এত দূরে যে ভেতরে কিছু কাছের হতে পারে না” — এই যুক্তিতে শাখা কেটে। উচ্চ মাত্রায় প্রায় কোনো এলাকাই যথেষ্ট দূরে নয়, তাই প্রায় সব শাখা খুলতে হয়, আর খরচ brute force-এর কাছাকাছি বা বেশি হয়।
তাহলে বাস্তব embedding-এ ANN এত ভালো কাজ করে কেন? কারণ বাস্তব ডেটা এলোমেলো নয়: ৭৬৮ মাত্রার vector-গুলো আসলে অনেক কম মাত্রার “পৃষ্ঠ”-এর কাছে থাকে, আর অর্থের দলে জমাট বাঁধে। IVF সেই দল ধরে, HNSW সেই কাঠামোয় পথ খোঁজে। আর আমাদের প্রশ্নটাই আনুমানিক — “ফোনের চার্জার”-এর নিখুঁত ১০ম প্রতিবেশী আর ১১তম প্রতিবেশীর পার্থক্য ক্রেতার কাছে প্রায় অর্থহীন, কারণ embedding নিজেই অর্থের একটি অনুমান।
মূল উপলব্ধি: নিখুঁততা তখনই মূল্যবান, যখন প্রশ্নটি নিজে নিখুঁত। embedding-এর মতো আনুমানিক মাপে exact খোঁজের দাম দেওয়া প্রায়ই অপচয় — কিন্তু সেই সিদ্ধান্ত recall মেপে নিন।
প্র ০৩“RAG যোগ করলে LLM আর ভুল বলবে না” — এই দাবি কতটা ঠিক, আর একটি প্রতিষ্ঠানের RAG-এ সবচেয়ে বড় ঝুঁকিগুলো কী?
দাবিটি অর্ধসত্য। Lewis ও সহকর্মীদের ২০২০ সালের RAG গবেষণা আর পরের বহু কাজে দেখা গেছে, প্রাসঙ্গিক নথি সামনে থাকলে LLM-এর তথ্যভিত্তিক উত্তর অনেক ভালো হয়, আর উৎস দেখানো যায়। কিন্তু RAG একটি শৃঙ্খল, আর প্রতিটি কড়ায় ভাঙন সম্ভব।
খোঁজ ব্যর্থ হলে: প্রাসঙ্গিক chunk top-k-তে না এলে LLM হয় “জানি না” বলবে, নয়তো নিজের স্মৃতি থেকে বানিয়ে বলবে। খারাপ chunking (একটি নিয়ম দুই টুকরোয় ভাগ), বাংলায় দুর্বল embedding, বা টেবিল-ভরা PDF থেকে ভুল লেখা বের হওয়া — সাধারণ কারণ। পুরোনো তথ্য: ২০২৩ সালের নীতিমালা আর ২০২৫ সালের সংশোধনী দুটিই index-এ থাকলে, খোঁজ পুরোনোটি আনতে পারে; তারিখ বা সংস্করণের metadata আর পুরোনো নথি সরানোর প্রক্রিয়া লাগে। প্রসঙ্গ মানলেও ভুল: LLM দুটি chunk মিলিয়ে এমন সিদ্ধান্ত টানতে পারে যা কোনোটিতেই নেই।
নিরাপত্তার ঝুঁকি আরও গুরুতর। অনুমতি: সব নথি একটি index-এ রাখলে, শাখার একজন কর্মী প্রশ্ন করে মানবসম্পদ বিভাগের গোপন নথির টুকরো পেয়ে যেতে পারেন — খোঁজের সময়েই ব্যবহারকারীর অনুমতি দিয়ে ফিল্টার (পর্ব ৫১-এর row-level security-র মতো চিন্তা) আবশ্যক। নথির ভেতরের নির্দেশ: কোনো নথিতে যদি লেখা থাকে “আগের নির্দেশ উপেক্ষা করো”, LLM সেটিকে নির্দেশ ভেবে নিতে পারে (prompt injection); তাই খুঁজে আনা লেখাকে তথ্য হিসেবে আলাদা করে দেওয়া আর LLM-এর ক্ষমতা সীমিত রাখা জরুরি।
মূল উপলব্ধি: RAG একটি ডেটাবেস সমস্যা যতটা, ততটাই AI সমস্যা — সঠিক, হালনাগাদ, অনুমতি-মানা ডেটা খুঁজে আনা। খোঁজের মান (recall@k) আর উত্তরের মান আলাদাভাবে নিয়মিত মাপুন।
প্র ০৪Vector search এত শক্তিশালী হলে keyword search (BM25) কেন এখনো দরকার? Hybrid search কি শুধু অস্থায়ী জোড়াতালি?
Information retrieval গবেষণায় বহু বছর ধরে একটি শিক্ষা বারবার এসেছে: ভিন্ন ধরনের সংকেত মেলালে সাধারণত একক সেরা পদ্ধতির চেয়েও ভালো ফল আসে। Cormack, Clarke ও Büttcher ২০০৯ সালে RRF প্রস্তাব করার সময় দেখিয়েছিলেন, এই সরল ক্রম-যোগ অনেক জটিল মেলানোর পদ্ধতির সমান বা ভালো কাজ করে। আধুনিক মূল্যায়নেও প্রায়ই দেখা যায় BM25 আর dense retrieval একে অপরের ভুল ধরে।
কারণটা দুই পদ্ধতির প্রকৃতিতে। BM25 হুবহু টোকেন মেলায়, আর বিরল শব্দকে বেশি ওজন দেয় (পর্ব ৩০-এর IDF) — তাই পণ্যের মডেল নম্বর, মানুষের নাম, আইনের ধারা (“ধারা ৫৭”), ত্রুটির কোড, বা একদম নতুন শব্দে সে নির্ভরযোগ্য। Embedding মডেল প্রশিক্ষণের সময় দেখা অর্থের ধরন শেখে; যে টোকেন সে প্রায় দেখেনি, বা যেখানে একটি অক্ষরের পার্থক্যই সব (“25W” বনাম “65W”), সেখানে সে প্রায়ই সব একসাথে গুলিয়ে ফেলে — এই পর্বের hybrid এডিটরের Power bank ঠিক তা-ই দেখিয়েছে। উল্টো দিকে সমার্থক শব্দ, অনুবাদ আর ঘুরিয়ে বলা প্রশ্নে embedding এগিয়ে।
মূল উপলব্ধি: Hybrid জোড়াতালি নয়, স্থায়ী নকশা — দুটি পদ্ধতি দুই ধরনের ভুল করে। একটি নতুন পদ্ধতি পুরোনোটিকে সাধারণত প্রতিস্থাপন করে না, পরিপূরক হয়।
প্র ০৫একটি ই-কমার্স প্রতিষ্ঠানের ২০ লাখ পণ্য আর ৫০ লাখ রিভিউ PostgreSQL-এ আছে। semantic search-এর জন্য pgvector নাকি আলাদা vector DB — কীভাবে সিদ্ধান্ত নেবেন?
প্রথমে হিসাব। ধরুন ৭০ লাখ টুকরো, ৭৬৮ মাত্রা, float32: 7 × 10⁶ × 768 × 4 ≈ ২১.৫ GB শুধু vector. HNSW-এ M = ১৬ হলে স্তর ০-তে প্রতি node-এ সর্বোচ্চ ৩২টি প্রতিবেশী — ৪ byte আইডি ধরলেও আরও প্রায় ১২৮ byte প্রতি vector, অর্থাৎ আরও ~০.৯ GB, বাস্তবায়নভেদে বাড়তি খরচসহ। halfvec (float16) নিলে vector-এর অংশ অর্ধেক, প্রায় ১১ GB. এটি একটি বড় কিন্তু সাধারণ সার্ভারের মেমরিতে আঁটে। অর্থাৎ মাপের দিক থেকে pgvector যুক্তিসংগত।
তারপর কাজের ধরন। ক্রেতার খোঁজে প্রায় সবসময় ফিল্টার থাকে — ক্যাটাগরি, দাম, স্টক, বিক্রেতা, এলাকায় ডেলিভারি হয় কি না। এগুলো সব আগেই relational টেবিলে, JOIN আর index-সহ। pgvector-এ এক কোয়েরিতে সব হয়, আর পণ্য হালনাগাদ আর তার vector একই transaction-এ। আলাদা vector DB নিলে এই metadata-র একটি প্রতিলিপি সেখানে রাখতে হবে, CDC বা queue দিয়ে মেলাতে হবে (পর্ব ৫২ আর ৫৪-এর replication আর eventual consistency-র সমস্যা), আর “মুছে ফেলা পণ্য সার্চে দেখা যাচ্ছে” ধরনের বাগ শুরু হবে।
আলাদা সিস্টেম জোরালো হয় যখন vector খোঁজ মূল ডেটাবেসের CPU খেয়ে ফেলে (মধ্যপথ: read replica-তে খোঁজ), যখন শত কোটির মাপে বহু সার্ভার লাগে, বা দল managed সেবা চায়। সিদ্ধান্তের আগে নিজের নমুনায় p95 latency, recall@10 আর মেমরি মাপুন (পর্ব ৫৩)।
মূল উপলব্ধি: “কোন টুল দ্রুততম” নয়, “আমার মাপে কোনটি যথেষ্ট দ্রুত, আর কম চলমান অংশ নিয়ে” — এটাই প্রশ্ন। প্রতিটি নতুন সিস্টেম একটি নতুন consistency, backup আর নিরাপত্তার দায়িত্ব।
অনুশীলন
প্রথমটি SQL লেখার কাজ — items টেবিল আছে এমন যেকোনো এডিটরে লিখে চালাতে পারেন। বাকিগুলো হাতে হিসাব আর ডিজাইনের ভাবনা।
-
কোয়েরি লিখুন: প্রশ্ন “ঈদের পাঞ্জাবি”-র embedding ধরা হলো (0.10, 0.00, 0.90). items টেবিল থেকে শুধু Clothing ক্যাটাগরির মধ্যে cosine অনুযায়ী সেরা দুটি পণ্যের নাম আর cosine (৪ দশমিক ঘর পর্যন্ত) বের করুন।
WITH q(qx, qy, qz) AS (VALUES (0.10, 0.00, 0.90)) SELECT title, round((x*qx + y*qy + z*qz) / (sqrt(x*x+y*y+z*z) * sqrt(qx*qx+qy*qy+qz*qz)), 4) AS cosine FROM items, q WHERE category = 'Clothing' ORDER BY cosine DESC LIMIT 2;ফল: Cotton panjabi │ 0.9884, তারপর জামদানি শাড়ি │ 0.9775. দুটিই খুব কাছে, কারণ দুটির vector প্রায় পুরোপুরি z-দিকে; প্রশ্নে x-এর সামান্য ধনাত্মক মান (0.10) panjabi-কে (x = 0) শাড়ির (x = −0.10) চেয়ে একটু এগিয়ে দিয়েছে। WHERE আগে বসায় এটি pre-filter.
ORDER BY-তে aliascosineব্যবহার SQLite-এ চলে। -
হাতে হিসাব — ফল আগে বলুন: u = (1, 2, 2), v = (2, 1, 2). (ক) u·v, ‖u‖, ‖v‖ আর cosine কত? (খ) Euclidean দূরত্ব কত? (গ) দুটিকে normalize করলে দূরত্বের বর্গ কত হবে — অংশ ৩-এর সূত্র দিয়ে, আর সরাসরি হিসাব করে মিলিয়ে দেখান।
(ক) u·v = 1×2 + 2×1 + 2×2 = 8; ‖u‖ = √(1+4+4) = 3, ‖v‖ = 3; cosine = 8/9 ≈ 0.889. (খ) ‖u − v‖ = √((1−2)² + (2−1)² + 0²) = √2 ≈ 1.414. (গ) সূত্র: 2 − 2 × 8/9 = 2/9 ≈ 0.222. সরাসরি: û = u/3, v̂ = v/3, তাই ‖û − v̂‖² = ‖u − v‖² / 9 = 2/9. দুটি মিলেছে — এখানে দুই vector-এর দৈর্ঘ্য সমান বলে normalize করা শুধু মাপ বদলায়, ক্রম নয়।
-
খরচ আর recall: ১০ লাখ vector, IVF-এ ১০০০টি সমান মাপের list. (ক) nprobe = ৮ হলে প্রতি প্রশ্নে মোটামুটি কয়টি দূরত্ব হিসাব হয়, brute force-এর তুলনায় কত গুণ কম? (খ) একটি প্রশ্নে exact top-5 = {3, 8, 12, 40, 77}, IVF দিল {3, 12, 40, 91, 105}. recall@5 কত? (গ) recall বাড়াতে কোন দুটি নব ঘোরাবেন, আর তার দাম কী?
(ক) ১০০০টি centroid + ৮ × (১০ লাখ / ১০০০) = 1000 + 8000 = ৯০০০ দূরত্ব; brute force ১০ লাখ, তাই প্রায় ১১১ গুণ কম। (খ) মিল {3, 12, 40} — ৩টি, তাই recall@5 = 3/5 = 0.6. (গ) nprobe বাড়ানো (বেশি list খোঁজা — কাজ প্রায় সমানুপাতে বাড়ে), অথবা index আবার বানিয়ে list-এর সংখ্যা বদলানো; HNSW হলে ef_search বাড়ানো। সব ক্ষেত্রে দাম latency. মাপ ছাড়া নব ঘোরাবেন না — recall@k আর p95 latency দুটিই মাপুন।
-
ডিজাইনের সিদ্ধান্ত: একটি জেলা প্রশাসনের অফিস তাদের সব পরিপত্র আর অফিস-আদেশ (বাংলায়, কিছু স্ক্যান করা PDF) দিয়ে কর্মীদের জন্য একটি প্রশ্নোত্তর-সহায়ক বানাতে চায়। কিছু নথি সবার জন্য, কিছু শুধু নির্দিষ্ট শাখার। (ক) chunks টেবিলে কোন কলাম রাখবেন? (খ) অনুমতির ফিল্টার কোথায় বসাবেন? (গ) একটি পরিপত্র বাতিল হলে কী করবেন? (ঘ) মান কীভাবে যাচাই করবেন?
(ক)
chunk_id(PK),doc_id(FK → documents),chunk_no,text,embedding,model_version, আর documents টেবিলেissued_on,status(বলবৎ/বাতিল),access_group. (খ) খোঁজের সময়েই, pre-filter হিসেবে:WHERE access_group IN (ব্যবহারকারীর দলগুলো) AND status = 'active'— post-filter বা LLM-কে “গোপন জিনিস দেখিও না” বলা যথেষ্ট নয়; PostgreSQL-এ row-level security আরও শক্ত স্তর দেয়। (গ) status বদলান, আর তার chunk-গুলো খোঁজ থেকে বাদ দিন (বা মুছুন) একই transaction-এ; উত্তরে নথির তারিখ দেখান। (ঘ) কর্মীদের কাছ থেকে ৫০–১০০টি বাস্তব প্রশ্ন আর সঠিক নথি সংগ্রহ করে recall@k মাপুন; স্ক্যান করা PDF-এর বাংলা লেখা (OCR) ঠিক আছে কি না হাতে দেখুন, কারণ ভুল লেখা থেকে ভালো embedding হয় না; আর কয়েকটি উত্তর হাতে মিলিয়ে দেখুন প্রতিটি দাবি উৎসে আছে কি না।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি RAG ব্যবস্থায় নথি থেকে উত্তর পর্যন্ত ধাপগুলো সাজান (▲▼ চেপে সরান)
- নথিকে ছোট ছোট chunk-এ ভাগ করা
- প্রতিটি chunk-এর embedding বানিয়ে metadata-সহ vector DB-তে রাখা
- ব্যবহারকারীর প্রশ্নকে একই মডেলে embed করা
- অনুমতির ফিল্টারসহ সবচেয়ে কাছের top-k chunk খুঁজে আনা
- chunk আর প্রশ্ন দিয়ে prompt বানিয়ে LLM-কে দেওয়া, উত্তরে উৎস দেখানো
u আর v দুটিই একক দৈর্ঘ্যের vector (‖u‖ = ‖v‖ = 1), আর তাদের cosine similarity 0.9. ‖u − v‖² কত?
- 0.1
- 1.8
- 0.2
- 0
PostgreSQL-এর pgvector-এ cosine দূরত্ব অনুযায়ী সবচেয়ে কাছের সারি আনতে ORDER BY embedding ___ :q LIMIT 10-এ কোন অপারেটর বসবে?
<-><=><#>@>
একটি IVF index-এ nprobe ১ থেকে ৮-এ বাড়ালে সাধারণত কী হয়?
- Recall বাড়ে, প্রতি প্রশ্নে কাজ ও latency বাড়ে
- Recall কমে, গতি বাড়ে
- Index-এর মেমরি আট গুণ হয়
- কোনো প্রভাব নেই, কারণ k একই থাকে
একটি প্রশ্নে exact top-5 = {a, b, c, d, e}, আর একটি ANN index দিল {a, c, e, x, y}. এই প্রশ্নে recall@5 কত?
- 0.4
- 0.6
- 1.0
- 0.3
একটি দল তাদের embedding মডেল পুরোনোটি থেকে নতুন একটি মডেলে বদলাল; দুটিরই মাত্রা ৭৬৮. পুরোনো ডকুমেন্টের vector রেখে শুধু নতুন প্রশ্ন নতুন মডেলে embed করলে কী হবে?
- ফল আগের চেয়ে ভালো হবে, কারণ নতুন মডেল উন্নত
- মাত্রা মেলায় DBMS error দেবে
- কোয়েরি চলবে, কিন্তু ফল অর্থহীন; সব ডকুমেন্ট re-embed করতে হবে
- শুধু ANN index নতুন করে বানালেই হবে
Reciprocal rank fusion-এ (k = 60) একটি ডকুমেন্ট keyword তালিকায় ১ নম্বরে আর vector তালিকায় ৩ নম্বরে। তার RRF স্কোর কোনটি?
- 1/61 + 1/63
- 1 + 3 = 4
- BM25 স্কোর + cosine স্কোর
- 1/(61 × 63)
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Embedding = মডেলের দেওয়া vector; কাছের অর্থ = কাছের বিন্দু। ডকুমেন্ট আর প্রশ্ন একই মডেলে; মডেল বদলালে re-embed.
- Cosine দিক, Euclidean অবস্থান, dot product দিক + দৈর্ঘ্য। একক vector-এ ‖u − v‖² = 2 − 2 cos θ — তিনটির ক্রম এক।
- Exact k-NN = O(n·d) প্রতি প্রশ্নে; উচ্চ মাত্রায় B-tree/R-tree সাহায্য করে না। ANN — IVF (nprobe), HNSW (ef_search), PQ (সংকোচন) — recall-এর বিনিময়ে গতি ও মেমরি।
- ফিল্টার: post-filter-এ ফল কমে যেতে পারে; কড়া শর্তে pre-filter. Hybrid = BM25 + vector, মেলানো হয় প্রায়ই RRF দিয়ে: Σ 1/(60 + r).
- RAG = chunk → embed → খোঁজ → prompt → উৎসসহ উত্তর; মাপুন recall@k. অনেক ক্ষেত্রে PostgreSQL + pgvector যথেষ্ট — বাংলায় নিজের ডেটায় মান যাচাই করুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৬১ · Cloud database আর DBaaSপরবর্তী পর্বmanaged সেবা, serverless আর খরচের হিসাব।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- পর্ব ৩০ · JSON, full-text search আর বিশেষ ডেটা টাইপআবার দেখুনInverted index আর BM25 — hybrid search-এর keyword অর্ধেক।
- DSA · Graphপাশের ট্র্যাকHNSW যে গ্রাফে হাঁটে, তার মৌলিক ধারণা।
- DSA · Heapগভীরে যানn-টি দূরত্ব থেকে সেরা k-টি O(n log k)-তে রাখার কৌশল।