Part 05 · Building LLM Applications

Embedding Models and Vector Databases

Embedding modelsCosine similarityANNHNSWpgvector

অর্থ দিয়ে search: embedding model, vector তুলনা করা, আর একটা vector database কীভাবে লাখ লাখ vector এর মধ্যে প্রতিটা না দেখেই সবচেয়ে কাছেরগুলো খুঁজে পায়।

⏱ 1-2 ঘণ্টা · Station 37 / 92

জিনিসটা কী, কেন দরকার

একজন customer লিখল "I want my money returned"। তোমার help centre এ একটা article আছে "How to request a refund for a cancelled order"। কোনো শব্দ মেলে না, তাই Part 1 এর project এর keyword search এটাকে 0 দেয়। একজন মানুষ সাথে সাথে দেখে দুইটার মানে একই।

Embedding model computer কে এই ক্ষমতা দেয়। সে যেকোনো text কে কয়েকশো সংখ্যার একটা vector বানায়, এমনভাবে যে একই রকম অর্থের text এর vector একই দিকে মুখ করে, এমনকি ভাষা আলাদা হলেও। Search হয়ে যায় "query র vector এর সবচেয়ে কাছের vector গুলো খোঁজো"। Vector database হলো লাখ লাখ vector এর জন্য এটা দ্রুত করার জন্য বানানো একটা store। দুইটা মিলে RAG, semantic search, recommendation আর duplicate ধরার "retrieval" অংশ।

ভেতরে কীভাবে চলে

Embedding model। একটা transformer encoder (Part 4) text পড়ে, আর তার token vector গুলোর গড় নিয়ে (pooling) একটা vector হয়, সাধারণত 384 থেকে 3,072 টা সংখ্যা, length 1 এ normalise করা। Model টা train হয় এমন text এর জোড়ায় যেগুলো মেলা উচিত (একটা প্রশ্ন আর তার answer, একটা title আর তার article, একটা বাক্য আর তার অনুবাদ): মেলা জোড়া কাছে টানা হয়, না-মেলা গুলো দূরে ঠেলা হয় (contrastive learning)। বহুভাষিক model একসাথে অনেক ভাষায় train হয়, এজন্যই একটা বাংলা document একটা English query র সাথে মিলতে পারে।

মিল। Length 1 এর vector এ cosine similarity শুধু একটা dot product (Part 1)। একটা query র বিপরীতে প্রতিটা document score করা মানে একটা matrix-vector গুণ।

বড় scale এর সমস্যা। দশ লাখ document × 768 সংখ্যা মানে float32 এ 3 GB, আর প্রতিটা query সবগুলোর সাথে মিলাতে হবে। কয়েক হাজারে ঠিক আছে; প্রতি second এ অনেক query তে লাখ লাখের জন্য বেশি ধীর।

Approximate nearest neighbour (ANN) search অল্প একটু নির্ভুলতা দিয়ে অনেক speed কেনে। সবচেয়ে common index, HNSW (hierarchical navigable small world), একটা graph বানায়:

  • প্রতিটা vector তার সবচেয়ে কাছের প্রতিবেশীদের সাথে যুক্ত, সাথে কিছু দূরের link (HNSW এ, উপরের পাতলা layer গুলো দিয়ে);
  • search একটা entry point থেকে শুরু হয় আর যুক্ত যে vector query র বেশি কাছে সেদিকে সরে, সেরা candidate দের একটা ছোট list রেখে (সাইজ ef);
  • যখন কোনো candidate এর প্রতিবেশী আর কাছে না, তখন থামে।

প্রতিটা query লাখের বদলে কয়েকশো vector দেখে। বড় ef = বেশি vector দেখা = বেশি recall (কতবার আসল সবচেয়ে কাছেরটা পায়) = বেশি ধীর। অন্য ধরনের index: IVF (আগে cluster করো, শুধু কাছের cluster এ খোঁজো) আর product quantization (memory বাঁচাতে vector চেপে ছোট করো)।

Vector database (Postgres এ pgvector, Qdrant, Weaviate, Milvus, Pinecone, Elasticsearch/OpenSearch) vector কে তার text আর metadata সহ রাখে, এই index গুলো বানায়, metadata দিয়ে ছাঁকে ("শুধু এই user এর document"), আর document বদলালে index update রাখে।

