অধ্যায় 4 · এমবেডিং ও লোকাল ভেক্টর সার্চ
এমবেডিং ও সাদৃশ্য
- পৃষ্ঠা 7 / 22
- 21 মিনিট পড়া
রিট্রিভাল (retrieval) ঠিক করে আপনার ডকুমেন্টের কোন অংশগুলো মডেল দেখতে পাবে। এই ধাপে ভুল প্যাসেজ উঠে এলে কোনো প্রম্পটই উত্তর বাঁচাতে পারে না। বেশির ভাগ রিট্রিভাল সিস্টেম একই কৌশল দিয়ে শুরু হয়: টেক্সটকে সংখ্যার তালিকায় বদলে ফেলা, যাতে "কোন প্যাসেজ এই প্রশ্নের কাছাকাছি?" প্রশ্নটা টেস্ট করার মতো অঙ্কে পরিণত হয়। এই পাতায় চলমান প্রজেক্ট EvidenceDesk-এর জন্য সেই কৌশল গোড়া থেকে বানানো হবে: একটা ছোট নির্ধারিত (deterministic) এমবেডার, কোসাইন সাদৃশ্য, ডকুমেন্ট লোডার আর প্রথম ইন-মেমরি সার্চ। সবকিছু মডেল, কি (key) বা ইন্টারনেট ছাড়াই চলে। ভেক্টর আর কোসাইন সাদৃশ্য আপনি AI-এর জন্য গণিত ও পরিসংখ্যান: NumPy দিয়ে মূল বিষয় টিউটোরিয়ালে দেখেছেন; এখানে সেটা টেক্সটের ওপর কাজে লাগবে।
যা শিখবেন
- অ্যাপ্লিকেশনের ভাষায় এমবেডিং (embedding) কী, এবং ভেক্টর বানানো আর সার্চ কেন আলাদা রাখা হয়।
- নির্ধারিত একটা টয় এমবেডার বানিয়ে তা দিয়ে র্যাংকিং টেস্ট করা, এবং সেটা কোথায় ব্যর্থ হয়।
- কোসাইন সাদৃশ্য লিখে টেস্ট করা, এবং কেন উঁচু স্কোর প্রমাণ নয়, শুধু একটা সংকেত।
- প্রতিটা প্যাসেজকে স্থিতিশীল আইডি ও মেটাডেটা দেওয়া, আর ১৩টা নমুনা ডকুমেন্টের ওপর ছোট লোকাল সার্চ বানানো।
- কখন লোকাল আর কখন হোস্টেড এমবেডিং ব্যবহার করবেন, এবং হাতে লেবেল করা পাঁচটা প্রশ্ন দিয়ে "সেরা মিলের প্যাসেজ" টেস্ট করা।
অ্যাপ্লিকেশনের ভাষায় এমবেডিং কী
এমবেডিং হলো নির্দিষ্ট দৈর্ঘ্যের সংখ্যার তালিকা, যা একটা ফাংশন টেক্সট থেকে হিসাব করে। ফাংশনটা এমনভাবে বানানো যে, যে টেক্সটগুলোকে সে সদৃশ মনে করে তাদের ভেক্টর প্রায় একই দিকে যায়। আপনার অ্যাপ্লিকেশনের কাছে এটুকুই: রিট্রিভালের জন্য ব্যবহৃত সংখ্যাভিত্তিক উপস্থাপনা। এটা টেক্সটের সারসংক্ষেপ নয়, ব্যাখ্যাও নয়, আর আলাদা আলাদা সংখ্যা থেকে কোনো অর্থ পড়া যায় না। প্রতিটা প্যাসেজের জন্য একবার হিসাব করে সংরক্ষণ করবেন, প্রশ্নের জন্যও একটা বানাবেন, তারপর তুলনা করবেন।
শেষ কথাটা টেস্টের জন্য গুরুত্বপূর্ণ। "উত্তরটা কি ভালো?" কঠিন প্রশ্ন। "আমরা যে প্যাসেজ আশা করছি সেটাই কি এই প্রশ্নে সবার আগে আসছে?" একটা সাধারণ ইউনিট টেস্ট। এমবেডিং রিট্রিভালকে একটা মাপা যায় এমন, বারবার চালানো যায় এমন ধাপ দেয়।
এমবেডিং তৈরি আর সার্চ আলাদা রাখুন
দুটো কাজ আলাদা রাখুন। এমবেডিং তৈরি টেক্সটকে ভেক্টরে বদলায় এবং মডেল (বা আমাদের টয়) লাগে। সার্চ ভেক্টর নিয়ে সবচেয়ে কাছেরগুলো ফেরত দেয় এবং টেক্সট সম্পর্কে কিছুই জানে না। তাহলে প্রতিটা আলাদাভাবে টেস্ট করা যায়: সার্চ হাতে বানানো ভেক্টর দিয়ে (কোনো মডেল ছাড়াই), আর এমবেডিং তৈরি গড়ন (shape), দৈর্ঘ্য ও নির্ধারিত আচরণ দেখে।
text --[embed]--> vector --+
+--[search]--> ranked ids and scores
question --[embed]--> vector --+নিচের কোডে embed_all এমবেডিং তৈরির কাজ করে আর top_k সার্চের কাজ। তৃতীয় অংশ LocalSearch শুধু দুটোকে জোড়া দেয়। এমবেডার একটা সাধারণ ফাংশন যা আপনি আর্গুমেন্ট হিসেবে দেন, তাই পরে সার্চে হাত না দিয়েই টয়কে আসল মডেল দিয়ে বদলানো যায়। প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট পাতার ফেক ক্লায়েন্টের একই ধারণা: আপনার নিয়ন্ত্রণে থাকা একটা ছোট স্ট্যান্ড-ইন, যাতে টেস্ট বারবার একই ফল দেয়।
নমুনা ডকুমেন্ট আর কিট ফাইল
EvidenceDesk একটা কাল্পনিক দোকান Kestrel Cycles-এর হেল্প পেজ নিয়ে প্রশ্নের উত্তর দেয়। কোর্সের উপকরণ থেকে corpus/ ফোল্ডারটা আপনার প্রজেক্টে কপি করুন: এগুলো Kestrel Cycles-এর ১৩টা নমুনা ডকুমেন্ট (১১টা ইংরেজি, ২টা বাংলা)। সব ডেটা কৃত্রিম (synthetic)।
ls corpusassembly-guide.md
battery-care.md
bike-sizing.md
gift-cards.md
privacy-notice.md
returns-policy-v2.md
returns-policy.bn.md
returns-policy.md
safety-recalls.md
service-booking.md
shipping.bn.md
shipping.md
warranty.mdপ্রতিটা ফাইল শুরু হয় ফ্রন্ট ম্যাটার দিয়ে (id, title, version, status, lang), তারপর ## দিয়ে শুরু হওয়া সেকশন। returns-policy-v2.md খেয়াল করুন: এটা পুরোনো, বাতিল (superseded) নীতি, যা বর্তমানটার সঙ্গে মেলে না। ইচ্ছা করেই রাখা হয়েছে, কারণ বাস্তব ডকুমেন্ট সেটে পুরোনো পাতা থাকে, আর রিট্রিভাল সেগুলোর সঙ্গে কী করে তা আমরা দেখতে চাই।
এবার এই পাতার মডিউলটা বানান। একবার পুরোটা পড়ে নিন; পরের সেকশনগুলোতে একেকটা অংশ আলাদা করে চালানো হবে। এতে শুধু স্ট্যান্ডার্ড লাইব্রেরি আর NumPy লাগে। এমবেডারে zlib.crc32 খেয়াল করুন: Python-এর বিল্ট-ইন hash() স্ট্রিংয়ের জন্য প্রতিটা প্রসেসে আলাদা সল্ট ব্যবহার করে, তাই প্রতি রানে ভেক্টর বদলে যেত এবং পুনরাবৃত্তিযোগ্য টেস্ট ভেঙে যেত।
# file: evidencedesk/embeddings.py
"""Toy embeddings, cosine similarity, document loading and a tiny in-memory search."""
from __future__ import annotations
import re
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import numpy as np
DIM = 256
STOPWORDS = frozenset("a an and are as at be by can do for how i in is it me my of on or the to we what when you your".split())
Embedder = Callable[[str], np.ndarray]
def words(text: str) -> list[str]:
cleaned = (w.strip(".,;:!?()\"'#-") for w in text.lower().split())
return [w for w in cleaned if w and w not in STOPWORDS]
def toy_embed(text: str, dim: int = DIM) -> np.ndarray:
"""Hashed character trigrams of each word, L2-normalised. Matches letters, not meaning."""
vec = np.zeros(dim)
for word in words(text):
padded = f"#{word}#"
for i in range(len(padded) - 2):
vec[zlib.crc32(padded[i : i + 3].encode("utf-8")) % dim] += 1.0
norm = np.linalg.norm(vec)
return vec / norm if norm else vec
def cosine(a: np.ndarray, b: np.ndarray) -> float:
na, nb = np.linalg.norm(a), np.linalg.norm(b)
return float(a @ b / (na * nb)) if na and nb else 0.0
@dataclass(frozen=True)
class Passage:
id: str
doc_id: str
title: str
heading: str
text: str
version: int
status: str
lang: str
@property
def embed_text(self) -> str:
return f"{self.title}. {self.heading}. {self.text}"
def load_passages(path: Path) -> list[Passage]:
"""Parse front matter and split the body at '## ' headings; ids are '<doc id>#s<n>'."""
head, body = path.read_text(encoding="utf-8").split("---\n", 2)[1:]
meta = dict(line.split(": ", 1) for line in head.strip().splitlines())
out = []
for n, part in enumerate(re.split(r"^## ", body, flags=re.M)[1:], 1):
heading, _, text = part.partition("\n")
out.append(Passage(f"{meta['id']}#s{n}", meta["id"], meta["title"], heading.strip(), " ".join(text.split()),
int(meta["version"]), meta["status"], meta["lang"]))
return out
def load_corpus(folder: Path) -> list[Passage]:
return [p for f in sorted(folder.glob("*.md")) for p in load_passages(f)]
@dataclass(frozen=True)
class Hit:
passage_id: str
score: float
def embed_all(texts: list[str], embed: Embedder = toy_embed) -> np.ndarray:
"""Step 1: make vectors. Knows nothing about search."""
return np.vstack([embed(t) for t in texts])
def top_k(query_vec: np.ndarray, matrix: np.ndarray, ids: list[str], k: int = 3) -> list[Hit]:
"""Step 2: rank stored vectors. Knows nothing about text or models."""
scores = [cosine(query_vec, row) for row in matrix]
ranked = sorted(zip(ids, scores), key=lambda pair: (-round(pair[1], 9), pair[0]))
return [Hit(i, s) for i, s in ranked[:k]]
class LocalSearch:
"""In-memory baseline: all vectors in one numpy matrix."""
def __init__(self, passages: list[Passage], embed: Embedder = toy_embed, embed_query: Embedder | None = None):
self.passages = {p.id: p for p in passages}
self.embed_query = embed_query or embed # some models want a different prefix for questions
self.ids = [p.id for p in passages]
self.matrix = embed_all([p.embed_text for p in passages], embed)
def search(self, query: str, k: int = 3) -> list[Hit]:
return top_k(self.embed_query(query), self.matrix, self.ids, k)
def best_doc(self, query: str) -> str:
return self.passages[self.search(query, 1)[0].passage_id].doc_idevidencedesk/__init__.py এখনো না থাকলে ফাঁকা ফাইল বানিয়ে নিন। এই পাতার প্রতিটা স্ক্রিপ্ট প্রজেক্ট ফোল্ডার থেকে চালান, যাতে evidencedesk আর corpus খুঁজে পাওয়া যায়।
ছোট নির্ধারিত এমবেডিং ফিক্সচার
toy_embed তিনটা কাজ করে। টেক্সটকে ছোট হাতের অক্ষরে নেয়, খুব প্রচলিত কয়েকটা শব্দ (STOPWORDS) বাদ দেয়, আর বাকি প্রতিটা শব্দকে তিন অক্ষরের টুকরোয় ভাঙে (#re, ret, etu …)। প্রতিটা টুকরো crc32 দিয়ে হ্যাশ করে DIM সংখ্যক ঘরের একটাতে ১ যোগ করা হয়। শেষে ভেক্টরের দৈর্ঘ্য ১ করে দেওয়া হয় (L2 নরমালাইজ)। একই টেক্সট সব মেশিনে সবসময় একই ভেক্টর দেয়, কোনো ডাউনলোড লাগে না। লাইনটা ছোট রাখার জন্য এখানে ১২টা ঘর ছাপানো হয়েছে; আসল ডিফল্ট ২৫৬।
import numpy as np
from evidencedesk.embeddings import toy_embed
v = toy_embed("return a bike", dim=12) # 12 numbers so it fits on a line; the default is 256
print(np.round(v, 3))
print("length:", round(float(np.linalg.norm(v)), 3))
print("same input, same vector:", np.array_equal(toy_embed("return a bike"), toy_embed("return a bike")))[0.267 0. 0.267 0.267 0.267 0. 0.535 0.535 0. 0.267 0.267 0. ]
length: 1.0
same input, same vector: Trueকিছু ঘরে ০.২৬৭-এর বদলে ০.৫৩৫ আছে: দুটো তিন-অক্ষরের টুকরো একই ঘরে পড়েছে। একে বলে হ্যাশ কলিশন, আর মাত্র ১২টা ঘরে এটা সাধারণ। ২৫৬ ঘরে কম হয়, কিন্তু শূন্য হয় না। আসল এমবেডিং মডেলে এই ধরনের ঝামেলা নেই, তবে তাদের নিজস্ব ভুল আছে।
এই ফিক্সচার কী, তা সৎভাবে বলা দরকার। এটা মেলায় অক্ষর, অর্থ নয়। "Return" আর "returning"-এর টুকরো মেলে, তাই কাছাকাছি পড়ে। "Bike" আর "bicycle"-এর প্রায় কিছুই মেলে না, তাই দূরে পড়ে। আসল মডেল শেখে যে এই দুটো শব্দের অর্থ প্রায় এক; আমাদের টয় তা পারে না। এর কাজ হলো র্যাংকিং লজিক টেস্ট করার জন্য নির্দিষ্ট, ব্যাখ্যাযোগ্য ভেক্টর দেওয়া। রিট্রিভালের মান মাপতে এটা কখনো ব্যবহার করবেন না।
কোসাইন সাদৃশ্য
কোসাইন সাদৃশ্য দুটো ভেক্টরের মধ্যের কোণ মাপে: ১ মানে একই দিক, ০ মানে সম্পর্কহীন (সমকোণে), -১ মানে উল্টো দিক। এটা দৈর্ঘ্য উপেক্ষা করে, তাই লম্বা আর ছোট প্যাসেজ একই দিকে থাকতে পারে। ফাংশনটা ছোট: ডট প্রোডাক্টকে দুই দৈর্ঘ্যের গুণফল দিয়ে ভাগ, আর শূন্য ভেক্টরের জন্য শূন্য দিয়ে ভাগ না করে ০.০ ফেরত। বিস্তারিত আবার দেখতে চাইলে AI-এর জন্য গণিত ও পরিসংখ্যান: NumPy দিয়ে মূল বিষয় দেখুন। টেক্সটে এটা কাজে লাগানোর আগে এমন ভেক্টরে টেস্ট করে নিন, যার উত্তর আপনার জানা:
import numpy as np
from evidencedesk.embeddings import cosine, toy_embed
print(round(cosine(np.array([1, 0]), np.array([1, 1])), 3)) # 45 degrees apart
print(round(cosine(np.array([3, 4]), np.array([6, 8])), 3)) # same direction, different length
print(round(cosine(np.array([1, 0]), np.array([0, 1])), 3)) # at right angles
a, b, c = (toy_embed(t) for t in ["return a bike", "returning bikes", "battery charging time"])
print(round(cosine(a, b), 3), round(cosine(a, c), 3))0.707
1.0
0.0
0.676 0.069প্রথম তিন লাইন জানা উত্তরের ক্ষেত্র (৪৫ ডিগ্রিতে ১/√২ = ০.৭০৭; ভেক্টর বড় করলে ফল বদলায় না; সমকোণে ০)। শেষ লাইনটা টেক্সটের প্রথম কাজের ফল: "return a bike"-এর দুটো রূপ ০.৬৭৬ পেয়েছে, আর সম্পর্কহীন বাক্যাংশ ০.০৬৯।
ইনডেক্সের জন্য ডকুমেন্ট প্রস্তুত করুন
ভেক্টর কোথা থেকে এসেছে বলতে না পারলে সেটা অকেজো। কিছু এমবেড করার আগে প্রতিটা প্যাসেজকে একটা স্থিতিশীল আইডি দিন এবং তার মেটাডেটা রাখুন। load_passages ফ্রন্ট ম্যাটার পড়ে, বডিকে প্রতিটা ## হেডিংয়ে ভাঙে, আর <document id>#s<n> আইডিসহ একটা Passage বানায়। একই ফাইল সবসময় একই আইডি দেয়, আর আইডি দেখেই বোঝা যায় কোন ডকুমেন্ট। মেটাডেটা (version, status, lang) প্যাসেজের সঙ্গে থাকে; একটু পরেই status লাগবে। হেডিং ধরে ভাঙা সবচেয়ে সহজ প্রস্তুতি; চাঙ্কিং ঠিকভাবে আছে RAG ইনজেশন ও সাইটেশন প্রবাহ পাতায়।
import textwrap
from pathlib import Path
from evidencedesk.embeddings import load_corpus
passages = load_corpus(Path("corpus"))
print(len(passages), "passages from", len({p.doc_id for p in passages}), "documents")
p = next(p for p in passages if p.id == "kc-returns-v3#s1")
print(p.id, p.doc_id, p.version, p.status, p.lang, p.heading, sep=" | ")
print(textwrap.fill(p.text, 80))36 passages from 13 documents
kc-returns-v3#s1 | kc-returns-v3 | 3 | current | en | Returning a bike
You can return a new, unridden bike within 30 days of delivery for a full
refund. The bike must be in its original box with all parts. We refund the price
of the bike; the delivery fee is refunded only if the bike arrived damaged.লক্ষ করুন, embed_text শিরোনাম, হেডিং ও টেক্সট জুড়ে দেয়, তাই হেডিংয়ের শব্দও মেলাতে সাহায্য করে। সংরক্ষিত টেক্সট আর এমবেড করা টেক্সট দুটো আলাদা জিনিস ভাবুন: একটা বদলালে অন্যটা না-ও বদলাতে পারে।
ছোট লোকাল সার্চ বেসলাইন
এবার সার্চ। LocalSearch ৩৬টা প্যাসেজকে একটা NumPy ম্যাট্রিক্সে এমবেড করে, প্রশ্নটা এমবেড করে, আর প্রতিটা সারিকে কোসাইন দিয়ে র্যাংক করে। এটা সরাসরি ব্রুট ফোর্স (exact), কয়েকশো প্যাসেজের জন্য ঠিক আছে। টাই হলে প্যাসেজ আইডি দিয়ে ভাঙা হয়, তাই ক্রম সবসময় একই থাকে।
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for hit in search.search("How long does the battery charge take?", k=3):
p = search.passages[hit.passage_id]
print(f"{hit.score:.3f} {hit.passage_id:<18} {p.heading}")0.626 kc-battery-v1#s1 Charging
0.555 kc-battery-v1#s3 Warranty
0.496 kc-battery-v1#s2 Storageসবার ওপরে চার্জিং সেকশন, যা ঠিক। তিনটা ফলই ব্যাটারি পাতার, কারণ শিরোনাম এমবেড করা টেক্সটের অংশ। এবার সেই ব্যর্থতা, যা বুঝিয়ে দেয় আসল এমবেডিং কেন দরকার:
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for question in ["Can I return my bike?", "Can I send my bicycle back?"]:
hit = search.search(question, k=1)[0]
p = search.passages[hit.passage_id]
print(f"{question:<30} -> {hit.score:.3f} {p.doc_id} / {p.heading}")Can I return my bike? -> 0.657 kc-returns-v3 / Returning a bike
Can I send my bicycle back? -> 0.353 kc-shipping-v1 / Delivery fee"bike" শব্দ থাকলে সঠিক প্যাসেজ জেতে। একই অর্থের "bicycle" থাকলে টয় গিয়ে পড়ে ডেলিভারি ফি সেকশনে, কারণ প্রায় কিছুই মেলে না: ওই সেকশনের ("a bike" আছে) সঙ্গে শুধু #bi টুকরোটা মেলে, তাই র্যাংকিং প্রায় এলোমেলো। ব্যবহারকারী একই জিনিস জিজ্ঞেস করেছেন; রিট্রিভার শুনেছে অন্য কিছু। বিপুল টেক্সটে ট্রেন করা মডেল এমন প্যারাফ্রেজকে কাছাকাছি রাখার জন্যই বানানো, আর সেটাই মডেল ব্যবহারের কারণ।
সাদৃশ্য স্কোর কী বলতে পারে না
স্কোর শুধু বলে দুটো ভেক্টর কতটা কাছে। প্যাসেজটা প্রশ্নের উত্তর দেয় কি না, সেটা বর্তমান (current) কি না, বা সত্য কি না, তা বলে না। নমুনা ডকুমেন্টের দুটো কেস দেখুন:
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for question in ["Can I return a bike after 14 days?", "Can I pay in instalments?"]:
print(question)
for hit in search.search(question, k=2):
p = search.passages[hit.passage_id]
print(f" {hit.score:.3f} {p.doc_id} (v{p.version}, {p.status}) / {p.heading}")Can I return a bike after 14 days?
0.591 kc-returns-v2 (v2, superseded) / Returning a bike
0.574 kc-returns-v3 (v3, current) / Returning a bike
Can I pay in instalments?
0.259 kc-warranty-v2 (v2, current) / Components
0.214 kc-returns-v3 (v3, current) / How refunds are paidপ্রথমত, "Can I return a bike after 14 days?" প্রশ্নে বাতিল নীতি (১৪ দিন) সামান্য ব্যবধানে বর্তমানটাকে (৩০ দিন) ছাড়িয়ে যায়। যে ব্যবহারকারী সবার ওপরের ফলে ভরসা করবে, সে বেশ ভালো স্কোরসহ ভুল নিয়ম পাবে। দ্বিতীয়ত, "Can I pay in instalments?"-এর উত্তর কোনো ডকুমেন্টেই নেই, তবু রিট্রিভাল একটা র্যাংক করা তালিকা ফেরত দেয়। এখানে স্কোর কম, কিন্তু স্কোরে "উত্তর আছে" বোঝানোর কোনো বাঁধা সীমারেখা নেই: সেটা নির্ভর করে এমবেডার, ডকুমেন্ট সেট ও প্রশ্নের ওপর। স্কোরকে প্রার্থীদের সাজানোর সংকেত হিসেবে দেখুন। প্যাসেজ উত্তরকে সমর্থন করে কি না, তা ঠিক হয় পরে: মেটাডেটা ফিল্টার, সাইটেশন যাচাই ও টেস্ট দিয়ে (পরের পাতাগুলোতে এর কাজ শুরু হয়)।
অনুশীলনী: সেরা মিলের প্যাসেজ খুঁজে বের করুন
"ঠিক মনে হচ্ছে"-কে টেস্টে বদলান। পাঁচটা প্রশ্ন হাতে লেবেল করুন, প্রতিটার উত্তর কোন ডকুমেন্টে আছে তা দিয়ে, তারপর অ্যাসার্ট করুন যে সেরা প্যাসেজ ওই ডকুমেন্ট থেকেই আসে। নিচের টেস্ট ফাইলে আগের সেকশনগুলোর টেস্টও আছে: জানা ভেক্টরে কোসাইন, এমবেডারের নির্ধারিত আচরণ, হাতে বানানো ভেক্টরে সার্চ, স্থিতিশীল আইডি, আর ওপরের দুটো শিক্ষা টেস্ট হিসেবে লেখা। প্যারাফ্রেজের ফাঁকটা xfail(strict=True) দিয়ে চিহ্নিত: স্যুট একটা জানা ব্যর্থতা নথিভুক্ত করে, আর কেউ ঘটনাচক্রে সেটা পাস করালে pytest জানিয়ে দেয়, যাতে আপনি লেবেল হালনাগাদ করতে পারেন।
# file: tests/test_embeddings.py
from pathlib import Path
import numpy as np
import pytest
from evidencedesk.embeddings import LocalSearch, cosine, load_corpus, load_passages, toy_embed, top_k
CORPUS = Path(__file__).resolve().parent.parent / "corpus"
LABELS = [ # hand-labelled: question -> the document that answers it
("How long do I have to return a bike?", "kc-returns-v3"),
("How long does the battery charge take?", "kc-battery-v1"),
("What is the RD2-24 recall?", "kc-recalls-v1"),
("How tall must I be for size M?", "kc-sizing-v1"),
("Are tyres covered by warranty?", "kc-warranty-v2"),
]
def test_cosine_on_known_vectors():
a = np.array([3.0, 4.0])
assert cosine(a, a) == pytest.approx(1.0)
assert cosine(a, np.array([4.0, -3.0])) == pytest.approx(0.0)
assert cosine(a, -a) == pytest.approx(-1.0)
assert cosine(a, np.array([4.0, 3.0])) == pytest.approx(0.96)
assert cosine(a, np.zeros(2)) == 0.0
def test_toy_embed_is_deterministic_and_normalised():
v = toy_embed("Return a bike")
assert np.array_equal(v, toy_embed("Return a bike"))
assert np.linalg.norm(v) == pytest.approx(1.0)
assert not toy_embed("the of a").any() # only stop words -> zero vector
def test_top_k_uses_vectors_only():
matrix = np.array([[1.0, 0.0], [0.0, 1.0], [0.7, 0.7]])
hits = top_k(np.array([1.0, 0.1]), matrix, ["x", "y", "z"], k=2)
assert [h.passage_id for h in hits] == ["x", "z"]
def test_load_passages_gives_stable_ids_and_metadata():
ps = load_passages(CORPUS / "returns-policy.md")
assert [p.id for p in ps] == ["kc-returns-v3#s1", "kc-returns-v3#s2", "kc-returns-v3#s3"]
assert (ps[0].status, ps[0].version, ps[0].lang) == ("current", 3, "en")
ids = [p.id for p in load_corpus(CORPUS)]
assert len(ids) == len(set(ids)) == 36
@pytest.mark.parametrize("question,doc_id", LABELS)
def test_best_passage_comes_from_labelled_document(question, doc_id):
assert LocalSearch(load_corpus(CORPUS)).best_doc(question) == doc_id
@pytest.mark.xfail(strict=True, reason="toy embedder matches letters, not meaning: 'bicycle' is not 'bike'")
def test_paraphrase_is_a_known_gap():
assert LocalSearch(load_corpus(CORPUS)).best_doc("Can I send my bicycle back?") == "kc-returns-v3"
def test_high_score_is_not_proof():
top = LocalSearch(load_corpus(CORPUS)).search("Can I return a bike after 14 days?", 1)[0]
assert top.passage_id == "kc-returns-v2#s1" # the superseded policy winspython -m pytest -q tests/test_embeddings.py | sed 's/ in [0-9.]*s//'.........x. [100%]
10 passed, 1 xfailedফল: দশটা পাস আর একটা প্রত্যাশিত ব্যর্থতা। (কমান্ডটা pytest-এর শেষ লাইন থেকে রান-টাইম সরিয়ে দেয়, যাতে আউটপুট প্রতিবার একই থাকে।) পাঁচটা লেবেল করা প্রশ্ন খেলনা মাত্র; পরের পাতায় বড় সেটে রিট্রিভাল মাপা হবে এবং হিট কীভাবে গোনা যায় দেখানো হবে। এই ধরনের টেস্ট যা দেখাতে পারে না: যে প্রশ্নগুলো আপনি লেবেল করেননি, সেগুলোর জন্য রিট্রিভাল কাজ করে কি না।
ঐচ্ছিক লোকাল এমবেডিং মডেল
টয় টেস্টের জন্য। আসল অর্থের জন্য ট্রেন করা মডেল লাগে। sentence-transformers প্যাকেজ এমন মডেল আপনার নিজের কম্পিউটারে চালায়। LocalSearch এমবেডারকে আর্গুমেন্ট হিসেবে নেয়, তাই পরিবর্তন ছোট। কিছু মডেলে প্রশ্ন আর প্যাসেজের জন্য আলাদা প্রিফিক্স লাগে, সেজন্য LocalSearch আলাদা embed_query নেয়। এই ব্লক ঐচ্ছিক এবং এই কোর্সে চালানো হয়নি: এর জন্য প্যাকেজ ইনস্টল আর মডেল ডাউনলোড লাগে।
# not run here: needs the sentence-transformers package and a model download (hundreds of MB)
from pathlib import Path
from sentence_transformers import SentenceTransformer
from evidencedesk.embeddings import LocalSearch, load_corpus
model = SentenceTransformer("intfloat/multilingual-e5-small") # downloaded on first use, then cached
def embed_passage(text: str):
return model.encode("passage: " + text) # this model expects these prefixes
def embed_query(text: str):
return model.encode("query: " + text)
search = LocalSearch(load_corpus(Path("corpus")), embed=embed_passage, embed_query=embed_query)
print(search.best_doc("ডেলিভারির ফি কত?"))চালালে প্রথম রানে মডেল ডাউনলোড হবে, পরের রানে লোকাল ক্যাশ ব্যবহার হবে। বাংলা প্রশ্নটা ডেলিভারি ফি জানতে চায়; রিট্রিভাল ঠিকঠাক কাজ করলে উত্তর শিপিং ডকুমেন্টের (বাংলা বা ইংরেজি) দিকে ইঙ্গিত করার কথা। বাংলার মান ইংরেজির চেয়ে কম, তাই ধরে না নিয়ে ফলটা যাচাই করুন।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী):
sentence-transformers-এর ভার্সন ছিল 6.1.0 (প্রকাশ ২০২৬-০৯-১৮), লাইসেন্স Apache-2.0, Python 3.10 বা নতুন। মডেল Hugging Face Hub থেকে এনে ডিস্কে ক্যাশ করা হয় (ডিফল্ট ফোল্ডার~/.cache/huggingface/hub,HF_HOMEদিয়ে বদলানো যায়); Windows-এ সিমলিংক না থাকলে ক্যাশ ফাইল কপি করে বেশি ডিস্ক নিতে পারে।all-MiniLM-L6-v2(Apache-2.0, ৩৮৪ ডাইমেনশন, প্রায় ২২.৭ মিলিয়ন প্যারামিটার) শুধু ইংরেজির জন্য।paraphrase-multilingual-MiniLM-L12-v2-এর ভাষা তালিকায় বাংলা নেই, তাই বাংলার জন্য এটা ব্যবহার করবেন না।multilingual-e5-small(MIT, ৩৮৪ ডাইমেনশন, প্রায় ১১৭.৭ মিলিয়ন প্যারামিটার, ওয়েটস ফাইল প্রায় ৪৭১ MB) বাংলা তালিকাভুক্ত করে; এর কার্ডেquery:ওpassage:প্রিফিক্স বাধ্যতামূলক, এবং সতর্কতা আছে যে কম সম্পদের ভাষায় মান কম হতে পারে। মডেল ওয়েটসের নিজস্ব লাইসেন্স আছে, আর সাইজ ও ভার্সন বদলায়: ডাউনলোডের আগে বর্তমান ডকুমেন্টেশন ও মডেল কার্ড দেখে নিন।
হোস্টেড না লোকাল এমবেডিং
হোস্টেড মানে প্রোভাইডারের API-তে টেক্সট পাঠিয়ে ভেক্টর ফেরত পাওয়া; লোকাল মানে মডেল আপনার মেশিনেই চলে। কোনোটাই সব জায়গায় ভালো নয়। টেবিলটা ইচ্ছা করেই গুণগত; নিজের প্রশ্ন দিয়ে মাপুন (পরের পাতায় দেখানো হবে কীভাবে)।
| প্রশ্ন | হোস্টেড API | লোকাল মডেল |
|---|---|---|
| মান | মডেলের ওপর নির্ভর করে; কিছু মডেল বহু ভাষায় শক্তিশালী | মডেলের ওপর নির্ভর করে; ছোট মডেল দুর্বল, আর কম সম্পদের ভাষায় মান পড়তে পারে |
| লেটেন্সি | প্রতি কলে নেটওয়ার্ক যাওয়া-আসা, সঙ্গে প্রোভাইডারের লোড | নেটওয়ার্ক নেই, কিন্তু আপনার মেশিনের CPU বা GPU সময় আর শুরুর লোড |
| প্রাইভেসি | আপনার টেক্সট মেশিনের বাইরে যায়; প্রোভাইডারের ডেটা-সংক্রান্ত শর্ত পড়ুন | টেক্সট আপনার মেশিনেই থাকে |
| স্টোরেজ | ভেক্টর আপনি রাখেন: প্রতিটা ডাইমেনশন ৪ বাইট (৩২-বিট ফ্লোট), তাই ১৫৩৬ ডাইমেনশনে প্রতি প্যাসেজে প্রায় ৬ KB | ভেক্টরের জন্য একই, সঙ্গে ডিস্কে মডেল ফাইল |
| রিসোর্স | অ্যাকাউন্ট, কি (key), নেটওয়ার্ক ও বাজেট | RAM ও ডিস্ক; ডাউনলোড; সাধারণ ল্যাপটপে ধীর হতে পারে |
যেটাই বেছে নিন, একটা মডেলের ভেক্টর শুধু একই মডেলের ভেক্টরের সঙ্গে তুলনা হয়; মডেল মেশানো নিয়ে কথা হবে পরের পাতায়। হোস্টেড অ্যাডাপ্টার হবে ওপরের embed_passage-এর মতো একই গড়নের আরেকটা ফাংশন, আর টেস্ট চলবে টয় বা ফেক দিয়ে, নেটওয়ার্কে নয়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): OpenAI-এর ডকুমেন্টেশনে আছে
client.embeddings.create(input=..., model=...), ভেক্টর পাওয়া যায়response.data[0].embedding-এ; ডকুমেন্টেশনের উদাহরণ মডেলtext-embedding-3-small(ডিফল্ট দৈর্ঘ্য ১৫৩৬), সর্বোচ্চ ইনপুট ৮১৯২ টোকেন, আরdimensionsপ্যারামিটার ভেক্টর ছোট করে (নিজে হাতে ছোট করলে নিজেই নরমালাইজ করুন)। Google-এর ডকুমেন্টেশনে আছেclient.models.embed_content(model=..., contents=...), ভেক্টরresult.embeddings[i].values-এ; তাদের পাতাভেদে উদাহরণ মডেল আইডি আলাদা, তাই বর্তমান পাতা পড়ুন। Ollama-র ডকুমেন্টেশনে আছেollama.embed(model=..., input=...)(REST:POST /api/embed), ভেক্টর L2 নরমালাইজড। Anthropic নিজস্ব এমবেডিং মডেল দেয় না; তাদের ডকুমেন্টেশন একটি প্রোভাইডার হিসেবে Voyage AI-এর কথা বলে, যাদের ডকুমেন্টেশন রিট্রিভালেinput_type="query"বা"document"ব্যবহার করতে বলে। মডেলের নাম, সীমা ও গড়ন প্রায়ই বদলায়: বর্তমান ডকুমেন্টেশন দেখে নিন।
কাজের জায়গায় এটা কোথায় দেখা যায়
ঢাকায় নাদিয়ার দল একটা হেল্প সেন্টারের ওপর সাপোর্ট অ্যাসিস্ট্যান্ট চালায়। তাদের প্রথম রিট্রিভাল বাগ ঠিক ওপরের মতোই: পুরোনো নীতির পাতা ভালো স্কোর নিয়ে সবার আগে এসেছিল, আর অ্যাসিস্ট্যান্ট বাতিল হয়ে যাওয়া নিয়ম উদ্ধৃত করেছিল। তারা প্রম্পটে সারায়নি। মেটাডেটায় status যোগ করেছে, ওই ঘটনার জন্য একটা লেবেল করা প্রশ্ন লিখেছে, আর সেটাকে টেস্ট বানিয়েছে। যে দল এমবেডিং তৈরি আর সার্চ আলাদা রাখে, তারা এক জায়গায় এমবেডিং মডেল বদলে একই লেবেল করা প্রশ্ন আবার চালিয়ে দেখতে পারে ফল ভালো হলো না খারাপ।
সাধারণ ভুল
- উঁচু স্কোরকে প্রমাণ ধরা। স্কোর শুধু নৈকট্য। বাতিল বা অপ্রাসঙ্গিক প্যাসেজও উঁচু স্কোর পেতে পারে। প্যাসেজ ব্যবহারের আগে status, উৎস ও সমর্থন যাচাই করুন, আর লেবেল করা প্রশ্ন দিয়ে টেস্ট করুন।
- টয় এমবেডারে Python-এর
hash()ব্যবহার। এটা প্রতি প্রসেসে সল্ট বদলায়, তাই ভেক্টর (এবং টেস্টের ফল) প্রতি রানে বদলে যায়।zlib.crc32-এর মতো স্থিতিশীল হ্যাশ ব্যবহার করুন। - আইডি আর মেটাডেটা হারানো। শুধু ভেক্টর বলতে পারে না সেটা কোন ডকুমেন্ট, ভার্সন বা ভাষার। শুরু থেকেই প্রতিটা প্যাসেজের সঙ্গে আইডি ও মেটাডেটা রাখুন।
- আলাদা মডেল বা আকারের ভেক্টর মেশানো। তারা আলাদা স্পেসে থাকে; তাদের মধ্যের স্কোরের কোনো অর্থ নেই। একটা ইনডেক্সে একটাই মডেল।
- মডেলের ইনপুট ফরম্যাট ভুলে যাওয়া। কিছু মডেলে
query:ওpassage:-এর মতো প্রিফিক্স লাগে। না দিলে ফল চুপচাপ খারাপ হয়।
নিজে চেষ্টা করুন
- সহজ: হাতে হিসাব করুন
[1, 0]ও[1, 1]-এর কোসাইন, এবং[2, 0]ও[5, 0]-এর কোসাইন। তারপরcosineদিয়ে দুটোই মিলিয়ে দেখুন। - মাঝারি: নির্দিষ্ট ভেক্টরের একটা ডিকশনারি থেকে ফেক এমবেডার লিখুন (প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট পাতার ফেক ক্লায়েন্টের একই ধারণা), আর
embed_allওtop_kদিয়ে দেখান যে "dog" শব্দটা "cat"-এর অনেক কাছে, "car" তার চেয়ে অনেক দূরে। - কঠিন: একটা স্কোর থ্রেশহোল্ড বেছে নিন, যার নিচে সার্চ উত্তর না দিয়ে বিরত থাকবে (abstain)। চারটা প্রশ্নে চালান: একটা স্পষ্ট উত্তরযোগ্য, ১৪ দিনের প্রশ্নটা, একটা উত্তরহীন, আর "When will my parcel arrive?"। থ্রেশহোল্ড কী ঠিক করে আর কী ঠিক করতে পারে না, লিখে রাখুন।
উত্তর
সহজ। প্রথমটা ১ / (১ x ১.৪১৪) = ০.৭০৭। দ্বিতীয়টা ১.০, কারণ দুটোই একই দিকে, আর দৈর্ঘ্য উপেক্ষিত হয়।
import numpy as np
from evidencedesk.embeddings import cosine
print(round(cosine(np.array([1, 0]), np.array([1, 1])), 3))
print(round(cosine(np.array([2, 0]), np.array([5, 0])), 3))0.707
1.0মাঝারি। এমবেডার একটা ল্যামডা, যা টেক্সট ধরে ভেক্টর খুঁজে দেয়। মডেল নেই, ডাউনলোড নেই, আর র্যাংকিং পুরোপুরি আপনার লেখা সংখ্যা দিয়ে ব্যাখ্যা করা যায়।
import numpy as np
from evidencedesk.embeddings import embed_all, top_k
VECTORS = {"cat": [1.0, 0.0], "dog": [0.9, 0.1], "car": [0.0, 1.0]}
fake_embed = lambda text: np.array(VECTORS[text])
matrix = embed_all(["dog", "car"], fake_embed)
for hit in top_k(fake_embed("cat"), matrix, ["dog", "car"], k=2):
print(hit.passage_id, round(hit.score, 3))dog 0.994
car 0.0কঠিন। এই চারটা প্রশ্নে ০.৩ থ্রেশহোল্ড মিলে যায়। এটা ঠিকভাবে instalments প্রশ্নে বিরত থাকে। কিন্তু parcel প্রশ্নেও বিরত থাকে, যার উত্তর ডকুমেন্টে আছে (শিপিং), কারণ শিপিং পাতায় "delivery" লেখা, "parcel" নয়, আর টয়ের সঙ্গে কোনো শব্দ মেলে না। আর ১৪ দিনের প্রশ্নকে ০.৫৯১ স্কোরে পার করে দেয়, যদিও ফেরত আসছে বাতিল হওয়া নীতি। থ্রেশহোল্ড কিছু গোলমাল কমাতে পারে; প্যাসেজ বর্তমান কি না বা উত্তরকে সমর্থন করে কি না, তা বলতে পারে না।
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
THRESHOLD = 0.3
questions = [
"How long do I have to return a bike?", # answerable, right passage
"Can I return a bike after 14 days?", # answerable, but the old policy wins
"Can I pay in instalments?", # not answerable
"When will my parcel arrive?", # answerable (shipping), but no shared words
]
for q in questions:
hit = search.search(q, k=1)[0]
verdict = "answer " if hit.score >= THRESHOLD else "abstain"
print(f"{verdict} {hit.score:.3f} {hit.passage_id:<18} {q}")answer 0.555 kc-returns-v3#s1 How long do I have to return a bike?
answer 0.591 kc-returns-v2#s1 Can I return a bike after 14 days?
abstain 0.259 kc-warranty-v2#s2 Can I pay in instalments?
abstain 0.217 kc-warranty-v2#s1 When will my parcel arrive?পাস চেকলিস্ট:
- সহজ: আপনি ০.৭০৭ ও ১.০ পেয়েছেন এবং বলতে পারেন কেন দৈর্ঘ্য কোনো ব্যাপার নয়।
- মাঝারি: আপনার ফেক ভেক্টরে "dog" আগে এসেছে, আর কোনো মডেল লোড হয়নি।
- কঠিন: থ্রেশহোল্ডের দুটো সীমা আপনি বলেছেন, একটা উত্তরযোগ্য প্রশ্ন বাদ পড়ার, আরেকটা ভুল প্যাসেজ তবু পার হওয়ার। মনে রাখুন, কনটেক্সট সাজানো ও বিশ্বাসের সীমানা যেমন দেখিয়েছে, রিট্রিভ করা টেক্সট অবিশ্বস্ত ইনপুট: উঁচু স্কোর প্যাসেজকে বাড়তি বিশ্বাস এনে দেয় না।
সারসংক্ষেপ
- এমবেডিং হলো রিট্রিভালের জন্য নির্দিষ্ট দৈর্ঘ্যের সংখ্যাভিত্তিক উপস্থাপন, সারসংক্ষেপ বা ব্যাখ্যা নয়।
- এমবেডিং তৈরি (টেক্সট থেকে ভেক্টর) আর সার্চ (ভেক্টর থেকে র্যাংকিং) আলাদা রাখুন, যাতে প্রতিটা আলাদা টেস্ট করা যায়।
- স্থিতিশীল হ্যাশের নির্ধারিত টয় এমবেডার বারবার চালানো যায় এমন র্যাংকিং টেস্ট দেয়, কিন্তু সে অক্ষর মেলায়, অর্থ নয়, তাই প্যারাফ্রেজ মিস করে।
- কোসাইন সাদৃশ্য জানা ভেক্টরে লিখে টেস্ট করা সহজ; উঁচু স্কোর একটা সংকেত, প্যাসেজ যে উত্তর দেয় বা বর্তমান, তার প্রমাণ নয়।
- প্রতিটা প্যাসেজকে স্থিতিশীল আইডি ও মেটাডেটা দিন, লেবেল করা মিনি সেটকে টেস্ট হিসেবে রাখুন, আর হোস্টেড বা লোকাল বেছে নিন নিজের প্রশ্নে মেপে।
এরপর: লোকাল ইনডেক্স ও রিট্রিভালের মান — ইনডেক্সের সঙ্গে এমবেডিং মডেলের পরিচয় রাখা, রিকল মাপা, মেটাডেটা দিয়ে ফিল্টার করা এবং লোকাল ডেটাবেসে ভেক্টর রাখা।