অধ্যায় 4 · এমবেডিং ও লোকাল ভেক্টর সার্চ
লোকাল ইনডেক্স ও রিট্রিভালের মান
- পৃষ্ঠা 8 / 22
- 22 মিনিট পড়া
আগের পাতার সার্চ মেমরিতে থাকত, প্রোগ্রাম থামলেই সব ভুলে যেত। বাস্তব অ্যাসিস্ট্যান্টের আরও কিছু লাগে: সেভ করে আবার খোলা যায় এমন ইনডেক্স, কোন মডেল ভেক্টর বানিয়েছে তার রেকর্ড, অকেজো টেক্সট বাদ দেওয়ার উপায়, পুরোনো পাতা সরিয়ে রাখার ফিল্টার, আর রিট্রিভাল আদৌ ভালো কি না তা বলে দেওয়া একটা সংখ্যা। এই পাতায় চলমান প্রজেক্ট EvidenceDesk-এ এই সবকিছু যোগ হবে একটাই মডিউলে, evidencedesk/index.py। সবকিছু অফলাইনে চলে, এমবেডিং ও সাদৃশ্য পাতার টয় এমবেডার দিয়ে।
যা শিখবেন
- ইনডেক্সের সঙ্গে এমবেডিং মডেলের পরিচয় কীভাবে রাখবেন, আর মডেল বদলালে কেন ইনডেক্স নতুন করে বানাতে হয়।
- ব্যাচে এমবেড করা, আর খালি বা খুব ছোট টেক্সট নিয়ে কী করবেন।
- ছোট লেবেল করা সেটে রিকল কীভাবে মাপবেন, আর নিখুঁত (exact) ও আনুমানিক (approximate) সার্চ কীভাবে গতি ও রিকলের মধ্যে ট্রেড-অফ করে।
- প্রমাণ ব্যবহারের আগেই মেটাডেটা দিয়ে ফিল্টার করা, ইনডেক্স সেভ করে আবার খোলা, আর একটা লোকাল ভেক্টর ডেটাবেস চেষ্টা করা।
- শেষে এমন একটা টেস্ট স্যুট, যা নমুনা কর্পাসের র্যাংকিং আটকে রাখে।
একটা মডিউল, সাতটা প্রতিশ্রুতি
নিচের প্রতিটা কাজ index.py-র একটা ছোট, টেস্ট করা যায় এমন অংশ পায়। ফাইলটা একবার পুরোটা পড়ুন; পরের অংশগুলোতে প্রতিটা টুকরো চালানো হবে। মডিউল শুধু সেটাই ইমপোর্ট করে, যা embeddings.py আগেই দিয়েছে।
| কাজ | কোথায় আছে |
|---|---|
| মডেলের পরিচয় | Model, IndexMeta, IndexMismatch |
| বদলালে নতুন করে বানানো | ensure_index |
| ব্যাচিং | embed_batches, counting |
| অকেজো টেক্সট | unusable_reason, Skipped |
| ফিল্টার ও সার্চ | VectorIndex.search(..., where=) |
| সেভ করা | VectorIndex.save, VectorIndex.load |
| মান | recall_at_k |
# file: evidencedesk/index.py
"""A local vector index that remembers its embedding model, skips unusable text, filters and persists."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Callable
import numpy as np
from evidencedesk.embeddings import Hit, Passage, embed_all, top_k, toy_embed, words
BatchEmbedder = Callable[[list[str]], np.ndarray]
MIN_CHARS = 3
class IndexMismatch(ValueError):
"""The index and the model that wants to use it are not compatible."""
@dataclass(frozen=True)
class Model:
"""An embedding function plus the identity that must travel with its vectors."""
name: str
dim: int
embed_batch: BatchEmbedder
normalised: bool = True
@classmethod
def toy(cls, dim: int = 256) -> Model:
return cls(f"toy-trigram-{dim}", dim, lambda texts: embed_all(texts, lambda t: toy_embed(t, dim)))
def counting(model: Model) -> tuple[Model, list[int]]:
"""Wrap a model; the list collects the size of every embed_batch call."""
sizes: list[int] = []
def embed_batch(texts: list[str]) -> np.ndarray:
sizes.append(len(texts))
return model.embed_batch(texts)
return Model(model.name, model.dim, embed_batch, model.normalised), sizes
@dataclass(frozen=True)
class IndexMeta:
model: str
dim: int
normalised: bool
created_at: str # from an injected clock, so tests do not depend on the date
def check(self, model: Model) -> None:
if (self.model, self.dim) != (model.name, model.dim):
raise IndexMismatch(f"index built by {self.model} ({self.dim} dims), query uses {model.name} "
f"({model.dim} dims): rebuild the index")
@dataclass(frozen=True)
class Skipped:
id: str
reason: str # empty | too_short | no_words
def unusable_reason(text: str) -> str | None:
"""Why a text must not be embedded, or None. Never embed whitespace."""
stripped = text.strip()
if not stripped:
return "empty"
if len(stripped) < MIN_CHARS:
return "too_short"
return None if words(stripped) else "no_words" # only stop words: the toy vector would be all zeros
def embed_batches(texts: list[str], model: Model, batch_size: int = 16) -> np.ndarray:
"""Call the model once per batch of batch_size texts, not once per text."""
if batch_size < 1:
raise ValueError("batch_size must be at least 1")
parts = [model.embed_batch(texts[i : i + batch_size]) for i in range(0, len(texts), batch_size)]
return np.vstack(parts) if parts else np.zeros((0, model.dim))
def payload_of(p: Passage) -> dict:
return {"doc_id": p.doc_id, "title": p.title, "version": p.version, "status": p.status, "lang": p.lang}
class VectorIndex:
def __init__(self, meta: IndexMeta, ids: list[str], matrix: np.ndarray, payloads: list[dict]):
if matrix.shape != (len(ids), meta.dim) or len(payloads) != len(ids):
raise IndexMismatch("ids, payloads and vectors do not fit together")
self.meta, self.ids, self.matrix, self.payloads = meta, ids, matrix, payloads
@classmethod
def build(cls, passages: list[Passage], model: Model, clock: Callable[[], str],
batch_size: int = 16) -> tuple[VectorIndex, list[Skipped]]:
keep, skipped = [], []
for p in passages:
reason = unusable_reason(p.text)
if reason:
skipped.append(Skipped(p.id, reason))
else:
keep.append(p)
matrix = embed_batches([p.embed_text for p in keep], model, batch_size)
meta = IndexMeta(model.name, model.dim, model.normalised, clock())
return cls(meta, [p.id for p in keep], matrix, [payload_of(p) for p in keep]), skipped
def __len__(self) -> int:
return len(self.ids)
def search(self, query: str, model: Model, k: int = 3, where: dict | None = None) -> list[Hit]:
"""Refuse a foreign model, filter first, then rank only what is allowed. A blank query finds nothing."""
self.meta.check(model)
if unusable_reason(query):
return []
rows = [i for i, pl in enumerate(self.payloads) if all(pl.get(key) == v for key, v in (where or {}).items())]
if not rows:
return []
return top_k(model.embed_batch([query])[0], self.matrix[rows], [self.ids[i] for i in rows], k)
def save(self, path: Path) -> None:
header = json.dumps({"meta": asdict(self.meta), "ids": self.ids, "payloads": self.payloads})
with open(path, "wb") as f:
np.savez(f, matrix=self.matrix, header=np.array(header))
@classmethod
def load(cls, path: Path) -> VectorIndex:
with np.load(path, allow_pickle=False) as data:
header, matrix = json.loads(str(data["header"])), data["matrix"]
return cls(IndexMeta(**header["meta"]), header["ids"], matrix, header["payloads"])
def ensure_index(index: VectorIndex | None, passages: list[Passage], model: Model, clock: Callable[[], str],
batch_size: int = 16) -> tuple[VectorIndex, bool]:
"""Keep a compatible index; otherwise rebuild from the source passages. Returns (index, rebuilt)."""
if index is not None:
try:
index.meta.check(model)
return index, False
except IndexMismatch:
pass
return VectorIndex.build(passages, model, clock, batch_size)[0], True
def doc_ids(index: VectorIndex, hits: list[Hit]) -> list[str]:
by_id = dict(zip(index.ids, index.payloads))
return [by_id[h.passage_id]["doc_id"] for h in hits]
def recall_at_k(index: VectorIndex, model: Model, labels: list[tuple[str, str]], k: int = 3) -> tuple[float, list[str]]:
"""recall@k = questions whose expected document is among the documents of the top k passages / all questions."""
misses = [q for q, expected in labels if expected not in doc_ids(index, index.search(q, model, k))]
return 1 - len(misses) / len(labels), missesফাইলটার শুধু NumPy লাগে। ভেক্টরগুলো একটা NumPy ম্যাট্রিক্সে থাকে, তাই "ইনডেক্স" মানে একটা ম্যাট্রিক্স, আইডির একটা তালিকা আর মেটাডেটা ডিকশনারির একটা তালিকা।
ইনডেক্সের সঙ্গে এমবেডিং মডেলের পরিচয় রাখুন
ভেক্টরের অর্থ আছে শুধু যে মডেল বানিয়েছে তার সঙ্গে মিলিয়ে। তাই ইনডেক্স একটা IndexMeta রাখে: মডেলের নাম, ভেক্টরের দৈর্ঘ্য, ভেক্টরের দৈর্ঘ্য ১ কি না (নরমালাইজড), আর ইনডেক্স কখন তৈরি হয়েছে। তৈরির সময় আসে আপনার দেওয়া ক্লক ফাংশন থেকে, তাই টেস্টে তা স্থির করা যায়। মডেলের ভার্সন নামের মধ্যেই রাখুন (যেমন toy-trigram-256-v2), কারণ ভেক্টরের দৈর্ঘ্য না বদলেও মডেল বদলে যেতে পারে।
from pathlib import Path
from evidencedesk.embeddings import Passage, load_corpus
from evidencedesk.index import IndexMismatch, Model, VectorIndex, counting, ensure_index, recall_at_k
CLOCK = lambda: "2026-10-12T09:00:00Z" # injected, so the output is the same on every run
show = lambda hits: [(h.passage_id, round(h.score, 3)) for h in hits]
passages = load_corpus(Path("corpus"))
model = Model.toy()
index, skipped = VectorIndex.build(passages, model, CLOCK)
print(len(index), "indexed,", len(skipped), "skipped")
print(index.meta)36 indexed, 0 skipped
IndexMeta(model='toy-trigram-256', dim=256, normalised=True, created_at='2026-10-12T09:00:00Z')১৩টা নমুনা ডকুমেন্টের ৩৬টা প্যাসেজের সবই ইনডেক্স হয়েছে, কোনোটা বাদ পড়েনি। show হেল্পার হিটগুলোকে ছাপার জন্য ছোট জোড়ায় বদলায়।
উপস্থাপনা বদলালে ইনডেক্স নতুন করে বানান
দুই আলাদা এমবেডারের ভেক্টর আলাদা স্পেসে থাকে। তাদের তুলনা করলে সংখ্যা আসে, কিন্তু সংখ্যাগুলোর কোনো অর্থ নেই, আর কিছু ক্র্যাশও করে না যে আপনি টের পাবেন। তাই search আগে পরিচয় মেলায় এবং না মিললে প্রত্যাখ্যান করে। পুরোনো ভেক্টরকে নতুন মডেলের জন্য বদলে নেওয়ার নিরাপদ কোনো উপায় নেই: দরকার মূল টেক্সট আর নতুন করে এমবেড করা। ensure_index সামঞ্জস্যপূর্ণ ইনডেক্স রেখে দেয়, নইলে প্যাসেজ থেকে নতুন একটা বানায়।
other = Model.toy(dim=64) # pretend we switched to a different embedder: new name, new size
try:
index.search("battery", other)
except IndexMismatch as error:
print("refused:", error)
rebuilt, did_rebuild = ensure_index(index, passages, other, CLOCK)
print(did_rebuild, rebuilt.meta.model, rebuilt.matrix.shape)
same, did_rebuild = ensure_index(index, passages, model, CLOCK)
print(did_rebuild, same is index)refused: index built by toy-trigram-256 (256 dims), query uses toy-trigram-64 (64 dims): rebuild the index
True toy-trigram-64 (36, 64)
False Trueপ্রথম লাইনটাই সেই এরর, প্রোডাকশনে যেটা দেখতে চান, নীরবে ভুল উত্তরের বদলে। দ্বিতীয় লাইন ৬৪ সংখ্যার নতুন মডেল দিয়ে পুনর্নির্মাণ দেখায়; তৃতীয়টা দেখায় সামঞ্জস্যপূর্ণ ইনডেক্সে হাত পড়ে না। বড় কর্পাসে মাইগ্রেশন পরিকল্পনা করুন: নতুন ইনডেক্স পুরোনোটার পাশে বানান, দুটোকেই আপনার লেবেল করা প্রশ্নে তুলনা করুন (নিচে দেখুন), তারপর বদলান।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): Google-এর এমবেডিং ডকুমেন্টেশনে বলা আছে,
gemini-embedding-001ওgemini-embedding-2-এর এমবেডিং স্পেস সামঞ্জস্যপূর্ণ নয়, তাই একটা থেকে অন্যটায় গেলে সব ডেটা নতুন করে এমবেড করতে হয়। আমরা যে OpenAI পাতা পড়েছি তাতে এ রকম স্পষ্ট বক্তব্য নেই, তাই "মডেল মিশিয়ে ফেলবেন না" নিয়মটাকে আমাদের নিজের নিয়ম ভাবুন, ওই পাতার উদ্ধৃতি নয়। মডেলের নাম ও সীমা প্রায়ই বদলায়: বর্তমান ডকুমেন্টেশন দেখে নিন।
এমবেডিংয়ের কাজ ব্যাচে করুন
হোস্টেড এমবেডিং API প্রতি কলে একটা নেটওয়ার্ক রাউন্ড ট্রিপ নেয়, আর লোকাল মডেলের প্রতি কলে চালু হওয়ার খরচ থাকে। ৩৬টা টেক্সট একটা একটা করে পাঠালে ৩৬ রাউন্ড ট্রিপ; ১৬টার দলে পাঠালে ৩টা। embed_batches টেক্সটগুলোকে নির্দিষ্ট আকারের দলে কাটে। আকার বাছাই একটা ট্রেড-অফ: বড় ব্যাচে কল কমে, কিন্তু মেমরি বেশি লাগে আর প্রোভাইডারের ইনপুট সীমা ছাড়ানোর ঝুঁকি বাড়ে। তাই ডকুমেন্টেশনে সীমা পড়ুন এবং আকারটা সেটিং হিসেবে রাখুন। counting হেল্পার একটা মডেলকে মুড়ে প্রতিটা কলের আকার লিখে রাখে; এভাবে ঘড়ি ছাড়াই ব্যাচিং টেস্ট করা যায়।
counted, sizes = counting(model)
VectorIndex.build(passages, counted, CLOCK, batch_size=16)
print("batch sizes:", sizes)
one_by_one, sizes = counting(model)
VectorIndex.build(passages, one_by_one, CLOCK, batch_size=1)
print("calls with batch_size=1:", len(sizes))batch sizes: [16, 16, 4]
calls with batch_size=1: 36টয় এমবেডারের ব্যাচ লাগে না; তবু কোডের গড়ন বাস্তব API-র মতো (ভেতরে তালিকা, বাইরে ম্যাট্রিক্স), তাই পরে আসল অ্যাডাপ্টার সহজে বসে যায়।
খালি ও খুব ছোট ইনপুট সামলান
অকেজো টেক্সট নিয়ে ইনডেক্সার কী করবে, তা একবারই ঠিক করুন। আমাদের নিয়ম: বাদ দিন, জানান, কখনো ক্র্যাশ নয়, ফাঁকা জায়গা কখনো এমবেড নয়। কোনো প্যাসেজ অকেজো, যদি তার টেক্সট খালি বা শুধু ফাঁকা জায়গা হয় (empty), ৩ অক্ষরের কম হয় (too_short), অথবা শুধু স্টপ ওয়ার্ড দিয়ে তৈরি হয়, ফলে টয় এমবেডার শূন্য ভেক্টর দিত (no_words)। বাদ পড়া প্যাসেজগুলো একটা তালিকায় ফেরত আসে, যাতে কলার সেগুলো লগ করতে বা বিল্ড ব্যর্থ করতে পারে। ফাঁকা প্রশ্ন এলোমেলো-দেখতে হিট না দিয়ে কিছুই খুঁজে পায় না।
texts = ["", " \n ", "ok", "the of a", "Charge it fully before storing."]
odd = [Passage(f"t#s{n}", "t", "Title", "Head", text, 1, "current", "en") for n, text in enumerate(texts)]
counted, sizes = counting(model)
small, skipped = VectorIndex.build(odd, counted, CLOCK)
print("indexed:", small.ids)
print("skipped:", [(s.id, s.reason) for s in skipped])
print("texts sent to the model:", sum(sizes))
print("empty input:", len(VectorIndex.build([], model, CLOCK)[0]), "| blank query:", index.search(" ", model))indexed: ['t#s4']
skipped: [('t#s0', 'empty'), ('t#s1', 'empty'), ('t#s2', 'too_short'), ('t#s3', 'no_words')]
texts sent to the model: 1
empty input: 0 | blank query: []মডেলের কাছে মাত্র একটা টেক্সট পৌঁছেছে। লক্ষ করুন, বাদ পড়া প্যাসেজগুলোর শিরোনাম আছে, কিন্তু শুধু শিরোনাম উত্তর নয়, তাই চেকটা বডি টেক্সট দেখে।
ছোট ডেটাসেটে রিট্রিভাল রিকল মাপুন
হাতে প্রশ্ন লেবেল করুন, প্রতিটার উত্তর যে ডকুমেন্টে আছে তার নামসহ, তারপর জিজ্ঞেস করুন: সঠিক ডকুমেন্ট কি সেরা ফলাফলের মধ্যে আসছে? recall@k = যে প্রশ্নগুলোর প্রত্যাশিত ডকুমেন্ট সেরা k প্যাসেজের ডকুমেন্টের মধ্যে আছে, সেগুলোর সংখ্যা ÷ সব প্রশ্নের সংখ্যা। কোড হিট গোনে এবং যে প্রশ্নগুলো মিস হয়েছে সেগুলো ফেরত দেয়, যাতে আপনি পড়ে দেখতে পারেন।
LABELS = [ # question -> the document that answers it (a smoke test: 8 questions, not a benchmark)
("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"),
("When do gift cards expire?", "kc-giftcards-v1"),
("How much does delivery cost?", "kc-shipping-v1"),
("How do I get my money back?", "kc-returns-v3"), # paraphrase: shares few letters with the page
("Where can I try a bike before buying?", "kc-sizing-v1"), # the page says "test ride"
]
for k in (1, 3, 5):
recall, misses = recall_at_k(index, model, LABELS, k)
print(f"recall@{k} = {recall:.3f} ({len(misses)} of {len(LABELS)} questions missed)")
print("still missed at k=5:", misses)recall@1 = 0.750 (2 of 8 questions missed)
recall@3 = 0.750 (2 of 8 questions missed)
recall@5 = 0.875 (1 of 8 questions missed)
still missed at k=5: ['Where can I try a bike before buying?']পড়ার উপায়: k ১ থেকে ৫ করলে একটা প্রশ্ন উপকৃত হয় ("money back" প্যারাফ্রেজ, যার উত্তর ৪ নম্বরে), কিন্তু "try a bike before buying" কখনোই আসে না, কারণ পাতায় লেখা "test ride" আর টয় এমবেডার অক্ষর মেলায়, অর্থ নয়। এই আটটা প্রশ্ন একটা স্মোক টেস্ট, বেঞ্চমার্ক নয়: একটা প্রশ্নে রিকল ০.১২৫ নড়ে, আর যে প্রশ্নে সিস্টেম টিউন করেন সেগুলোতেই মাপলে মাপছেন মুখস্থ, মান নয়। কিছু প্রশ্ন আলাদা করে রাখুন, যেগুলোতে কখনো টিউন করবেন না, আর সেটটা বাড়ান ব্যবহারকারীদের আসল প্রশ্ন থেকে। রিকল এটাও বলে না যে প্যাসেজ উত্তরকে সমর্থন করে কি না।
নিখুঁত ও আনুমানিক সার্চের তুলনা
নিখুঁত (exact) সার্চ প্রশ্নকে সংরক্ষিত প্রতিটা ভেক্টরের সঙ্গে মেলায়, তাই সবসময় আসল নিকটতমগুলো পায়, কিন্তু কাজ ভেক্টরের সংখ্যার সঙ্গে বাড়ে। আনুমানিক (approximate) সার্চ (approximate nearest neighbour, ANN) এমন কাঠামো বানায়, যাতে ভেক্টরের একটা অংশই দেখতে হয়। বড় সেটে এটা দ্রুত, কিন্তু আসল প্রতিবেশী মিস করতে পারে। এটাই গতি ও রিকলের ট্রেড-অফ। ৩৬টা প্যাসেজের জন্য নিখুঁত সার্চই ঠিক; ট্রেড-অফটা গুরুত্বপূর্ণ হয় লক্ষ লক্ষ ভেক্টরে।
নিচের ব্লকটা একটা ক্লাসরুম মডেল, আসল ANN লাইব্রেরি নয়। এটা তৈরি করা ৩,০০০ ভেক্টরকে এলোমেলো হাইপারপ্লেন দিয়ে বাকেটে কাটে, প্রশ্নের বাকেটের (চাইলে এক বিট দূরের বাকেটগুলোরও) ভেক্টরই শুধু স্কোর করে, আর নিখুঁত সেরা ১০-এর সঙ্গে তুলনা করে। সিড স্থির। "examined" হলো স্কোর করা ভেক্টরের অংশ, কাজের একটা স্ট্যান্ড-ইন মাপ; এই পাতা ঘড়ির সময় (wall-clock) মাপে না, তা নিয়ে কোনো দাবিও করে না।
import numpy as np
rng = np.random.default_rng(7) # fixed seed: same numbers on every run
centres = rng.normal(size=(30, 32)) # 30 topics, 100 noisy vectors each: 3000 vectors of 32 numbers
unit = lambda m: m / np.linalg.norm(m, axis=-1, keepdims=True)
data = unit(np.repeat(centres, 100, axis=0) + 0.5 * rng.normal(size=(3000, 32)))
queries = unit(centres[rng.integers(0, 30, 50)] + 0.5 * rng.normal(size=(50, 32)))
def exact(q, k=10): # compare with every vector
return set(np.argsort(-(data @ q))[:k])
class Buckets:
"""Classroom model, not a real ANN library: random hyperplanes cut the space into 2**bits buckets."""
def __init__(self, bits, seed=1):
self.planes = np.random.default_rng(seed).normal(size=(bits, 32))
self.weights = 1 << np.arange(bits)
keys = ((data @ self.planes.T) > 0) @ self.weights
self.members = {int(key): np.flatnonzero(keys == key) for key in set(keys)}
def search(self, q, k=10, neighbours=False):
key = int(((self.planes @ q) > 0) @ self.weights)
keys = [key] + ([key ^ w for w in self.weights] if neighbours else []) # own bucket (+ buckets one bit away)
found = np.concatenate([self.members.get(x, np.array([], dtype=int)) for x in keys])
best = found[np.argsort(-(data[found] @ q))[:k]] # exact scoring, but only of the candidates
return set(best), len(found)
print(f"{'method':<22}{'examined':>9}{'recall@10':>11}")
print(f"{'exact (all vectors)':<22}{'100.0%':>9}{1.0:>11.3f}")
for bits, neighbours in [(4, False), (6, False), (6, True), (8, True)]:
index_ = Buckets(bits)
runs = [index_.search(q, neighbours=neighbours) for q in queries]
recall = np.mean([len(found & exact(q)) / 10 for (found, _), q in zip(runs, queries)])
share = np.mean([n for _, n in runs]) / len(data)
name = f"{bits} bits" + (" + neighbours" if neighbours else "")
print(f"{name:<22}{share:>9.1%}{recall:>11.3f}")method examined recall@10
exact (all vectors) 100.0% 1.000
4 bits 7.4% 0.482
6 bits 2.4% 0.338
6 bits + neighbours 13.2% 0.744
8 bits + neighbours 5.2% 0.566বাকেট বেশি হলে কম ভেক্টর দেখা হয় কিন্তু রিকল কমে; পাশের বাকেটে খুঁজলে বেশি কাজের বিনিময়ে রিকল ফেরত আসে। এই সংখ্যাগুলো এই খেলনা আর এই বানানো ডেটার; কোনো আসল লাইব্রেরি সম্পর্কে এগুলো কিছু বলে না।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): Qdrant-এর ডকুমেন্টেশনে Dot, Cosine, Euclid ও Manhattan দূরত্বের তালিকা আছে; Cosine-এর ক্ষেত্রে ভেক্টর যোগ করার সময় একবার নরমালাইজ করা হয়, তারপর তুলনা ডট প্রোডাক্টের সমতুল্য। এর সার্চ প্যারামিটারে একটা
exactঅপশন আছে, যাকে বলা হয়েছে আনুমানিক সার্চ না ব্যবহার করা, আর সতর্ক করা হয়েছে যে পূর্ণ স্ক্যানে অনেক সময় লাগতে পারে। ডেন্স ভেক্টরের জন্য এটা HNSW ব্যবহার করে, যা একটা আনুমানিক ইনডেক্স; কনফিগারেশন নোটে আছে, বড়mসার্চ আরও নিখুঁত করে কিন্তু বেশি জায়গা লাগে, আর বড়ef_constructসার্চ আরও নিখুঁত করে কিন্তু বানাতে বেশি সময় লাগে। পাতাটা কোনো রিকলের সংখ্যা দেয় না। বর্তমান ডকুমেন্টেশন দেখে নিন।
মেটাডেটা দিয়ে ফলাফল ফিল্টার করুন
ইনডেক্সের প্রতিটা সারির একটা পেলোড আছে: doc_id, title, version, status ও lang। search(..., where={...}) শুধু সেই সারিগুলো রাখে, যাদের পেলোড প্রতিটা কি-র সঙ্গে মেলে, তারপর র্যাংক করে। একই কৌশলে ভাষার ফিল্টার, ডকুমেন্টের ফিল্টার বা অ্যাক্সেস ফিল্টার ("এই ব্যবহারকারী যেসব ডকুমেন্ট পড়তে পারেন শুধু সেগুলো") চলে। কোনো টেক্সট প্রম্পটে পৌঁছানোর আগেই ফিল্টার চালাতে হবে: যে প্যাসেজ ব্যবহারকারীর দেখার কথা নয়, তা রিট্রিভই হওয়া চলবে না। ফিল্টার র্যাংকিংয়ের আগে চালান, পরে নয়, নইলে ফলাফল হারান।
Q = "How long do I have to return a bike?"
for where in (None, {"status": "current"}, {"lang": "bn"}, {"doc_id": "kc-returns-v2"}):
print(where, "->", show(index.search(Q, model, 3, where)))
after = [h for h in index.search(Q, model, 2) if index.payloads[index.ids.index(h.passage_id)]["status"] == "current"]
print("filter after top-2 keeps", len(after), "| filter before keeps", len(index.search(Q, model, 2, {"status": "current"})))None -> [('kc-returns-v3#s1', 0.555), ('kc-returns-v2#s1', 0.463), ('kc-warranty-v2#s1', 0.403)]
{'status': 'current'} -> [('kc-returns-v3#s1', 0.555), ('kc-warranty-v2#s1', 0.403), ('kc-returns-v3#s2', 0.383)]
{'lang': 'bn'} -> [('kc-returns-v3-bn#s2', 0.197), ('kc-returns-v3-bn#s1', 0.137), ('kc-shipping-v1-bn#s2', 0.113)]
{'doc_id': 'kc-returns-v2'} -> [('kc-returns-v2#s1', 0.463), ('kc-returns-v2#s2', 0.234)]
filter after top-2 keeps 1 | filter before keeps 2ফিল্টার ছাড়া বাতিল হওয়া রিটার্ন নীতি দুই নম্বরে আসে। status-কে current করলে সেটা সরে যায় আর তালিকা আবার পূর্ণ হয়। শেষ লাইন দেখায় ক্রম কেন গুরুত্বপূর্ণ: সেরা ২ নেওয়ার পরে পুরোনো সারি বাদ দিলে থাকে মাত্র ১টা; আগে ফিল্টার করলে ২টা। ফিল্টার কোডের একটা নিয়ম। এটা বাকি প্যাসেজগুলোকে সঠিক করে দেয় না, আর ব্যবহারকারীর অ্যাক্সেসের ফিল্টারও কোডেই থাকে, প্রম্পটে কখনো নয় (দেখুন কনটেক্সট সাজানো ও বিশ্বাসের সীমানা)।
ইনডেক্স সেভ ও আবার খোলা টেস্ট করুন
যে ইনডেক্স আবার খোলা যায় না, তা প্রতিবার চালু হওয়ার সময় নতুন করে বানাতে হয়; তাতে সময় যায়, আর হোস্টেড মডেলে টাকাও। save ম্যাট্রিক্স, মেটাডেটা আর পেলোড একটা NumPy .npz ফাইলে লেখে; load সেগুলো পড়ে আর গড়ন (shape) মেলে কি না দেখে। যেভাবে ব্যবহার করবেন সেভাবেই টেস্ট করুন: সেভ করুন, একটা নতুন অবজেক্ট খুলুন, তুলনা করুন।
import tempfile
import numpy as np
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "kestrel.npz"
index.save(path)
reopened = VectorIndex.load(path) # a new object, read from disk
print("same meta:", reopened.meta == index.meta, "| same vectors:", np.array_equal(reopened.matrix, index.matrix))
print("same ranking:", reopened.search(Q, model, 3) == index.search(Q, model, 3))same meta: True | same vectors: True
same ranking: Trueভেক্টরগুলো হুবহু সংরক্ষিত (কোনো রাউন্ডিং নেই), তাই র্যাংকিং একই। অস্থায়ী ফোল্ডার with ব্লকের শেষে মুছে যায়, তাই টেস্ট কিছু ফেলে রাখে না।
একটা লোকাল ভেক্টর ডেটাবেসের অপশন যোগ করুন
ভেক্টর ডেটাবেস ভেক্টরগুলোকে পেলোডসহ রাখে, আর আপনার হয়ে ফিল্টার ও সার্চ করে। qdrant-client কোনো সার্ভার ছাড়াই আপনার Python প্রসেসের ভেতরে লোকাল মোডে চলতে পারে। আমরা নিজেদের ভেক্টর একটা কালেকশনে কপি করি, পেলোড ফিল্টারসহ কোয়েরি করি, তারপর path= দিয়ে ডিস্কে আরেকটা রেখে আবার খুলি। প্রতিটা ক্লায়েন্টে close() ডাকি; নইলে ফোল্ডার লক হয়ে থাকে, আর Windows-এ অস্থায়ী ফোল্ডার মোছা ব্যর্থ হতে পারে।
import tempfile
from qdrant_client import QdrantClient, models
points = [models.PointStruct(id=n, vector=index.matrix[n].tolist(), payload={"pid": pid, **index.payloads[n]})
for n, pid in enumerate(index.ids)]
def fill(client):
client.create_collection("kestrel", vectors_config=models.VectorParams(size=model.dim, distance=models.Distance.COSINE))
client.upsert("kestrel", points=points)
def ask(client, question, status):
only = models.Filter(must=[models.FieldCondition(key="status", match=models.MatchValue(value=status))])
res = client.query_points("kestrel", query=model.embed_batch([question])[0].tolist(), limit=3, query_filter=only)
return [(p.payload["pid"], round(p.score, 3)) for p in res.points]
Q = "How long do I have to return a bike?"
memory = QdrantClient(":memory:")
fill(memory)
print("memory :", ask(memory, Q, "current"))
print("ours :", show(index.search(Q, model, 3, {"status": "current"})))
memory.close()
with tempfile.TemporaryDirectory() as folder:
disk = QdrantClient(path=folder)
fill(disk)
disk.close() # release the folder lock before reopening (and before Windows deletes it)
again = QdrantClient(path=folder)
print("reopened:", ask(again, Q, "superseded"))
again.close()memory : [('kc-returns-v3#s1', 0.555), ('kc-warranty-v2#s1', 0.403), ('kc-returns-v3#s2', 0.383)]
ours : [('kc-returns-v3#s1', 0.555), ('kc-warranty-v2#s1', 0.403), ('kc-returns-v3#s2', 0.383)]
reopened: [('kc-returns-v2#s1', 0.463), ('kc-returns-v2#s2', 0.234)]ইন-মেমরি উত্তর আমাদের উত্তরের সঙ্গে প্যাসেজে প্যাসেজে ও স্কোরে স্কোরে মেলে, কারণ দুটোই কোসাইন সাদৃশ্য নিখুঁতভাবে হিসাব করে। আবার খোলা ডিস্ক কালেকশন সংরক্ষিত ডেটা থেকে উত্তর দেয়; এখানে ওকে superseded স্ট্যাটাস চাওয়া হয়েছে, দেখাতে যে সংরক্ষিত পেলোডেও ফিল্টার চলে। আমরা সারির নম্বরকে পয়েন্ট আইডি করেছি, আর আমাদের নিজের প্যাসেজ আইডি পেলোডে রেখেছি।
টুল নোট (২০২৬-১০-১২ অনুযায়ী):
qdrant-client-এর ভার্সন ছিল 1.19.1 (প্রকাশ ২০২৬-০৯-১৬, Apache-2.0, Python 3.10 বা নতুন)। এর README-তেQdrantClient(":memory:")ওQdrantClient(path=...)(ডিস্কে সংরক্ষিত) দেখানো আছে, আর লোকাল মোডকে ডেভেলপমেন্ট, প্রোটোটাইপ ও টেস্টিংয়ের জন্য উপযোগী বলা হয়েছে। বর্তমান সার্চ মেথডquery_points; পুরোনোsearchমেথড এখনকার সোর্সে নেই। লোকাল মোডে সার্চ নিখুঁত ব্রুট ফোর্স (তাইsearch_params-এর কোনো প্রভাব নেই), আর সোর্স কোডের একটা সতর্কবার্তা, ডকুমেন্টেশন পাতার নয়, বলে ২০,০০০ পয়েন্টের বেশি হলে লোকাল মোড সুপারিশ করা হয় না। লোকাল মোডে হাইব্রিড বা স্পার্স সার্চের ওপর আমরা নির্ভর করি না: ওটা শুধু কোডে পেয়েছি। আলাদাভাবে, Qdrant-এর ডকুমেন্টেশন বলে নিজে ডিপ্লয় করা সার্ভারে ডিফল্টে কোনো অথেনটিকেশন নেই এবং যে কেউ নাগাল পেলেই রিকোয়েস্ট পাঠাতে পারে; সার্ভার খুলে দেওয়ার আগে API-key অথেনটিকেশন চালু করুন। ওপরের ব্লকটা আমরা এই ভার্সনে Python 3.13-এ চালিয়েছি এবং কোনো সতর্কবার্তা দেখিনি। বর্তমান ডকুমেন্টেশন দেখে নিন।
মিনি-ল্যাব: লোকাল সেমান্টিক সার্চ
সবকিছু জুড়ুন। নমুনা কর্পাস ইনডেক্স করুন, সেরা ফলাফল তুলুন, আর টেস্ট দিয়ে আচরণ আটকে দিন। tests/test_index.py টেস্ট ফাইলটা কোর্স কিটের অংশ (এই পাতায় জায়গা হয় না); ফাইলটা পড়ুন এবং চালান। এতে ওপরে ব্যবহৃত আটটা লেবেল করা প্রশ্ন (LABELS তালিকা) আর ১২টা টেস্ট আছে:
- মেটাডেটা মডেলের নাম, ভেক্টরের দৈর্ঘ্য আর ইনজেক্ট করা ক্লক রেকর্ড করে;
- অকেজো টেক্সট বাদ পড়ে, রিপোর্ট হয়, আর মডেলের কাছে কখনো যায় না;
- এমবেডিং ১৬, ১৬ ও ৪-এর ব্যাচে চলে;
- ভিন্ন মডেল প্রত্যাখ্যাত হয়, যতক্ষণ ইনডেক্স নতুন করে বানানো না হয়;
- ফিল্টার র্যাংকিংয়ের আগে চলে (স্ট্যাটাস, ভাষা), আর ফাঁকা প্রশ্ন কিছুই পায় না;
- সেভ করা ইনডেক্স একই র্যাংকিং নিয়ে আবার খোলে;
- চারটা প্রশ্নের সেরা প্যাসেজ একই থাকে, আর recall@1 হলো ০.৭৫ ও recall@5 হলো ০.৮৭৫, ঠিক "try a bike" প্রশ্নটাই মিস হয়।
প্রজেক্ট ফোল্ডার থেকে চালান। pytest-এর শেষ লাইন থেকে চলার সময়টা sed দিয়ে কেটে দিয়েছি, তাই আউটপুট প্রতিবার একই থাকে।
python -m pytest -q tests/test_index.py | sed 's/ in [0-9.]*s//'............ [100%]
12 passedবারোটা টেস্টই পাস করেছে। এগুলো প্রমাণ করে প্লাম্বিং আর এই টয় এমবেডারে এই কর্পাসের র্যাংকিং। আসল এমবেডিং মডেল একই রকম র্যাংক করবে, তা এগুলো প্রমাণ করে না, আর রিকলের পাস করা সংখ্যা প্রমাণ করে না যে কোনো উত্তর সমর্থিত। পরের পাতায় রিট্রিভ করা প্যাসেজ RAG পাইপলাইনে ঢোকানো হবে আর সাইটেশন যাচাই হবে।
কাজের জায়গায় এটা কোথায় দেখা যায়
আক্রার কোয়ামের দল তাদের হেল্প-সেন্টার সার্চের এমবেডিং মডেল আপগ্রেড করল। রাতের ইনডেক্স জবের অর্ধেক নতুন মডেলে চলার পর কেউ টের পেল, আর ইনডেক্সে একই দৈর্ঘ্যের দুই রকম ভেক্টর জমে গেছে। কিছু ক্র্যাশ করেনি; উত্তরগুলো শুধু অদ্ভুত হয়ে গেল। এরপর তারা ইনডেক্সে মডেলের নাম রাখল, মিললে তবেই কোয়েরি করতে দিল, আর রিলিজ চেকলিস্টে ৪০টা আসল প্রশ্নের রিকল চেক যোগ করল। একই মডিউল তাদের আর্কাইভ করা পাতা লুকানোর পরিষ্কার উপায়ও দিল, আর ভাঙা এক্সপোর্টের কারণে খালি হয়ে আসা পাতা বাদ দেওয়ার উপায়ও।
সাধারণ ভুল
- শুধু ভেক্টরের দৈর্ঘ্য মেলানো। দুই মডেলের দৈর্ঘ্য এক হয়েও স্পেস আলাদা হতে পারে। মডেলের নাম ও ভার্সন মেলান, আর বদলালে নতুন করে বানান।
- ফাঁকা বা শুধু স্টপ ওয়ার্ডের টেক্সট এমবেড করা। এতে এমন সারি যোগ হয়, যা কিছুর সঙ্গেই মেলে না, নয়তো সবকিছুর সঙ্গে মেলে। বাদ দিন আর জানান।
- সেরা k নেওয়ার পরে ফিল্টার করা। চাওয়ার চেয়ে কম ফলাফল পান, কখনো একটাও না। আগে ফিল্টার, তারপর র্যাংক।
- ছোট রিকল সংখ্যায় ভরসা করা। আটটা প্রশ্ন স্মোক টেস্ট। যে প্রশ্ন রিপোর্ট করেন, সেগুলোতে টিউন করবেন না, আর রিকলকে সঠিক উত্তরের প্রমাণ ভাববেন না।
- লোকাল মোডকে সার্ভার ভাবা। এটা নিখুঁত সার্চ করে এবং ছোট কালেকশনের জন্য। পরে সার্ভার চালালে মনে রাখুন, ডিফল্টে তাতে অথেনটিকেশন নেই।
নিজে চেষ্টা করুন
- সহজ: চারটা লেবেল করা প্রশ্নের প্রত্যাশিত ডকুমেন্ট এসেছে ১, ২, ৫ নম্বরে, আর চতুর্থটা সেরা ১০-এও নেই। recall@1, recall@3 ও recall@5 হাতে বের করুন।
- মাঝারি: এমবেডিং ও সাদৃশ্য পাতা আবার দেখে লিখুন
confident(index, model, question, min_score=0.3), যা শুধু current স্ট্যাটাসের এবং অন্ততmin_scoreস্কোরের হিট ফেরত দেয়। চেষ্টা করুন "Can I pay in instalments?", "How much does delivery cost?" আর "How do I get my money back?" দিয়ে। - কঠিন: পুরোনোটার সমান আকার ও ভেক্টরের একটা নতুন মডেল ভার্সন ঘোষণা করুন। ইনডেক্স সেভ করুন, আবার খুলুন, দেখান যে নতুন ভার্সন প্রত্যাখ্যাত হয়,
ensure_indexদিয়ে নতুন করে বানান, আবার সেভ করুন, আর ফাইলে নতুন নামটা দেখান।
উত্তর
সহজ। recall@1 = ১/৪ = ০.২৫ (শুধু প্রথম প্রশ্ন)। recall@3 = ২/৪ = ০.৫ (১ ও ২ নম্বর)। recall@5 = ৩/৪ = ০.৭৫ (১, ২ ও ৫ নম্বর)। চতুর্থ প্রশ্ন কোনো ক্ষেত্রেই গোনা হয় না।
মাঝারি। ফাংশনটা স্ট্যাটাস ফিল্টার পার হওয়া হিটগুলোর স্কোরের ওপর একটা ফিল্টার।
def confident(index, model, question, min_score=0.3, k=3):
return [h for h in index.search(question, model, k, {"status": "current"}) if h.score >= min_score]
for question in ("Can I pay in instalments?", "How much does delivery cost?", "How do I get my money back?"):
print(question, "->", show(confident(index, model, question)))Can I pay in instalments? -> []
How much does delivery cost? -> [('kc-shipping-v1#s3', 0.539), ('kc-shipping-v1#s1', 0.532), ('kc-shipping-v1#s2', 0.465)]
How do I get my money back? -> []থ্রেশহোল্ড instalments প্রশ্নে বিরত থাকে, যার উত্তর ডকুমেন্টে নেই: ঠিক। "money back"-এও বিরত থাকে, যার উত্তর আছে (রিটার্ন পাতায়), কারণ টয়ের সঙ্গে তার অক্ষর কম মেলে: এটা মিস। থ্রেশহোল্ড গোলমাল কমায়, কিন্তু দুর্বল রিট্রিভাল সারাতে পারে না।
কঠিন। একই ভেক্টর দৈর্ঘ্য মানে একই পরিচয় নয়।
v2 = Model("toy-trigram-256-v2", 256, model.embed_batch) # same size, same vectors, but declared a new version
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "kestrel.npz"
index.save(path)
old = VectorIndex.load(path)
try:
old.search("battery", v2)
except IndexMismatch as error:
print("refused:", error)
fresh, did_rebuild = ensure_index(old, passages, v2, CLOCK)
fresh.save(path)
print(did_rebuild, VectorIndex.load(path).meta.model)refused: index built by toy-trigram-256 (256 dims), query uses toy-trigram-256-v2 (256 dims): rebuild the index
True toy-trigram-256-v2পাস চেকলিস্ট:
- সহজ: আপনি ০.২৫, ০.৫ ও ০.৭৫ পেয়েছেন এবং বলতে পারেন কেন শেষ প্রশ্নটা কখনো গোনা হয় না।
- মাঝারি: instalments প্রশ্নের জন্য আপনার তালিকা খালি, থ্রেশহোল্ড ভুলভাবে বাদ দিয়েছে এমন একটা উত্তরযোগ্য প্রশ্নের নাম আপনি বলতে পারেন, আর ফিল্টারের কাজ প্রম্পট দিয়ে করাননি।
- কঠিন: এররে দুটো মডেল ভার্সনের নামই আছে,
did_rebuildহলোTrue, আর আবার খোলা ফাইল নতুন নাম দেখায়।
সারসংক্ষেপ
- ইনডেক্সের সঙ্গে মডেলের নাম, ভার্সন, ভেক্টরের দৈর্ঘ্য আর তৈরির সময় রাখুন; অন্য মডেল দিয়ে কোয়েরি প্রত্যাখ্যান করুন, আর মডেল বদলালে নতুন করে বানান।
- নির্দিষ্ট আকারের ব্যাচে এমবেড করুন, টেস্টে কল গুনুন, আর খালি, খুব ছোট ও শুধু স্টপ ওয়ার্ডের টেক্সট রিপোর্টসহ বাদ দিন।
- হাতে লেবেল করা প্রশ্নে recall@k ছোট সেটে একটা স্মোক টেস্ট, বেঞ্চমার্ক নয়, আর সমর্থিত উত্তরের প্রমাণও নয়।
- নিখুঁত সার্চ সরল ও পূর্ণাঙ্গ; আনুমানিক সার্চ কম কাজের বিনিময়ে রিকল ছাড়ে। আমাদের বাকেট মডেল ট্রেড-অফটা দেখায় কিন্তু আসল লাইব্রেরি নয়, আর
qdrant-client-এর লোকাল মোড নিখুঁত এবং ছোট কালেকশনের জন্য। - র্যাংকিংয়ের আগে এবং প্রমাণ ব্যবহারের আগে মেটাডেটা দিয়ে ফিল্টার করুন; সেভ করে আবার খুলে তুলনা করে স্থায়িত্ব প্রমাণ করুন।
এরপর: RAG ইনজেশন ও সাইটেশন প্রবাহ - ডকুমেন্টকে স্থিতিশীল আইডিসহ প্যাসেজে ভাঙা, সেগুলো রিট্রিভ করে প্রম্পটে আনা, আর প্রতিটা সাইটেশন এমন প্যাসেজের দিকে ইঙ্গিত করছে কি না যা উত্তরকে সমর্থন করে তা যাচাই করা।