আগে অনুমান করো

তোমার app কে একটা embedding model থেকে নতুন, ভালো একটায় বদলালে। Vector database এ আগে থেকে থাকা 20 লাখ document vector এর কী করতে হবে?

ছবিতে দেখো

querysearchstart at an entry point; hop to the neighbour closest to the querystop when no neighbour is closer: 8 hops instead of checking everythinglong links cross the space fast; short links refine the answer
Vector গুলো একটা graph এর node, কাছের প্রতিবেশী আর কয়েকটা দূরের node এর সাথে যুক্ত। Search একটা entry point থেকে শুরু হয়ে বারবার সেই প্রতিবেশীর দিকে লাফায় যেটা query র বেশি কাছে, মোট আট লাফ, আর কোনো প্রতিবেশী আর কাছে না হলে থামে। দূরের link জায়গাটা দ্রুত পার করে; কাছের link answer টা শানায়।

আসল embedding দিয়ে search

একটা ছোট বহুভাষিক model (multilingual-e5-small, 384 সংখ্যা, local এ চলছে) থেকে আসল embedding, English আর বাংলায় 8 টা help-centre এর টুকরোর জন্য, English, Banglish আর বাংলা ধাঁচের query দিয়ে খোঁজা। তারপর 20,000 টা বানানো vector, brute force আর তিনটা ef setting এ একটা graph index দিয়ে খোঁজা।

"""Sentence embeddings with a small multilingual model (intfloat/multilingual-e5-small, 384 numbers).

E5 models expect a prefix: "query: " for searches and "passage: " for documents.
Vectors are normalised to length 1, so a dot product is the cosine similarity.
"""
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer

torch.set_num_threads(4)
NAME = "intfloat/multilingual-e5-small"
_tok = AutoTokenizer.from_pretrained(NAME)
_model = AutoModel.from_pretrained(NAME).eval()

def embed(texts, kind="passage", batch=32):
    out = []
    for i in range(0, len(texts), batch):
        enc = _tok([f"{kind}: {t}" for t in texts[i:i + batch]], padding=True, truncation=True,
                   max_length=256, return_tensors="pt")
        with torch.no_grad():
            h = _model(**enc).last_hidden_state
        mask = enc["attention_mask"].unsqueeze(-1).float()
        out.append(F.normalize((h * mask).sum(1) / mask.sum(1), dim=-1))   # mean of token vectors, length 1
    return torch.cat(out).numpy()
"""Search by meaning with real embeddings, then a tiny HNSW-style graph search vs brute force."""
import numpy as np
from embed import embed

DOCS = [
    "How to request a refund for a cancelled order",
    "Money back is credited to your bKash wallet within 3 days",
    "The rider could not find the address and called the customer",
    "Delivery is delayed because of heavy rain in Mirpur",
    "Change the phone number on your account from Settings",
    "OTP not received? Check your SIM signal and try again",
    "অর্ডার বাতিল হলে টাকা কীভাবে ফেরত পাবেন",
    "বৃষ্টির কারণে ডেলিভারি দেরি হচ্ছে",
]
D = embed(DOCS)
print("document matrix:", D.shape, " vector length:", round(float(np.linalg.norm(D[0])), 3))

for q in ["I want my money returned", "why is my food so late", "amar taka kobe ferot pabo", "parcel kokhon ashbe"]:
    s = D @ embed([q], kind="query")[0]                     # cosine similarity with every document
    best = np.argsort(-s)[:2]
    print(f"\nquery: {q}")
    for i in best:
        print(f"  {s[i]:.3f}  {DOCS[i]}")

# Approximate nearest neighbours: why vector databases don't compare against everything.
rng = np.random.default_rng(0)
N, dim = 20000, 384
topics = rng.normal(size=(200, dim))                      # real embeddings cluster by topic
X = (topics[rng.integers(0, 200, N)] + 0.6 * rng.normal(size=(N, dim))).astype(np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)
queries = X[rng.choice(N, 50, replace=False)] + 0.1 * rng.normal(size=(50, dim)).astype(np.float32)
queries /= np.linalg.norm(queries, axis=1, keepdims=True)

exact = [int(np.argmax(X @ q)) for q in queries]          # brute force: every query against all 20,000

# Build once: link every vector to its 16 nearest neighbours (computed in blocks to save memory).
K = 16
neighbours = np.empty((N, K), dtype=np.int64)
for start in range(0, N, 2000):
    sims = X[start:start + 2000] @ X.T
    np.fill_diagonal(sims[:, start:start + 2000], -2)       # not your own neighbour
    neighbours[start:start + 2000] = np.argpartition(-sims, K, axis=1)[:, :K]
# Close neighbours alone leave each topic cluster an island the walk can't leave. A few random
# long-range links per vector make the graph "small world": any cluster is a few hops away.
# (HNSW gets the same effect with sparse upper layers.)
neighbours = np.hstack([neighbours, rng.integers(0, N, (N, 4))])
K = neighbours.shape[1]

def graph_search(q, ef=48, starts=4):
    """Best-first search over the graph, keeping the ef best vectors seen (HNSW's search on one layer)."""
    import heapq
    seen = set(int(i) for i in rng.choice(N, starts, replace=False))
    frontier = [(-float(X[i] @ q), i) for i in seen]       # max-heap by similarity
    heapq.heapify(frontier)
    best = sorted((-s, i) for s, i in frontier)            # (similarity, id), kept to ef items
    while frontier:
        neg, cur = heapq.heappop(frontier)
        if len(best) >= ef and -neg < best[0][0]:
            break                                          # nothing left can beat what we have
        for nb in neighbours[cur]:
            nb = int(nb)
            if nb in seen:
                continue
            seen.add(nb)
            sim = float(X[nb] @ q)
            if len(best) < ef or sim > best[0][0]:
                heapq.heappush(frontier, (-sim, nb))
                best.append((sim, nb)); best.sort()
                best = best[-ef:]
    return best[-1][1], len(seen)

print(f"\n{N:,} vectors of {dim} numbers; brute force compares all {N:,} per query")
for ef in (16, 48, 128):
    results = [graph_search(q, ef=ef) for q in queries]
    recall = np.mean([r[0] == e for r, e in zip(results, exact)])
    print(f"graph search ef={ef:<3}: compares {np.mean([r[1] for r in results]):>5,.0f} per query, finds the true nearest {recall:.0%} of the time")

Output:

document matrix: (8, 384)  vector length: 1.0

query: I want my money returned
  0.882  অর্ডার বাতিল হলে টাকা কীভাবে ফেরত পাবেন
  0.826  How to request a refund for a cancelled order

query: why is my food so late
  0.855  বৃষ্টির কারণে ডেলিভারি দেরি হচ্ছে
  0.816  OTP not received? Check your SIM signal and try again

query: amar taka kobe ferot pabo
  0.840  How to request a refund for a cancelled order
  0.838  Change the phone number on your account from Settings

query: parcel kokhon ashbe
  0.797  বৃষ্টির কারণে ডেলিভারি দেরি হচ্ছে
  0.784  OTP not received? Check your SIM signal and try again

20,000 vectors of 384 numbers; brute force compares all 20,000 per query
graph search ef=16 : compares   275 per query, finds the true nearest 30% of the time
graph search ef=48 : compares   500 per query, finds the true nearest 62% of the time
graph search ef=128: compares 1,008 per query, finds the true nearest 92% of the time

কী দেখলাম:

  • ভাষা পেরিয়ে অর্থ। "I want my money returned" এর সাথে কোনো document এর একটা শব্দও মেলে না, তবু প্রথম দুইটা result বাংলা refund article (0.882) আর English টা (0.826)। Query আর সবচেয়ে ভালো match এমনকি আলাদা ভাষায়।
  • "why is my food so late" প্রথমে বাংলা বৃষ্টিতে দেরির notice পায়। কিন্তু দ্বিতীয় result দেখো: OTP এর article 0.816, সামান্য কম। E5 এর score একটা সরু ঘরে থাকে (মোটামুটি 0.75 থেকে 0.9), তাই কাঁচা score confidence না: তোমার data তে tune করা একটা relevance threshold লাগবে, বা reranking (এই part এর lesson 7)।
  • Banglish দুর্বল। "amar taka kobe ferot pabo" প্রথমে refund article পায়, কিন্তু phone-number এর article এর চেয়ে মাত্র 0.002 বেশি দিয়ে। "parcel kokhon ashbe" যায় দেরির notice এ, তারপর OTP। Training data তে Roman হরফের বাংলা কম; তোমার user দের আসল বানানে test করো।
  • ANN: brute force প্রতি query তে 20,000 টা vector ই মেলায়। Graph search মেলায় 275 থেকে 1,008, 20 থেকে 70 গুণ কম, আর ef বাড়ার সাথে আসল সবচেয়ে কাছেরটা পায় 30%, 62% বা 92% সময়। প্রতিটা vector database এই knob টাই দেয়। (মিস করলে সাধারণত খুব কাছের একটা প্রতিবেশী দেয়, search এর জন্য যেটা প্রায়ই যথেষ্ট; এই খেলনা index আসল HNSW এর চেয়ে সহজ, যেটা একই কাজে বেশি recall পায়।)

Production এ যা মাথায় রাখবে

  • নিজের data তে model বাছো। Public leaderboard (MTEB) দিয়ে শুরু করা যায়; জানা সঠিক answer সহ 50 থেকে 100 টা আসল query বানাও, বাংলা আর Banglish সহ, আর recall মাপো (Part 8)।
  • প্রতিটা vector এর সাথে model এর নাম রাখো, আর বদলালে re-index এর plan রাখো।
  • Embed করার আগে document টুকরো করো: 20 পাতার একটা PDF একটা vector এ রাখলে সবকিছু ঝাপসা হয়ে একটা গড়ে পরিণত হয়। টুকরো করার কৌশল RAG এর অংশ (এই part এর lesson 6)।
  • তোমার latency বাজেটের জন্য ef (বা index এর parameter) tune করো, আর একটা sample এ brute force এর বিপরীতে recall মাপো।
  • Metadata দিয়ে index এর ভেতরেই ছাঁকো, পরে না: "এই tenant এর document এর সেরা 10" যেন "সবার সেরা 10, তারপর অন্য tenant এরগুলো বাদ" না হয় (এতে অস্তিত্ব leak হয় আর কম result আসে; Part 11)।
  • Memory: 1 কোটি × 768 float32 ≈ 31 GB, index এর বাড়তি খরচের আগেই। কম dimension এর model, float16 বা quantised vector এটা 2 থেকে 8 গুণ কমায়।
  • প্রায়ই pgvector ই যথেষ্ট। আগে থেকেই Postgres চালালে আর কয়েক মিলিয়নের কম vector থাকলে, আরেকটা database যোগ করার আগে ওখান থেকে শুরু করো।

এক কথায়

Embedding model text কে এমন একটা vector বানায় যাতে একই রকম অর্থ একই দিকে মুখ করে, ভাষা আলাদা হলেও; search মানে cosine similarity, আর vector database HNSW এর মতো approximate index দিয়ে লাখের বদলে কয়েকশো vector দেখে, অল্প recall এর বদলে speed কেনে।

নিজে করে দেখো

Example এ আরো পাঁচটা document যোগ করো: rider এর আচরণ নিয়ে দুইটা ("the rider was rude"), খাবারের মান নিয়ে দুইটা ("the rice was cold") আর একটা Banglish এ ("rider onek deri korse")। তিনটা query লেখো যাদের সঠিক answer আগেই ঠিক করে রাখবে, আর দেখো প্রতিটা সঠিক answer কত নম্বরে আসে। তারপর 384 dimension এ 50 লাখ document এর memory বের করো float32 আর float16 এ।

Answer

Rank এর result model এর উপর নির্ভর করবে; বিশেষ করে খেয়াল করো Banglish document টা English query তে পাওয়া যায় কিনা, আর উল্টোটা, কারণ এই model সবচেয়ে দুর্বল ওখানেই। Memory: 5,000,000 × 384 × 4 byte ≈ float32 এ 7.7 GB, আর float16 এ তার অর্ধেক, প্রায় 3.8 GB, HNSW graph এর নিজের বাড়তি খরচের আগে (প্রায়ই আরো 10 থেকে 50%)।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →